diff --git a/.agents/skills/service-skill/SKILL.md b/.agents/skills/service-skill/SKILL.md new file mode 100644 index 000000000..565b26c97 --- /dev/null +++ b/.agents/skills/service-skill/SKILL.md @@ -0,0 +1,85 @@ +--- +name: service-skill +description: Use when discussing, designing, or reviewing application Service boundaries purely from business and data-model first principles — concept-only, not code-driven — and when archiving a finalized Service design as long-lived reference material. Inputs are business semantics (entities, interactions, invariants), not source code. Not for bug fixes, refactors, current-code mapping, or module exposure review. +--- + +# Service Design Skill + +Use this skill to reason about application Service boundaries **from concepts only** — entities, aggregates, user interactions, data-model invariants — and to archive finalized designs as reusable reference material. + +## Inputs and outputs + +**Inputs** + +- Business / product description: entities, user interactions, constraints (persistence, visibility, archival, concurrency). +- Optionally a candidate design or an existing archived design under reconsideration. + +**Not used as input** + +- Current source code, existing Service names, DI container wiring, route layer, repository implementations. +- File paths, package layout, prior tooling decisions in any repository. + +**Outputs** + +- A conceptual Service split (Command / Query / Runtime / Repository / Index). +- Interfaces as TypeScript-like pseudocode. +- Decision records for non-obvious choices. +- Optionally: archived files under this skill's `explanation/` / `reference/` / `how-to/` / `tutorial/` trees. + +## When to use + +- Designing a new application Service boundary from scratch. +- Deciding which Service owns a piece of business logic. +- Reviewing an existing **design** (not code) for boundary violations. +- Archiving a finalized design as reusable reference. + +## When NOT to use + +- Bug fixes, code refactors, renaming, single-file edits. +- Mapping current code structure to documentation (use `knowledge-lifecycle__docs-sync`). +- Reviewing module public API / exposure (use `module-review`). +- Implementing or migrating a designed Service (use `plan-lifecycle__*`). + +## Skill Map + +Read by Diátaxis type. + +### Explanation — principles and domain narratives + +- [`explanation/service-design-principles.md`](explanation/service-design-principles.md) — thinking style, the Command / Query / Runtime split, red flags. +- [`explanation/domains/session-workspace.md`](explanation/domains/session-workspace.md) — finalized domain narrative for Session / Workspace. + +### Reference — reusable patterns and per-service contracts + +Patterns (generic Service templates): + +- [`reference/patterns/command-service.md`](reference/patterns/command-service.md) +- [`reference/patterns/query-service.md`](reference/patterns/query-service.md) +- [`reference/patterns/runtime-service.md`](reference/patterns/runtime-service.md) +- [`reference/patterns/repository-and-index.md`](reference/patterns/repository-and-index.md) + +Domains (finalized Service contracts): + +- Session / Workspace: + - [`reference/domains/session-workspace/workspace-service.md`](reference/domains/session-workspace/workspace-service.md) + - [`reference/domains/session-workspace/session-service.md`](reference/domains/session-workspace/session-service.md) + - [`reference/domains/session-workspace/session-query-service.md`](reference/domains/session-workspace/session-query-service.md) + - [`reference/domains/session-workspace/session-runtime-service.md`](reference/domains/session-workspace/session-runtime-service.md) + - [`reference/domains/session-workspace/types.md`](reference/domains/session-workspace/types.md) + +### How-to + +- [`how-to/design-a-service.md`](how-to/design-a-service.md) — design a Service from business facts. +- [`how-to/review-a-service-design.md`](how-to/review-a-service-design.md) — review a candidate or archived design. +- [`how-to/archive-service-design.md`](how-to/archive-service-design.md) — archive a finalized design into this skill. + +### Tutorial + +- [`tutorial/design-session-workspace-services.md`](tutorial/design-session-workspace-services.md) + +## How to read this skill + +- For a quick refresher on the split and rules: `explanation/service-design-principles.md`. +- For a working example: the tutorial, then the Session / Workspace domain narrative and reference files. +- For executing a task: the matching `how-to/` file. +- For writing a new Service from scratch: pick the relevant patterns in `reference/patterns/` and instantiate them in your domain. diff --git a/.agents/skills/service-skill/explanation/domain-migration.md b/.agents/skills/service-skill/explanation/domain-migration.md new file mode 100644 index 000000000..e09bfdeea --- /dev/null +++ b/.agents/skills/service-skill/explanation/domain-migration.md @@ -0,0 +1,258 @@ +# Domain 迁移规范 + +本文是 di-v3 的 **domain 迁移规范**:把现有 `packages/agent-core/src/services//` 逐 domain 迁到 `packages/agent-core/src//`(契约 + 厚实现 + 工具同居)。本文不展开设计取舍,只给出可机械执行的步骤、硬规则与验收命令。 + +本文是 ROADMAP **P3.1–P3.8** 的逐步 recipe(`plan/ROADMAP.md:296-432`)。P3.x worker 应能按本文逐条落地一个 domain 的迁移,不留下任何待定的设计决策。 + +权威来源(本文对它们做规范化整理,命名以本文为准): + +- `plan/PLAN.md:166-195`(§2.3 每个 domain 目录的结构、§2.4 工具注册) +- `plan/ROADMAP.md:9-27`(Global constraints:barrel-only、禁止 re-import/re-export shim、依赖方向 fence) +- `plan/ROADMAP.md:296-432`(P3 阶段,尤其 P3.0 在 `:301-311`、P3 acceptance 在 `:432`) + +## 目录 + +- [结论](#结论) +- [1. 目的与适用](#1-目的与适用) +- [2. 目标目录结构](#2-目标目录结构) +- [3. 依赖方向(fence)](#3-依赖方向fence) +- [4. 迁移步骤(⑦ 步,每步可验证)](#4-迁移步骤-步每步可验证) +- [5. 禁止事项(硬规则)](#5-禁止事项硬规则) +- [6. 每步验证门槛(强制)](#6-每步验证门槛强制) +- [7. 提交规范](#7-提交规范) +- [8. 偏离处理](#8-偏离处理) +- [9. 参考](#9-参考) + +## 结论 + +每迁一个 domain,按下面的硬规则落地: + +- **结构按 PLAN §2.3**:`.ts`(契约)+ `Service.ts`(厚实现)+ support files + `tools/`(如有)+ `index.ts`(barrel)。见 [§2](#2-目标目录结构)。 +- **barrel-only 暴露(强制)**:每层只通过 `index.ts` 暴露公共面;consumer 一律从 barrel 导入(`#/` / `#/_base/` / `@moonshot-ai/agent-core`),禁止 deep-import 子模块。见 [§2](#2-目标目录结构)、[§5](#5-禁止事项硬规则)。 +- **不留旧路径 re-export alias / shim**:consumer 全量改写为新 barrel,旧路径 `services//` 在同一步内删除。没有「deprecated,P9 删除」。见 [§4 步骤⑦](#4-迁移步骤-步每步可验证)、[§5](#5-禁止事项硬规则)。 +- **依赖方向 fence 已 ACTIVE**:`_utils ← _base ← domains`,由 vitest fence(`packages/agent-core/test/dependency-direction.test.ts:608-614`)唯一强制;oxlint 无法表达该规则,**不要**新增 oxlint 近似规则。见 [§3](#3-依赖方向fence)。 +- **新增顶层 barrel 必须显式登记 `imports`**:在 `packages/agent-core/package.json` 的 `imports` 中加 `"#/": "./src//index.ts"`(仿 `#/_base/di`,`:30`),不要依赖 `#/*` 通配。test-only 符号走 `#//test` 子路径。见 [§4 步骤⑥](#4-迁移步骤-步每步可验证)。 +- **每步一提交,提交前过全部门槛**:typecheck + test + fence + 两个 grep(`services/` 0 命中、deep-import 0 命中)。见 [§6](#6-每步验证门槛强制)。 +- **不动 decorator 字符串**:`'coreProcessService'` 等历史字符串 P9 才允许改名。见 [§5](#5-禁止事项硬规则)。 + +## 1. 目的与适用 + +本文管辖的是**现有 domain 的平移式迁移**:把 `packages/agent-core/src/services//` 下的契约、厚实现、support files、tools,按 di-v3 的目标布局搬到 `packages/agent-core/src//`。迁移完成后,目标 domain 内: + +- 契约(接口 + `createDecorator` + sentinel errors)与厚实现(`class XxxService`)**同居**在一个目录; +- 该域提供的工具(如有)落在 `/tools/`; +- 公共面只通过 `/index.ts` barrel 暴露; +- 该域自己的服务注册函数 `registerServices` 与工具注册函数 `registerTools` 由 barrel 导出(命名见 PLAN §2.4,`plan/PLAN.md:180-195`)。 + +本文是 ROADMAP **P3.1–P3.8** 每一步的执行模板(`plan/ROADMAP.md:313-430`)。P3.1–P3.6 各迁一个独立 domain(session / workspace / mcp / skill / terminal / config);P3.7 批量迁剩余 domain;P3.8 删除已空的 `services/`。每步都按 [§4](#4-迁移步骤-步每步可验证) 的 ⑦ 步落地,按 [§6](#6-每步验证门槛强制) 验收。 + +**不在本文管辖**: + +- 新建 domain 的设计(见 `service-design-principles.md`、scope 概念见 `scope-mechanism.md`); +- scope 机制本身(`LifecycleScope` / `registerScopedService` 等,见 `scope-mechanism.md`); +- P4 / P5 的跨 domain wiring 汇总(`bootstrap.ts::registerAllBuiltinTools` 的最终形态 P5 落地;本文只规定每域导出的注册函数名)。 + +## 2. 目标目录结构 + +迁移终态(PLAN §2.3,`plan/PLAN.md:166-178`): + +```text +/ +├── .ts # 契约:IXxxService + createDecorator + sentinel errors +├── Service.ts # 厚实现:class XxxService +├── # 状态机 / scheduler / persistence / parser / provider 适配器 +├── tools/ # 该域提供的工具(如有) +│ └── .ts +└── index.ts # export + registerServices + registerTools +``` + +**barrel-only 暴露(强制)**:每层(`` / `_base/` / `_utils/`)的 `index.ts` 是其唯一公共面。consumer 一律从 barrel 导入: + +```ts +// 允许 +import { IFooService } from '#/foo'; +import { createDecorator } from '#/_base/di'; +import { SomethingPublic } from '@moonshot-ai/agent-core'; + +// 禁止(deep-import 子模块) +import { IFooService } from '#/foo/store'; // 错:绕过 barrel +import { InstantiationService } from '#/_base/di/instantiation'; // 错:绕过 barrel +``` + +公共契约(接口、`createDecorator`、sentinel errors)与对外注册函数(`registerServices` / `registerTools`)从 `/index.ts` 导出;内部文件(store、parser、provider 适配器、未标注为公共的 support file)**不**从 barrel 导出,外部也不得 deep-import。 + +例外:**test-only 符号**走 `#//test` 子路径(如 `#/_base/di/test`,落到 `src/_base/di/test.ts`),不进入生产 barrel,以把 sinon 等 dev-only 依赖隔离在生产面之外。见 [§4 步骤⑥](#4-迁移步骤-步每步可验证)。 + +> P1.6 通配遮蔽提醒:新增顶层 `/index.ts` barrel 后,diff `src/index.ts` 的 `^export` 列表,确认没有顶层命名冲突(同名 export 会被 barrel 通配遮蔽)。 + +## 3. 依赖方向(fence) + +依赖方向 fence 已 ACTIVE 且 green:**`_utils ← _base ← domains`**(`packages/agent-core/test/dependency-direction.test.ts:608-614`,14/14)。P2.6 之后这两条断言扫的是真实 `src/` 树(注释见 `:604-607`),不再是 vacuously-clean 占位。 + +迁移新增 / 修改 `/` 目录时,从第一天起就受这条 fence 约束: + +- 一个 domain **可以** import:`_base/*`、`_utils/*`、自身(barrel 内相对引用)、其他 domain 的**契约 / barrel**(`.ts` 或 `/index.ts`)。 +- 一个 domain **不得** import:另一个 domain 的**具体实现**(`/store`、`Service` 等 impl 文件)。 +- `_base/*` 与 `_utils/*` **不得**反向 import 任何 domain。 + +**唯一强制机制是 vitest fence**。`packages/agent-core/test/dependency-direction.test.ts` 里: + +- `:608-610` `it('di-v3 _utils ← _base ← domains layering is clean (real src)', ...)` — 校验 `_base`/`_utils` 不反向依赖 domain,且方向为 `_utils ← _base ← domains`; +- `:612-614` `it('di-v3 cross-domain impl fence is clean (real src)', ...)` — 校验跨 domain 不 import 具体 impl。 + +**oxlint 不能表达这条规则**。oxlint 1.59.0 没有 `no-restricted-paths`;可用的 `no-restricted-imports` 是全局 specifier 封禁,无法表达「动态 domain 集合」与「barrel-vs-impl」区分(P2.6 已验证)。**不要**为了这条 fence 新增 oxlint 近似规则——它会制造虚假安全感且漏掉 bare specifier / 全部 domain 目标 / 跨 domain impl 规则。fence 测试即权威。 + +迁移中若 fence 转红,按 [§6](#6-每步验证门槛强制) 跑 fence 单测定位违规 import,改成合规路径(barrel 或契约),不要放宽 fence 规则。 + +## 4. 迁移步骤(⑦ 步,每步可验证) + +把 ROADMAP P3.0 的 ①–⑦(`plan/ROADMAP.md:301-311`)落成可执行清单。每步完成后按 [§6](#6-每步验证门槛强制) 跑对应门槛。 + +> 全程优先 `git mv`,保留文件历史。删除用 `git rm`。 + +### ① 建 `src//` + +- 新建 `packages/agent-core/src//`。 +- 用 `git mv` 把 `services//` 下的文件分批迁入(保留历史)。若目标目录已存在(如 session 已有 `SessionHost` / `SessionRepository`,ROADMAP P3.1,`plan/ROADMAP.md:320`),直接迁入并合并。 + +### ② 移契约(`.ts`) + +- 把接口、`createDecorator`、sentinel errors 集中到 `/.ts`。 +- **不改 decorator 字符串**(如 `'coreProcessService'`)。改名是 P9 的事(`plan/ROADMAP.md:23`)。 +- 契约文件本身不应 import 任何 impl(厚实现、store、provider)。契约只允许依赖 `_base/di`(`createDecorator`)、`_base/errors` 等基础面。 + +### ③ 移厚实现(`Service.ts` + support files) + +- 把 `class XxxService` 落到 `/Service.ts`。 +- 状态机 / scheduler / persistence / parser / provider 适配器等 support files 一并迁入 `/`。 +- impl 内部用相对 import 引用本域契约(`./`)与 support files;跨域依赖走 barrel / 契约。 + +### ④ 移工具(`/tools/`) + +- 若该域提供工具,迁到 `/tools/.ts`。 +- 若该域无工具,**跳过**本步并在 `STATUS.md` 记录「无 tools,步骤④跳过」。 + +### ⑤ 写 `src//index.ts` barrel + +- 导出**公共契约**:接口、`createDecorator`、sentinel errors。 +- 导出**注册函数**:`registerServices(accessor)`(服务注册)与 `registerTools(accessor)`(工具注册,形状见 PLAN §2.4,`plan/PLAN.md:180-195`)。 +- **不泄露内部文件**:store、parser、未标注公共的 support file 不导出。 + +barrel 形状(模板,按域替换): + +```ts +// /index.ts(模板) +export { IFooService, FooError } from './foo'; +export { registerFooServices, registerFooTools } from './fooService'; +// 不导出 './foo/store'、'./foo/internalParser' 等内部文件 +``` + +注册函数形状(模板,按域替换): + +```ts +// 服务注册(PLAN §2.4 形状) +export function registerFooServices(accessor: IServiceAccessor): IDisposable { /* ... */ } +// 工具注册 +export function registerFooTools(accessor: IServiceAccessor): IDisposable { /* ... */ } +``` + +最终由 `agent-core/bootstrap.ts::registerAllBuiltinTools(accessor)` 汇总各域 `registerTools`(P5 落地;本规范只规定每域导出名 `registerServices` / `registerTools`)。 + +### ⑥ 更新 import + +- **全量改写**所有 consumer 到 `#/` barrel,**含 `src/` 与 `test/`**。不留旧路径 alias(见 [§5](#5-禁止事项硬规则))。 +- **新增顶层 barrel 必须显式登记 `imports`**:在 `packages/agent-core/package.json` 的 `imports` 中加入 `"#/": "./src//index.ts"`(仿已有 `#/_base/di`,`packages/agent-core/package.json:30`)。`#/*` 通配(`./src/*/index.ts`,`:42-45`)只可靠覆盖单段 `*`,嵌套 barrel(多段路径)vitest 无法解析;**必须**写显式条目,不要依赖通配。 +- **test-only 符号走 `#//test`**(如 `#/_base/di/test` → `src/_base/di/test.ts`,`packages/agent-core/package.json:43` 的 `./src/*.ts` 通配覆盖)。不要把 sinon 等 dev-only 依赖塞进生产 barrel。 + +`package.json` `imports` 条目形状(模板): + +```jsonc +// packages/agent-core/package.json → imports +"#/": "./src//index.ts" +``` + +### ⑦ 删除旧路径 + 验证 + +- 同一步内 `git rm -r packages/agent-core/src/services//`。**不留 re-export alias / shim**(见 [§5](#5-禁止事项硬规则))。 +- 跑 [§6](#6-每步验证门槛强制) 的全部命令:typecheck + test + fence + 两个 grep(`services/` 0 命中、deep-import 0 命中)。 +- 若 `server` 被改:额外跑 server 的 typecheck + test(见 [§6](#6-每步验证门槛强制))。 + +## 5. 禁止事项(硬规则) + +P3.x worker 在迁移中**不得**做以下任一事项。违反任一即视为该 step 未通过: + +- **不留旧路径 re-export alias / shim**。迁移完成后 `services//` 必须消失;consumer 直接从 `#/` barrel 导入。没有「deprecated,P9 删除」(Global constraint,`plan/ROADMAP.md:25-26`)。 +- **不 deep-import**。禁止 `#//store`、`#/_base/di/instantiation` 等绕过 barrel 的 import(`#/foo/test` 子路径除外)。见 [§2](#2-目标目录结构)。 +- **不在 `_base` / `_utils` 反向 import domain**。依赖方向恒为 `_utils ← _base ← domains`。见 [§3](#3-依赖方向fence)。 +- **不绕过 fence**。fence 转红时改合规 import,不许改 fence 规则或加 allowlist 例外(除非 fence 本身需要随新 domain 同步更新规则——这种情况在 `STATUS.md` 显式说明)。见 [§3](#3-依赖方向fence)。 +- **不为 fence 新增 oxlint 规则**。oxlint 无法表达该规则;vitest fence 是唯一强制。见 [§3](#3-依赖方向fence)。 +- **不新增 `it.skip` / `test.skip`**。失败测试修复、删除或拆到后续 step(Global constraint,`plan/ROADMAP.md:21`)。 +- **不动 decorator 字符串**。`'coreProcessService'` 等历史字符串 P9 才允许改名(`plan/ROADMAP.md:23`)。 + +## 6. 每步验证门槛(强制) + +每个 P3.x step 提交前必须通过下列全部命令(字面验收)。任一不通过,按 [§8](#8-偏离处理) 处理,不许擅自放行。 + +```bash +# 1) agent-core 类型检查 +pnpm --filter @moonshot-ai/agent-core typecheck + +# 2) agent-core 全套测试 +pnpm --filter @moonshot-ai/agent-core test + +# 3) 依赖方向 fence(必须 green) +npx vitest run packages/agent-core/test/dependency-direction.test.ts + +# 4) 旧路径 0 命中(无 alias,无例外) +grep -rEn "services/" packages/agent-core/src packages/agent-core/test +# 期望:0 命中 + +# 5) deep-import 0 命中(除 /test 子路径外,禁止 #//) +grep -rEn "#//[a-zA-Z]" packages/agent-core/src packages/agent-core/test +# 期望:0 命中(#/foo/test 不计入;如有命中须改为 #/ barrel 或 #//test) +``` + +**若 `server` 被改**(consumer 落在 `packages/server/`):额外跑 + +```bash +pnpm --filter @moonshot-ai/server typecheck +pnpm --filter @moonshot-ai/server test +``` + +把 `` 替换为本次迁移的域名(如 `session`)。命令 4 / 5 的 grep 是「旧路径清干净 + 无 deep-import」的字面验收,与 fence 互补:fence 管方向,grep 管残留与绕过 barrel。 + +## 7. 提交规范 + +- **Conventional Commits**,scope 用 `agent-core`(Global constraint,`plan/ROADMAP.md:11`)。 +- 建议 message 形状: + + ```text + refactor(agent-core): migrate domain → / + ``` + +- **每步一提交**:一个 domain 一次提交,提交前必须通过 [§6](#6-每步验证门槛强制) 的全部门槛。 +- 提交内容只允许落在该 domain 的迁移范围内(新 `/`、删除 `services//`、consumer import 改写、`package.json` `imports` 条目)。不要夹带无关改动。 + +## 8. 偏离处理 + +若计划与现实冲突(例如某 domain 的实际依赖与本规范不符、某 consumer 无法直接改写到 barrel、某 fence 违规无法用合规 import 解决): + +- **不要擅自变通**。在 phase 状态目录写 `BLOCKER.md`,描述:冲突点、涉及的 `file:line`、尝试过的合规路径、为何走不通。 +- 把偏离记录进该 step 的 `STATUS.md`(决策 / Deviations 段)。 +- 停下,交回 orchestrator 决策。 + +可接受的、**不**算偏离的调整(在 `STATUS.md` 说明即可): + +- domain 目标位置与 ROADMAP 略有出入(如 ROADMAP P3.5 把 terminal 归入 `kaos/terminal.ts`,`plan/ROADMAP.md:374-387`)——按 ROADMAP 该步指定位置落地; +- fence 规则需随新 domain 同步更新(Global constraint 允许,「新增 domain 时同步更新 fence 规则(如需)」,`plan/ROADMAP.md:24`)——在 `STATUS.md` 写明改了哪条规则、为何需要。 + +## 9. 参考 + +- 目标结构 / 工具注册:`plan/PLAN.md:166-195`(§2.3 / §2.4)。 +- 全局硬规则:`plan/ROADMAP.md:9-27`(barrel-only `:25`、禁止 shim `:26`、fence `:24`、禁止 skip `:21`、decorator 字符串 `:23`)。 +- P3 阶段总览:`plan/ROADMAP.md:296-432`(P3.0 `:301-311`,P3 acceptance `:432`)。 +- 依赖方向 fence:`packages/agent-core/test/dependency-direction.test.ts:604-614`。 +- `package.json` `imports`:`packages/agent-core/package.json:29-46`(显式 barrel 条目 `:30-41`,仿 `#/_base/di` `:30`;`#/*` 通配 `:42-45`)。 +- `/test` 子路径先例:`packages/agent-core/src/_base/di/test.ts`(消费方:`import { ... } from '#/_base/di/test'`)。 +- scope 概念:`scope-mechanism.md`(domain 与 scope 正交、context service、manager 模式)。 +- 各 domain 设计文档(按域引用):`kimi-code-dev-2/plan/` 下对应文件(如 `2026.06.22-Session-Domain.md`、`2026.06.22-Workspace-Domain.md`、`2026.06.22-MCP-Domain.md`、`2026.06.22-Skill-Domain.md`、`2026.06.21-Kosong-Kaos-Loop-v2.md` 等;具体路径见 ROADMAP P3.x 各步的「源」字段,`plan/ROADMAP.md:326` 起)。 diff --git a/.agents/skills/service-skill/explanation/domains/file-fs.md b/.agents/skills/service-skill/explanation/domains/file-fs.md new file mode 100644 index 000000000..6ee41ea47 --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/file-fs.md @@ -0,0 +1,655 @@ +# File / Fs / FileStore / WorkspaceFs 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +> 范围说明:ROADMAP M4.7 把 `fs` / `fileStore` / `workspaceFs` 放在同一个 +> step 里确认边界。它们名字都带 “file / fs”,都触及“文件系统”,但**不是同一 +> 个 domain**——本文先把它们拆清楚,再分别确认 query / command / runtime / +> 持久化各自落在哪一层,并说明为什么**不需要**代码拆分。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的文件访问流](#统一的文件访问流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,标题里的 “file / fs” 实际上是**三个相互独立的 service 表面**,跨 +**两个半 domain**,共享 “在磁盘上读 / 写 / 列文件” 的直觉,但真相、键、作用 +域、副作用、对外入口都不同: + +- **fs domain(会话内文件操作)**:在**某个 session 的 cwd 内**对项目树做 + read / list / stat / mkdir / search / grep / git status / git diff / + watch。键是 `(sessionId, relPath)`,所有路径经 `resolveSafePath` 约束在 + `session.metadata.cwd` 内(拒绝绝对路径、`..`、逃逸、越界 symlink)。 + - **query(查询)**:`IFsService.list` / `read` / `stat` / `listMany` / + `statMany`(`fs.ts` / `fsService.ts`)+ `IFsSearchService.search` / + `grep`(`fsSearch.ts` / `fsSearchService.ts`)+ `IFsGitService.status` / + `diff`(`fsGit.ts` / `fsGitService.ts`)。 + - **command(命令)**:`IFsService.mkdir`(fs domain 对 daemon / SDK 的**唯 + 一写入**;其余方法都是只读 / 解析)。 + - **runtime(运行时)**:`IFsWatcher` / `FsWatcherService`(`fsWatcher.ts` + / `fsWatcherService.ts`)——connection-scoped 的活订阅,按 + `(connectionId, sessionId)` 持有 chokidar watcher,debounce / coalesce 后 + 经 `FsWatcherDeliverySink` 投递 `event.fs.changed` 帧。这是 fs domain 的 + 活状态 owner。 + - **infrastructure(基础设施,非 service)**:`resolveSafePath` / + `FsPathEscapesError`(`fsPathSafety.ts`)——被 fs 各 query / command 共用的 + 路径安全 helper,不是 `*Service`。 +- **fileStore domain(全局 blob 存储)**:上传文件的**持久化 blob 库**。键是 + 不透明的 `fileId`(`f_`),blob 落在 `/files/`, + 元数据索引在 `/files/index.json`。**不**按 session 作用域、**不** + 受 cwd 约束、**不**做路径安全校验(消费者拿不到路径,只拿到 `fileId` + + `blobPath`)。 + - **query(查询)**:`IFileStore.get(fileId)` → `{ meta, blobPath }`。 + - **command(命令)**:`IFileStore.save(source, filename, options)` / + `delete(fileId)`。 + - 它本质上是一个 **repository / blob-store contract**(持久化真相反映为磁 + 盘 blob + `index.json`),只是按 service-skill 的“daemon / SDK facade”形 + 状暴露为顶层 `IFileStore`。 +- **workspaceFs(workspace 的 browse / home 表面)**:**不是独立 domain**,而 + 是 **workspace aggregate** 的“浏览宿主文件系统以挑选 workspace root”的读表 + 面。它在 `services/workspace/` 下,输入是**绝对路径**(`browse(absPath?)`, + 非绝对路径抛 `WorkspaceFsNotAbsoluteError`),**不**按 session 作用域、**不** + 受 cwd 约束——它存在的目的恰恰是让用户在**还没有 session / cwd 之前**就能 + 浏览宿主磁盘、选择 workspace root。 + - **query(查询)**:`IWorkspaceFsService.browse(absPath?)` / `home()`。 + `home().recent_roots` 派生自 `IWorkspaceRegistry` 的最近打开列表。 + - **command**:无。workspace 的写表面(注册 / 重命名 / 删除 / root 解析) + 是 `IWorkspaceRegistry`,与 workspaceFs 的读表面在同一 workspace domain + 内分工。 + - 它已被统一的 `IWorkspaceService` facade(`workspace.ts` / + `workspaceService.ts`)吸收:`IWorkspaceService.browse` / `home` 直接委托 + 给 `IWorkspaceFsService`。遗留的 `IWorkspaceFsService` 契约保留给现有消费 + 者。 + +**三者不是同一个 domain,也不需要进一步拆分。** 边界当前就是干净的: + +- `services/fs`(fs domain)只做**会话内、受 cwd 约束**的文件操作 + 活订阅, + 不碰上传 blob、不做绝对路径浏览。 +- `services/fileStore`(fileStore domain)只做**全局 blob 持久化**,不知道 + session / cwd / 项目树,不做路径安全校验。 +- `services/workspace/workspaceFsService.ts`(workspace domain 的 browse / + home 表面)只做**绝对路径浏览 + home + 最近 root**,不写文件、不持订阅、 + 不知道 session。 + +**关系一句话:fs 是会话内文件操作 domain(query + 单写 command + runtime +watcher + 路径安全基础设施),fileStore 是全局 blob 持久化 domain(query + +command 的 repository contract),workspaceFs 是 workspace aggregate 的 +browse / home 读表面(query-only,已被 `IWorkspaceService` 吸收)。三者共享 +“文件系统” 的直觉但不共享真相、不共享键、不共享作用域,不应合并;当前代码已 +按目录 / 层物理分离,无需拆分。** + +接口 / 实现落点见 `services/fs/fs.ts` 的 `IFsService`(fs query + command +facade)、`services/fs/fsService.ts` 的 `FsService`(实现)、 +`services/fs/fsSearch.ts` 的 `IFsSearchService`(fs 搜索 query)、 +`services/fs/fsGit.ts` 的 `IFsGitService`(fs git query)、 +`services/fs/fsWatcher.ts` 的 `IFsWatcher` / `FsWatcherService` +(fs runtime watcher)、`services/fs/fsPathSafety.ts` 的 `resolveSafePath` +(路径安全基础设施)、`services/fileStore/fileStore.ts` 的 `IFileStore` +(fileStore query + command)、`services/fileStore/fileStoreService.ts` 的 +`FileStore`(实现 + blob + `index.json` 持久化)、 +`services/workspace/workspaceFs.ts` 的 `IWorkspaceFsService`(workspace +browse / home query)、`services/workspace/workspaceFsService.ts` 的 +`WorkspaceFsService`(实现)、`services/workspace/workspace.ts` 的 +`IWorkspaceService`(统一 facade,吸收 browse / home)。本文只承载跨 +Service 的概念叙述。 + +## 第一性原理 + +### 1. “file / fs” 这个词指代三件不同的事,不是单一 domain + +“fs” 在代码里同时指三件键 / 作用域 / 真相完全不同的事: + +- **fs(会话内文件操作)**:在某个 session 的 `metadata.cwd` 里读写项目文件。 + 键是 `(sessionId, relPath)`;真相是磁盘上的项目树;每次调用经 + `ISessionService.get(sessionId)` 取 cwd,再经 `resolveSafePath` 把 + `relPath` 约束在 cwd 内。生命周期跟随 session(cwd 一变,所有相对路径的含 + 义跟着变)。 +- **fileStore(全局 blob)**:上传文件的持久化库。键是 `fileId`;真相是 + `/files/` blob + `index.json`;与 session / cwd 完全 + 无关。生命周期由上传 / 删除驱动,blob 可设 `expires_at`。 +- **workspaceFs(workspace 浏览)**:在宿主磁盘上按**绝对路径**浏览目录 + + 取 home / 最近 root。键是 `absPath`;真相是宿主文件系统(无 cwd 约束);用 + 于 session 创建**之前**的 workspace root 选择。生命周期与 workspace 注册 + 表绑定(`recent_roots` 派生自 `IWorkspaceRegistry`)。 + +因此它们不是 “一个 file domain 的三个角色”,而是**两个独立 domain(fs / +fileStore)+ workspace aggregate 的一个读表面**。把它们合并成一个 “file / +fs domain” 会混淆三种完全不同的键(`sessionId+relPath` / `fileId` / +`absPath`)、作用域(cwd 内 / 全局 / 宿主绝对路径)和真相(项目树 / blob +库 / 宿主磁盘)。 + +### 2. fs 是会话内文件操作 domain,query / command / runtime 各就其位 + +fs domain 的五个文件对应 service-skill 角色表里的三个角色 + 一个基础设施: + +| 角色 | 契约 | 实现 | 职责 | +|---|---|---|---| +| query | `IFsService`(`list` / `read` / `stat` / `listMany` / `statMany` / `resolveDownload` / `resolvePath`) | `FsService` | 会话内项目树的只读读模型 + 路径解析 | +| query | `IFsSearchService`(`search` / `grep`) | `FsSearchService` | 文件名 / 内容搜索(spawn 子进程) | +| query | `IFsGitService`(`status` / `diff`) | `FsGitService` | git porcelain / numstat 解析 | +| command | `IFsService.mkdir` | `FsService.mkdir` | 会话内**唯一**写操作 | +| runtime | `IFsWatcher` | `FsWatcherService` | connection-scoped 活订阅 + `event.fs.changed` 投递 | +| infrastructure | `resolveSafePath` / `FsPathEscapesError` | `fsPathSafety.ts` | 路径安全约束(非 service) | + +按 [Domain decomposition](../../../../../packages/agent-core/src/services/AGENTS.md) +的规范:“不是每个 domain 都需要五件套,仅当某角色有明确 owner 且契约非空时才 +引入”。 + +- **fs 的 query 已经是三个 service 上的只读方法。** `list` / `read` / + `stat` / `search` / `grep` / `status` / `diff` 全部无副作用,scope 固定为 + 单 session(每个方法第一个参数都是 `sessionId`),无跨 session 读模型。它 + 们**就是** fs domain 的 query 角色,按职责(核心 / 搜索 / git)拆成三个 + service 是**实现内聚**,不是角色分裂——再抽一个统一的 `IFsQueryService` + 反而把核心 IO / 子进程搜索 / git 解析三种实现压进一个接口。 +- **fs 的 command 只有 `mkdir` 一个方法。** 它是 fs domain 对 daemon / SDK + 的唯一写入入口;fs 没有 create / update / archive / fork 等生命周期族(项 + 目文件不是 aggregate,没有生命周期)。`mkdir` 与 query 共用 `IFsService` + 不构成 muddle(见下一条),不需要为它单独抽 `IFsCommandService`。 +- **fs 的 runtime 是 `IFsWatcher`,不是 facade。** `FsWatcherService` 持有 + 活的 `(connectionId → sessionId → WatchedSession)` map,管理 chokidar + watcher、debounce / coalesce、按 connection 的 path 限额( + `FsWatchLimitError`),经 `FsWatcherDeliverySink` 投递 `event.fs.changed` + 帧。它对齐 [`runtime-service.md`](../../reference/patterns/runtime-service.md) + 描述的“由进程内对象 / 事件流推导的活状态”的 owner,从不出现在 SDK 读模型 + 里。 +- **fs 的路径安全是基础设施,不是 service。** `resolveSafePath`( + `fsPathSafety.ts:38`)是纯函数 + 错误类,被 `FsService` / + `FsSearchService` 的每个方法调用,不持有状态、不是 `*Service` DI 单例。 + +### 3. fs 的 query 与 `mkdir` command 共用 `IFsService` 不构成 muddle + +`list` / `read` / `stat` / `listMany` / `statMany` / `resolveDownload` / +`resolvePath`(query)与 `mkdir`(command)共用一个 `IFsService` 接口,但这 +是同一个会话内文件操作 facade 上的独立方法: + +- 实现互不调用:`FsService.list`(`fsService.ts:60`)/ `read`(`:163`)/ + `stat`(`:269`)/ `mkdir`(`:331`)各自经 `resolveSafePath` 取安全路径 + 后直接操作 `node:fs`,互不调用对方的业务方法。 +- 共享的只是“session → cwd → resolveSafePath → node:fs”这条管道: + `this.sessions.get(sessionId)` + `resolveSafePath(cwd, req.path)`。这条管 + 道是会话内文件操作的基础设施,不是 query 或 command 的业务逻辑。 +- AGENTS.md 的 “command / query 角色不互相调用业务方法” 针对的是**实现耦 + 合**,不是**接口同址**。`IFsService` 的方法满足这条规则。 + +真正的角色分离(query+command facade vs runtime watcher vs path-safety +helper)已经按文件物理分离,没有重叠或渗漏(见 DR6)。 + +### 4. fileStore 是全局 blob 持久化 domain,不是 fs 的“写那一半” + +fileStore 与 fs 都“写文件到磁盘”,但二者本质不同: + +- **键不同**:fs 用 `(sessionId, relPath)`(用户在项目树里指定的相对路 + 径);fileStore 用自己生成的 `fileId`(`f_`,消费者对路径无感)。 +- **作用域不同**:fs 受 `session.metadata.cwd` 约束;fileStore 全局,写在 + `/files/` 下,不知道任何 session。 +- **真相不同**:fs 的真相是用户项目树(用户直接拥有、直接编辑);fileStore + 的真相是上传 blob 库(agent / SDK 通过 `IFileStore` 间接拥有,用户按 + `fileId` 引用)。 +- **生命周期不同**:fs 的“文件”跟随项目(用户 / git 管理);fileStore 的 + blob 有 `expires_at`、可 `delete`,是上传附件 / 媒体的中转存储。 + +把 fileStore 并进 fs 会把“项目文件”与“上传 blob”混为一谈:fs 的 `mkdir` +是相对路径、cwd 内、用户可见;fileStore 的 `save` 是流式上传、全局、按 +`fileId` 引用、有大小限额(`DEFAULT_MAX_UPLOAD_BYTES` = 50MB)和过期。二者 +的写语义完全不同,不能共用 `write` / `save` 接口。 + +### 5. workspaceFs 是 workspace aggregate 的 browse / home 表面,不是 fs 的一种 scope + +workspaceFs 与 fs 都“列目录”,但二者也本质不同: + +- **路径语义相反**:fs **拒绝**绝对路径(`resolveSafePath` 对 + `path.isAbsolute(inputPath)` 抛 `FsPathEscapesError('absolute')`, + `fsPathSafety.ts:47`);workspaceFs **要求**绝对路径(`browse` 对非绝对 + 路径抛 `WorkspaceFsNotAbsoluteError`,`workspaceFsService.ts:33`)。 +- **作用域相反**:fs 必须 scoped 到 session cwd(无 session 即无 fs); + workspaceFs **故意不** scoped 到 session——它用于 session 创建**之前**浏 + 览宿主磁盘、挑选 workspace root。 +- **写能力相反**:fs 有 `mkdir`(写);workspaceFs 是 query-only(browse / + home 都是只读)。 +- **归属不同**:fs 是独立 domain;workspaceFs 住在 `services/workspace/`, + 是 workspace aggregate 的读表面,已被 `IWorkspaceService` 统一 facade 吸 + 收(`IWorkspaceService.browse` / `home` 委托 `IWorkspaceFsService`, + `workspaceService.ts:60-66`)。 + +把 workspaceFs 并进 fs 会制造矛盾:同一个 service 既要“拒绝绝对路径、约束 +在 cwd”又要“要求绝对路径、浏览宿主任意目录”。这两种语义不能共存于一个路 +径模型。workspaceFs 属于 workspace domain(它返回的 `FsBrowseEntry` 带 +`is_git_repo` / `branch`,是 workspace root 选择的视图),不属于 fs +domain。 + +### 6. 三者互不引用;向上各自由独立 transport 消费 + +fs / fileStore / workspaceFs 之间**没有任何 import**(`grep` 对三目录交叉 +引用为空)。它们各自向上被独立的 transport 表面消费: + +- fs → `packages/server/src/routes/fs.ts`(`IFsService` / `IFsSearchService` + / `IFsGitService`,REST 路由)。 +- fs runtime watcher → `packages/server/src/start.ts`(`IFsWatcher` / + `FsWatcherService` / `createConnectionLookup`,连接级投递 wiring)。 +- fileStore → `packages/server/src/routes/files.ts`(上传 / 下载 / 删除)+ + `packages/server/src/routes/prompts.ts`(`resolvePromptMediaFiles` 按 + `fileId` 解析媒体)。 +- workspaceFs → `packages/server/src/routes/workspaceFs.ts` + (`IWorkspaceFsService.browse` / `home`)。 + +这条“互不引用 + 各自独立 transport 表面”的边界是“是否需要拆分 / 合并”的 +硬指标:只要三者不共享真相、不互相调用、不共享 transport,三类关注点就是 +清晰的,不需要代码拆分。 + +## Service 拆分概览 + +| Service / 角色 | 一句话职责 | 角色 | Domain | +|---|---|---|---| +| `IFsService` | 会话内文件核心 facade:`list` / `read` / `stat` / `listMany` / `statMany` / `resolveDownload` / `resolvePath`(query)+ `mkdir`(command) | query + command(facade) | fs | +| `FsService` | `IFsService` 实现:session → cwd → `resolveSafePath` → `node:fs`,gitignore matcher 缓存,二进制 / mime / etag 推导 | query + command(impl) | fs | +| `IFsSearchService` / `FsSearchService` | 会话内文件名 / 内容搜索:`search` / `grep`(spawn 子进程 + gitignore) | query(impl) | fs | +| `IFsGitService` / `FsGitService` | 会话内 git 读模型:`status` / `diff`(porcelain / numstat 解析 + PR 缓存) | query(impl) | fs | +| `IFsWatcher` / `FsWatcherService` | fs 活订阅 runtime:connection-scoped watcher + debounce / coalesce + `event.fs.changed` 投递 + path 限额 | runtime(impl) | fs | +| `resolveSafePath` / `FsPathEscapesError`(`fsPathSafety.ts`) | 路径安全基础设施:拒绝空 / 绝对 / `..` / 越界 / symlink 越界 | infrastructure(非 service) | fs | +| `IFileStore` | 全局 blob 持久化 facade:`save` / `delete`(command)+ `get`(query) | query + command(repository contract) | fileStore | +| `FileStore` | `IFileStore` 实现:`/files/` blob + `index.json` 元数据索引 + 大小限额 + 过期 | query + command(impl + 持久化) | fileStore | +| `IWorkspaceFsService` / `WorkspaceFsService` | workspace browse / home 读表面:`browse(absPath?)` / `home()`(绝对路径、非会话作用域) | query(impl) | workspace | +| `IWorkspaceService` / `WorkspaceService` | workspace 统一 facade:registry + root 解析 + recent + **browse / home**(委托 `IWorkspaceFsService`) | query + command(facade,workspace domain) | workspace | + +> 只有这些角色。**不为 fs 引入统一的 `IFsQueryService` / 单独的 +> `IFsCommandService`**——fs 的 query 已按职责(核心 / 搜索 / git)拆成三 +> 个 service,`mkdir` 是唯一的写、与 query 共用 `IFsService` 不构成 muddle, +> 再拆一层只是同名复制 + 管道复制。**不为 fileStore 拆 command / query** +> ——`IFileStore` 只有 `save` / `delete` / `get` 三个方法,是 repository +> contract 的直接暴露,拆成两个接口不带来新契约。**不把 workspaceFs 从 +> workspace 拆进 fs**——workspaceFs 的“绝对路径、非会话作用域、query-only” +> 语义与 fs 的“相对路径、cwd 内、可写”语义相反,它是 workspace aggregate +> 的读表面,已被 `IWorkspaceService` 吸收。 +> 共享类型(`FsEntry` / `FsListRequest` / `FsReadResponse` / `FsGitStatus` / +> `FsChangeEntry` / `FileMeta` / `FsBrowseEntry` / `FsBrowseResponse` / +> `FsHomeResponse` 等)见 `@moonshot-ai/protocol`。 + +模式参考: + +- query 侧对齐 [`query-service.md`](../../reference/patterns/query-service.md) + 的**只读 list / get 语义**:fs 的 `IFsService` / `IFsSearchService` / + `IFsGitService`、fileStore 的 `IFileStore.get`、workspaceFs 的 + `IWorkspaceFsService.browse` / `home` 都是只读读模型入口;但三者 scope + 各不同(单 session / 全局 fileId / 绝对路径)、无跨 scope 的统一分页 / + search / count,所以**不套用**完整的 `BaseQuery` + scope 便捷方法骨架。 +- command 侧对齐 [`command-service.md`](../../reference/patterns/command-service.md) + 的**唯一写入入口**语义:fs 的 `mkdir`、fileStore 的 `save` / `delete` 各 + 自是其 domain 的写入入口;但 fs 没有 create / update / archive / fork 生命 + 周期族(项目文件不是 aggregate),fileStore 是 blob 中转(无 lifecycle), + 所以**不套用**完整的 `ICommandService` 生命周期骨架。 +- runtime 侧对齐 [`runtime-service.md`](../../reference/patterns/runtime-service.md) + 描述的“由进程内对象 / 事件流推导的活状态”的 owner:`FsWatcherService` 持 + 有 connection-scoped 的活 watcher,由 chokidar 事件推导 `event.fs.changed` + 帧向外投递;它不是 daemon / SDK 的 query / command facade。 + +## 统一的文件访问流 + +### fs:会话内 read(query) + +```text +routes/fs.ts read + └─ IFsService.read(sessionId, req) + ├─ ISessionService.get(sessionId) // 取 session.metadata.cwd + ├─ resolveSafePath(cwd, req.path) // 约束在 cwd 内;拒绝绝对 / .. / 越界 + ├─ fs.stat(safe.absolute) // 大小 / 类型校验 + ├─ detectBinary(sample) // 二进制嗅探 + └─ readFileRange(...) → FsReadResponse // content / encoding / etag / mime +``` + +要点: + +- 每一次 fs 调用都重新 `sessionId → cwd → resolveSafePath`;cwd 是 session + 的真相,路径约束是 fs 的基础设施,二者都不缓存相对路径的语义。 +- fs 不写真相(除 `mkdir`);项目树由用户 / git 拥有,fs 只是受限的读写窗 + 口。 + +### fs:会话内 mkdir(command,唯一写) + +```text +routes/fs.ts mkdir + └─ IFsService.mkdir(sessionId, req) + ├─ ISessionService.get(sessionId) + ├─ resolveSafePath(cwd, req.path) + └─ fs.mkdir(safe.absolute, { recursive }) // EEXIST → FsAlreadyExistsError +``` + +要点: + +- `mkdir` 是 fs domain 对 daemon / SDK 的唯一写入。它与 query 共用 + `IFsService`,但实现独立(不调任何 query 方法),共享的只是 session → + cwd → resolveSafePath 管道。 + +### fs:活订阅 watcher(runtime) + +```text +server start + └─ FsWatcherService + createConnectionLookup(getConnection) // 连接级投递 wiring + +订阅 + └─ IFsWatcher.addPaths(sessionId, connectionId, absPaths) + ├─ 按 (connectionId, sessionId) 建 WatchedSession(chokidar) + ├─ maxPathsPerConnection 闸门(FsWatchLimitError) + └─ chokidar 'change'/'add'/'unlink' → debounce / coalesce + └─ FsWatcherDeliverySink.send({ type: 'event.fs.changed', ... }) + +取消 + └─ IFsWatcher.removePaths(...) / forgetConnection(connectionId) +``` + +要点: + +- `FsWatcherService` 是 fs domain 唯一的 runtime owner:活 watcher 由它持 + 有,事件由它 debounce / coalesce,投递经注入的 `FsWatcherConnectionLookup` + 解析连接 sink。它不出现在 SDK 读模型里,query / command 也不调它。 + +### fileStore:上传 blob(command)+ 按 fileId 读取(query) + +```text +routes/files.ts upload + └─ IFileStore.save(source, filename, options) + ├─ ensureIndex() // 加载 /files/index.json + ├─ fileId = `f_${ulid()}` + ├─ pipeline(source, createWriteStream(blobPath)) // 流式 + maxUploadBytes 闸门 + └─ indexCache.set(fileId, meta) + writeIndex() // 原子 rename 写索引 + +routes/files.ts download + └─ IFileStore.get(fileId) → { meta, blobPath } + └─ createReadStream(blobPath) // 消费者拿不到真实路径,只按 fileId + +routes/prompts.ts resolvePromptMediaFiles + └─ IFileStore.get(fileId) → { meta, blobPath } // 媒体按 fileId 解析进 prompt +``` + +要点: + +- `FileStore` 是 fileStore domain 的唯一 owner:blob 落在 + `/files/`,元数据在 `index.json`,写索引用 tmp + + rename 保证原子性。 +- fileStore 不知道 session / cwd / 项目树;消费者按 `fileId` 引用,拿不到也 + 不需要真实路径。这是它与 fs “项目文件” 的根本区别。 + +### workspaceFs:绝对路径 browse + home(workspace query 表面) + +```text +routes/workspaceFs.ts browse + └─ IWorkspaceFsService.browse(absPath?) + ├─ target = absPath ?? os.homedir() + ├─ isAbsolute(target) 校验(非绝对 → WorkspaceFsNotAbsoluteError) + ├─ fsp.realpath(target) + fsp.readdir(dirOnly) + └─ 每个子目录 detectGit(childAbs) → FsBrowseEntry{ is_git_repo, branch } + +routes/workspaceFs.ts home + └─ IWorkspaceFsService.home() + ├─ os.homedir() + └─ IWorkspaceRegistry.list() → recent_roots(cap RECENT_ROOTS_LIMIT) +``` + +要点: + +- workspaceFs 是 workspace aggregate 的读表面:它**要求**绝对路径、**不** + scoped 到 session,用于 session 创建之前挑选 workspace root。 +- 它已被 `IWorkspaceService` 统一 facade 吸收:`WorkspaceService.browse` / + `home` 直接委托 `IWorkspaceFsService`(`workspaceService.ts:60-66`)。新代 + 码应依赖 `IWorkspaceService`,遗留 `IWorkspaceFsService` 保留给现有消费 + 者。 + +## 关键场景 + +### 场景 A:在 session 内读一个项目文件(fs query) + +```ts +fsService.read(sid, { path: 'src/index.ts', encoding: 'utf-8', offset: 0, length: 65536 }); +``` + +内部解析:`FsService.read`(`fsService.ts:163`)→ +`this.sessions.get(sid)` 取 `metadata.cwd` → `resolveSafePath(cwd, +'src/index.ts')` 约束在 cwd 内 → `fs.stat` 校验大小 / 非目录 → +`detectBinary` 嗅探 → `readFileRange` → `FsReadResponse`(content / etag / +mime / language_id)。无写入、无 watcher、不碰 fileStore。 + +### 场景 B:在 session 内创建目录(fs command) + +```ts +fsService.mkdir(sid, { path: 'src/new-dir', recursive: true }); +``` + +内部解析:`FsService.mkdir`(`fsService.ts:331`)→ 同样的 session → cwd → +`resolveSafePath` 管道 → `fs.mkdir(safe.absolute, { recursive })`;`EEXIST` +映射为 `FsAlreadyExistsError`,`ENOENT`/`ENOTDIR` 映射为 `FsPathNotFoundError`。 +这是 fs domain 唯一的写操作;实现不调用任何 query 方法。 + +### 场景 C:订阅 session 内某目录的文件变化(fs runtime) + +```ts +fsWatcher.addPaths(sid, connId, ['/abs/proj/src']); +// …chokidar 事件… → connection 收到 { type: 'event.fs.changed', payload: { changes, ... } } +``` + +内部解析:`FsWatcherService.addPaths` 按 `(connId, sid)` 建 / 复用 +`WatchedSession`(chokidar),受 `maxPathsPerConnection` 闸门;事件经 +debounce / coalesce 后由注入的 `FsWatcherDeliverySink` 投递 +`event.fs.changed` 帧。`forgetConnection(connId)` 在连接断开时清理。这是 +runtime 角色,与 query / command 互不调业务方法。 + +### 场景 D:上传一个文件并拿回 fileId(fileStore command) + +```ts +const meta = await fileStore.save(readableStream, 'avatar.png', { mimeType: 'image/png' }); +// meta.id = 'f_01J…';后续按 meta.id 引用 +``` + +内部解析:`FileStore.save`(`fileStoreService.ts:46`)→ `ensureIndex` 加载 +`/files/index.json` → 生成 `f_` → `pipeline(source, +createWriteStream(blobPath))` 流式写入,超 `DEFAULT_MAX_UPLOAD_BYTES` 抛 +`FileTooLargeError` 并清理半成品 → `indexCache.set` + `writeIndex`(tmp + +rename 原子)。与 session / cwd 完全无关。 + +### 场景 E:按 fileId 读取 / 删除 blob(fileStore query / command) + +```ts +const { meta, blobPath } = await fileStore.get('f_01J…'); // query +await fileStore.delete('f_01J…'); // command +``` + +内部解析:`get`(`fileStoreService.ts:106`)从 `indexCache` 取 meta,校验 +blob 存在(缺失则清理索引并抛 `FileNotFoundError`),返回 `{ meta, +blobPath }`;`delete`(`:128`)删索引 + `fsp.unlink(blobPath)` + `writeIndex`。 +消费者按 `fileId` 引用,不直接操作路径。 + +### 场景 F:session 创建前浏览宿主磁盘挑选 workspace root(workspaceFs query) + +```ts +const { path, parent, entries } = await workspaceFs.browse('/Users/me/projects'); +// entries: FsBrowseEntry[](dirOnly,每个带 is_git_repo / branch) +const { home, recent_roots } = await workspaceFs.home(); +``` + +内部解析:`WorkspaceFsService.browse`(`workspaceFsService.ts:30`)校验 +`isAbsolute` → `realpath` + `readdir(dirOnly)` → 每个子目录 `detectGit` → +`FsBrowseResponse`;`home`(`:76`)取 `os.homedir()` + `registry.list()` 派 +生 `recent_roots`。这是 workspace aggregate 的读表面,**不** scoped 到 +session,**要求**绝对路径——与 fs 的“相对路径、cwd 内”语义相反。新代码经 +`IWorkspaceService.browse` / `home` 访问同一能力。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 / 入口 | 角色 | Domain | +|---|---|---|---| +| 列出 session 内目录 | `fsService.list(sid, req)` / `listMany` | query(facade) | fs | +| 读 session 内文件 | `fsService.read(sid, req)` | query(facade) | fs | +| stat session 内路径 | `fsService.stat(sid, req)` / `statMany` | query(facade) | fs | +| 解析下载元数据 | `fsService.resolveDownload(sid, relPath)` | query(facade) | fs | +| 解析相对路径 | `fsService.resolvePath(sid, relPath)` | query(facade) | fs | +| 创建 session 内目录 | `fsService.mkdir(sid, req)` | command(facade) | fs | +| 搜索文件名 / 内容 | `fsSearchService.search(sid, req)` / `grep` | query | fs | +| git 状态 / diff | `fsGitService.status(sid, req)` / `diff` | query | fs | +| 订阅文件变化 | `fsWatcher.addPaths(sid, connId, absPaths)` | runtime | fs | +| 取消订阅 | `fsWatcher.removePaths(...)` / `forgetConnection(connId)` | runtime | fs | +| 路径安全校验 | `resolveSafePath(cwd, relPath)`(`fsPathSafety.ts`) | infrastructure | fs | +| 上传文件 | `fileStore.save(source, filename, options)` | command | fileStore | +| 按 fileId 读 blob | `fileStore.get(fileId)` → `{ meta, blobPath }` | query | fileStore | +| 删除 blob | `fileStore.delete(fileId)` | command | fileStore | +| 浏览宿主目录 | `workspaceFsService.browse(absPath?)` / `IWorkspaceService.browse` | query | workspace | +| 取 home + 最近 root | `workspaceFsService.home()` / `IWorkspaceService.home` | query | workspace | +| REST 路由(fs) | `packages/server/src/routes/fs.ts` | transport | fs | +| REST 路由(fileStore) | `packages/server/src/routes/files.ts` + `routes/prompts.ts` | transport | fileStore | +| REST 路由(workspaceFs) | `packages/server/src/routes/workspaceFs.ts` | transport | workspace | +| watcher wiring | `packages/server/src/start.ts`(`IFsWatcher` + `createConnectionLookup`) | transport / runtime | fs | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service (daemon / SDK facade) + IFsService (fs query + command — 会话内 list/read/stat/mkdir/resolve) + IFsSearchService (fs query — 会话内 search/grep) + IFsGitService (fs query — 会话内 git status/diff) + IFileStore (fileStore query + command — 全局 blob save/get/delete) + IWorkspaceService (workspace facade — registry + root + recent + browse/home) + IWorkspaceFsService (workspace query — 绝对路径 browse/home,被 IWorkspaceService 吸收) + +Runtime (in-process, connection-scoped) + IFsWatcher / FsWatcherService (fs runtime — chokidar 活订阅 + event.fs.changed 投递) + +Infrastructure (not *Service) + resolveSafePath / FsPathEscapesError (fs 路径安全 helper — cwd 内约束) + detectGit (workspaceFs 的 git repo 嗅探 helper) + +Persistence / Truth + 用户项目树 (session.metadata.cwd 下) (fs 真相 — 用户 / git 拥有) + /files/ + index.json (fileStore 真相 — blob + 元数据索引) + IWorkspaceRegistry (workspace 真相 — 注册表;workspaceFs.home 的 recent_roots 来源) + +Transport (above agent-core) + packages/server/src/routes/fs.ts (fs REST) + packages/server/src/routes/files.ts (fileStore REST) + packages/server/src/routes/prompts.ts (fileStore 媒体解析) + packages/server/src/routes/workspaceFs.ts (workspaceFs REST) + packages/server/src/start.ts (fs watcher wiring) +``` + +依赖关系: + +```text +IFsService.* → ISessionService.get + resolveSafePath + node:fs (query/command → session cwd + 路径安全) +IFsSearchService.* → ISessionService.get + spawn + node:fs (query → 子进程搜索) +IFsGitService.* → ISessionService.get + git child_process (query → git 解析) +IFsWatcher → chokidar + FsWatcherDeliverySink (runtime → 活订阅 + 投递) +IFileStore.save/get/delete → IEnvironmentService.homeDir + node:fs + index.json (query/command → 全局 blob 持久化) +IWorkspaceFsService.browse → node:fs + detectGit (query → 宿主磁盘,绝对路径) +IWorkspaceFsService.home → os.homedir + IWorkspaceRegistry (query → 派生 recent_roots) +IWorkspaceService.browse/home → IWorkspaceFsService (facade 委托) +routes/fs.ts → IFsService / IFsSearchService / IFsGitService (transport → fs) +routes/files.ts → IFileStore (transport → fileStore) +routes/prompts.ts → IFileStore (transport → fileStore 媒体) +routes/workspaceFs.ts → IWorkspaceFsService (transport → workspaceFs) +start.ts → IFsWatcher / FsWatcherService / createConnectionLookup (transport → fs runtime wiring) +``` + +禁止的边界: + +```text +services/fs/** ⇄ services/fileStore/** (fs 与 fileStore 互不引用) +services/fs/** ⇄ services/workspace/** (fs 与 workspaceFs 互不引用) +services/fileStore/** ⇄ services/workspace/** (fileStore 与 workspace 互不引用) +services/fileStore/** → ISessionService / session cwd (fileStore 不知道 session / cwd) +services/workspaceFs → ISessionService / resolveSafePath (workspaceFs 不 scoped 到 session,不做 cwd 路径安全) +services/fs (query) → IFsWatcher 业务方法 (query/command 不调 runtime 业务方法;watcher 独立 wiring) +IFsWatcher → services/fs query/command (runtime 不回调 facade) +IWorkspaceService → (复制 browse/home 实现) (facade 只委托,不复制 workspaceFs 逻辑) +``` + +关键不变量: + +- fs / fileStore / workspaceFs 三目录之间**零 import**(`grep` 交叉引用为 + 空)。三者共享的只是 `@moonshot-ai/protocol` 的协议类型与 `node:fs`,不共 + 享任何 service / 状态 / 真相。 +- fs 的真相是 `session.metadata.cwd` 下的项目树(用户 / git 拥有); + fileStore 的真相是 `/files/` blob + `index.json` + (fileStore 拥有);workspaceFs 没有自己的真相(`home().recent_roots` 派 + 生自 `IWorkspaceRegistry`)。三种真相不重叠。 +- fs 的所有路径经 `resolveSafePath` 约束在 cwd 内(拒绝绝对路径); + workspaceFs 的所有路径必须是绝对路径(`WorkspaceFsNotAbsoluteError`); + fileStore 的消费者拿不到路径(只按 `fileId`)。三种路径语义互不兼容,不能 + 合并进同一个路径模型。 +- fs 的 query / command 不调用 `IFsWatcher` 的业务方法;watcher 是独立的 + runtime wiring(`start.ts`),经 `FsWatcherDeliverySink` 投递 + `event.fs.changed`。command 副作用(`mkdir` 写盘)与 runtime 副作用 + (watcher 投递事件)各在其位。 +- `IWorkspaceService.browse` / `home` 是纯委托(`workspaceService.ts:60-66`), + 不复制 `WorkspaceFsService` 的浏览逻辑;workspace domain 的统一 facade 与 + workspaceFs 读表面不重复实现。 + +## 决策记录 + +- **DR1:“file / fs” 是两个独立 domain + workspace 的一个读表面,不是一个 + domain。** fs(会话内文件操作)、fileStore(全局 blob 持久化)、 + workspaceFs(workspace 的 browse / home)共享 “文件系统” 的直觉,但键 + (`sessionId+relPath` / `fileId` / `absPath`)、作用域(cwd 内 / 全局 / + 宿主绝对路径)、真相(项目树 / blob 库 / 派生自 registry)、写语义、对外 + 入口都不同。它们不合并成一个 “file / fs domain”,也不互相调用。 +- **DR2:fs 是会话内文件操作 domain,query + 单写 command + runtime + watcher。** `IFsService`(list/read/stat/listMany/statMany/ + resolveDownload/resolvePath = query;mkdir = command)、`IFsSearchService` + / `IFsGitService`(query)、`IFsWatcher`(runtime)共同组成 fs domain; + `resolveSafePath` 是其路径安全基础设施。所有方法 scoped 到 session、经 + `resolveSafePath` 约束在 cwd 内。 +- **DR3:fs 的 query 与 `mkdir` 共用 `IFsService` 不构成 muddle。** 它们是 + 同一会话内文件操作 facade 上的独立方法,实现互不调用(`FsService.mkdir` + 不调任何 query 方法),共享的只是 session → cwd → `resolveSafePath` → + `node:fs` 这条基础设施管道。AGENTS.md 的 “command / query 角色不互相调用 + 业务方法” 针对实现耦合,不是接口同址。共用 facade 避免了为一个单方法写 + 角色复制整份 session → cwd → 路径安全管道。真正的角色分离(query+command + facade vs runtime watcher vs path-safety helper)已按文件物理分离。 +- **DR4:fileStore 是全局 blob 持久化 domain,不是 fs 的“写那一半”。** + `IFileStore.save` / `delete` / `get` 是 repository contract 的直接暴露: + 键是 `fileId`、作用域全局、真相是 blob + `index.json`、有大小限额与过 + 期。它与 fs 的 `mkdir`(相对路径、cwd 内、用户可见的项目文件)写语义完 + 全不同,不能共用 `write` / `save` 接口,也不能并入 fs。 +- **DR5:workspaceFs 是 workspace aggregate 的 browse / home 读表面,不是 + fs 的一种 scope。** workspaceFs 要求绝对路径、不 scoped 到 session、 + query-only,用于 session 创建之前挑选 workspace root——这与 fs 的“相对 + 路径、cwd 内、可写”语义相反,不能共存于一个路径模型。它住在 + `services/workspace/`,已被 `IWorkspaceService` 统一 facade 吸收 + (`browse` / `home` 委托 `IWorkspaceFsService`)。 +- **DR6:三者互不引用 + 各自独立 transport 表面。** fs → `routes/fs.ts` + + `start.ts`(watcher wiring);fileStore → `routes/files.ts` + + `routes/prompts.ts`;workspaceFs → `routes/workspaceFs.ts`。三目录之间零 + import。这条边界是“是否需要拆分 / 合并”的硬指标:不共享真相、不互相调 + 用、不共享 transport,三类关注点就是清晰的。 +- **DR7:不引入统一的 `IFsQueryService` / 单独的 `IFsCommandService`。** + fs 的 query 已按职责(核心 IO / 子进程搜索 / git 解析)拆成三个 service, + `mkdir` 是唯一写、与 query 共用 `IFsService` 不构成 muddle。再抽统一 + query 接口会把三种实现压进一个接口;为 `mkdir` 单抽 command 接口只是同名 + 复制 + 管道复制。 +- **DR8:不为 fileStore 拆 command / query。** `IFileStore` 只有 `save` / + `delete` / `get` 三个方法,是 blob repository contract 的直接暴露。拆成 + `IFileStoreCommandService` / `IFileStoreQueryService` 不带来新契约,反而 + 把一个三方法 facade 拆成两个需要同时注入的接口。 +- **DR9:不把 workspaceFs 从 workspace 拆进 fs。** workspaceFs 的“绝对路径、 + 非会话作用域、query-only、返回 `is_git_repo` 视图”语义属于 workspace + aggregate(workspace root 选择的读模型),已被 `IWorkspaceService` 吸收。 + 把它并进 fs 会制造“同一 service 既要拒绝绝对路径又要要求绝对路径”的矛 + 盾。保留它在 `services/workspace/` 是正确的归属。 +- **DR10:当前代码布局已满足边界,无需迁移。** fs domain 在 + `services/fs/`(`IFsService` / `FsService` query+command + + `IFsSearchService` / `FsSearchService` query + `IFsGitService` / + `FsGitService` query + `IFsWatcher` / `FsWatcherService` runtime + + `resolveSafePath` infrastructure);fileStore domain 在 + `services/fileStore/`(`IFileStore` / `FileStore` query+command + blob + + `index.json` 持久化);workspaceFs 在 `services/workspace/` + (`IWorkspaceFsService` / `WorkspaceFsService` query,被 + `IWorkspaceService` / `WorkspaceService` 吸收)。三者经 `services/index.ts` + 注册为顶层 DI singleton,被 `packages/server` 的独立路由 / wiring 消费。 + 依赖方向单向:transport(`packages/server`)→ services facade;services 之 + 间无交叉引用;fs runtime watcher 独立 wiring。三层都没有反向 import, + M0.1 fence 干净。本次只出概念定稿,不做代码拆分。 diff --git a/.agents/skills/service-skill/explanation/domains/message-context.md b/.agents/skills/service-skill/explanation/domains/message-context.md new file mode 100644 index 000000000..c47fc4d6b --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/message-context.md @@ -0,0 +1,253 @@ +# Message / Context Service 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的 message-context 读取流](#统一的-message-context-读取流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,**message** 与 **context** 是两个相邻但职责不同的 domain: + +- `message` = **query(消息列表 / 转录读取)**:面向 daemon / SDK 的**只读 transcript 查询**——`IMessageService.list(sid, query)` 与 `get(sid, mid)`。它回答“这个 session 有哪些消息、按什么顺序、长什么样”,把 agent-core 的 `ContextMessage` 历史适配成协议的 `Message` 形状(含 `deriveMessageId` 合成 id、`toProtocolMessage` 内容映射)。它**没有任何写入入口**。 +- `context` = **runtime assembly(上下文装配)**:agent 进程内**模型当前上下文窗口**的装配——`ContextMemory` 维护 `_history` / `tokenCount`,负责 `appendMessage` / `appendLoopEvent` / `applyCompaction` / `project`,把内部历史投影成送给模型的 kosong `Message[]`。它回答“下一次喂给模型的上下文长什么样”。它是**运行时活状态 + 装配逻辑**,不是查询模型。 + +**这两个 domain 不需要合并、也不需要进一步拆分。** 边界当前就是干净的: + +- message 只做**只读 transcript 查询**(`list` / `get`),不写 context、不驱动 compaction、不持有模型的当前上下文窗口;它只在需要“未落盘的活尾巴”时经 CoreAPI **读取** `getContext().history` 一次,把它拼到 wire transcript 后面。 +- context 只做**上下文装配**(history / token / compaction / projection),不暴露 daemon 形状、不合成协议 id、不做分页;`IContextService` 是 agent 进程内的运行时接口,不是 SDK facade。 + +**关系一句话:context 拥有模型当前的上下文窗口;message 通过 wire transcript(必要时拼接 context 的活尾巴)向 daemon / SDK 提供只读 transcript 查询。** + +接口定义见 `services/message/message.ts` 的 `IMessageService`(query facade)与 `agent/context/index.ts` 的 `IContextService` / `ContextMemory`(runtime assembly owner);本文只承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. “转录查询”与“上下文装配”是两个不同的关注点 + +“这个 session 发生过哪些消息”与“下一次送给模型的上下文长什么样”是两件不同的事: + +- **转录查询(transcript)**:给定一个 session,列出它的全部消息历史(包括已被 compaction 折叠掉的早期消息),按协议形状返回,支持游标分页与角色过滤。这是**只读、可分页、可缓存**的查询,真相在 `wire.jsonl` 记录日志。 +- **上下文装配(context window)**:维护模型**当前**的上下文窗口——追加用户 / 助手 / 工具消息,累计 token,按需 compaction 成 `[compaction_summary, ...tail]`,投影成 kosong `Message[]` 送给模型。这是**可变、运行时、有副作用**的装配逻辑,真相在 agent 进程内存。 + +这两者的生命周期、真相、副作用都不同: + +- transcript 的真相在磁盘(`wire.jsonl`),读取不应改变 agent 状态;context 的真相在内存,每次 append / compaction 都会改变它。 +- compaction 会**折叠** context(当前窗口变短),但 transcript 要**保留**全部被折叠的消息——所以 transcript 不能只读 `getContext().history`(否则折叠前的消息会丢失)。 + +因此它们分属两个 domain:message 拥有 transcript 查询,context 拥有上下文窗口装配。 + +### 2. 命令 / 查询 / 运行时状态分开(按需要引入) + +按 service-skill 的角色表,本组 domain 实际用到两类: + +| 类型 | 关注 | 归属 | +|---|---|---| +| Query | 多 scope 列表 / 单条读取:message list / get(游标分页、角色过滤、协议形状适配) | `message`(`IMessageService` / `MessageService`) | +| Runtime assembly | 上下文窗口的活状态与装配:append / compaction / projection / token 计数 | `context`(`IContextService` / `ContextMemory`) | +| Command | aggregate 写入入口 | **无**(message 没有写入;context 的 append / compaction 是装配副作用,不是 daemon 暴露的 aggregate 生命周期命令) | + +按 [Domain decomposition](../../../../../packages/agent-core/src/services/AGENTS.md) 的规范:“不是每个 domain 都需要五件套,仅当某角色有明确 owner 且契约非空时才引入”。 + +- **message 已经是 query facade。** `IMessageService` 只有 `list` / `get` 两个读方法,没有写入方法、没有运行时状态、没有命令语义——它**就是** message aggregate 的 query 角色。再拆一个 `MessageQueryService` 不会引入新的契约,只是把同一个 facade 换个名字。 +- **context 不是查询模型。** `IContextService.history` / `tokenCount` / `messages` 是上下文窗口的当前快照(装配的副产物),不是多 scope 的 list / search / count;它不在 SDK 边界暴露协议形状,因此不套用 Query Service 骨架。 + +### 3. message 不持有“上下文窗口”,context 不表达“transcript 查询语义” + +边界保持干净: + +- message 侧只持有**查询所需的缓存**:按 session 的 wire transcript LRU(`transcriptCache`)。它不知道模型的当前 token 数、不知道哪些消息已被 compaction 折叠、不调用 `appendMessage` / `applyCompaction`。 +- context 侧只持有**装配所需的状态**:`_history` / `_tokenCount` / `openSteps` / `pendingToolResultIds` / `deferredMessages`。它不知道 daemon 的 `Message.id` 怎么合成、不知道 `before_id` / `after_id` 分页、不输出协议 `Message`。 + +这条边界是“是否需要拆分 / 合并”的唯一硬指标:只要 message 不混入上下文装配、context 不混入 transcript 查询,两个 domain 就是清晰的。 + +### 4. “transcript 还原”是 message 对 wire 记录的再归约,不是 context 的副作用 + +daemon 看到的 transcript 必须包含**全部被 compaction 折叠的早期消息**,而 `getContext().history` 在 compaction 后只剩 `[compaction_summary, ...tail]`。因此 message **不直接以 context 为真相**,而是: + +- 读取 `wire.jsonl` 记录日志,按与 `ContextMemory` restore 相同的语义**再归约**一份完整 transcript(`readWireTranscript` / `WireTranscript`),只是 `context.apply_compaction` 在这里**保留前缀 + 插入摘要**,而不是丢弃前缀。 +- 对正在运行的 session,wire 文件可能落后内存几条记录;message 用 `WireTranscript.foldedLength` 与 `getContext().history.length` 比较,把未落盘的尾巴**只读地拼上去**。 + +这避免了“transcript 真相在谁手里”的二义性:transcript 的真相在 wire 日志,由 message 还原;context 只负责模型当前窗口,不是 transcript 的真相来源。 + +### 5. Service 层 facade 暴露查询,transport 层只做形状适配 + +- `message`:transcript 还原(wire 再归约 + 活尾巴拼接)在 `MessageService` 内部完成;SDK 边界 `IMessageService.list` / `get` 只做 `ContextMessage` → 协议 `Message` 的形状翻译(`toProtocolMessage` / `deriveMessageId`);REST 路由只负责游标校验与错误码映射(`SessionNotFoundError` → 40401、`MessageNotFoundError` → 40403),不重新解释 transcript 语义。 +- `context`:上下文装配在 agent 进程内的 `ContextMemory` 完成;不直接暴露到 daemon / SDK 边界,message 只在必要时经 CoreAPI 读取它的 `history` 作为活尾巴来源。 + +## Service 拆分概览 + +| Service | 一句话职责 | 角色 | +|---|---|---| +| `IMessageService` | daemon/SDK 只读 transcript 查询:`list` / `get`(wire 再归约 + 活尾巴拼接 + 协议形状翻译) | query | +| `MessageService` | `IMessageService` 实现:wire transcript LRU + `_getProtocolMessages` 映射 | query(impl) | +| `IContextService` | agent 进程内的上下文窗口装配:history / token / compaction / projection | runtime assembly | +| `ContextMemory` / `ContextService` | `IContextService` 实现:append / applyCompaction / project | runtime assembly(impl) | + +> 只有这些 Service。**不引入 `IMessageQueryService` / `MessageQueryService`**——`IMessageService` 已经是纯 query facade(只有 `list` / `get`,无写入、无运行时状态),再拆一层只是同名复制。 +> **不引入 `IContextQueryService`**——`IContextService.history` / `messages` / `tokenCount` 是上下文窗口的当前快照,不是多 scope 查询模型。 +> 共享类型(`ContextMessage` / `MessageListQuery` / `Message` / `WireTranscript` 等)见 `agent/context/types.ts`、`services/message/message.ts` 与 `services/message/transcript.ts`。 + +模式参考: + +- message 侧对齐 [`query-service.md`](../../reference/patterns/query-service.md):message list / get 是这个 aggregate 的读模型入口,`MessageListQuery`(`before_id` / `after_id` / `page_size` / `role`)就是统一的 `Query` 类型;scope 固定为单个 session,不扩展多 scope 便捷方法。`IMessageService` 已经把 query 角色的契约(list / get / 分页 / 过滤)一次性实现完,无需再拆。 +- context 侧不对齐 query / command / runtime Service 骨架——它是 agent 进程内的**运行时装配**(更接近 [`runtime-service.md`](../../reference/patterns/runtime-service.md) 描述的“事件驱动活状态”,但活状态就是模型上下文窗口本身,不投影到 SDK);它的“写入”是装配副作用(append / compaction),不是 daemon 暴露的 aggregate 生命周期命令。 + +## 统一的 message-context 读取流 + +一次 `GET /v1/sessions/{sid}/messages` 只有一条主路径: + +```text +messageService.list(sid, query) // IMessageService:transcript 查询入口 + ├─ _requireSession(sid) // 确认 session 存在(→ SessionNotFoundError / 40401) + ├─ _getTranscriptEntries(sid, summary) + │ ├─ resumeSession(sid) // 确保 wire 协议版本已重写 + │ ├─ _readTranscriptCached(...) // 读 wire.jsonl + 再归约(LRU on size,mtime) + │ │ └─ readWireTranscript → WireTranscript // 完整 transcript(保留被折叠前缀) + │ └─ coreApi().getContext({sid, agentId}) // 只读:取模型当前 history 作活尾巴 + │ └─ context.history.slice(foldedLength) // 未落盘的 tail,append 到 transcript + ├─ entries → toProtocolMessage(...) // ContextMessage → 协议 Message(合成 id / created_at) + └─ 按 before_id / after_id / page_size / role 分页 // 游标 + 角色过滤,created_at desc +``` + +要点: + +- message 是**唯一的 transcript 查询 owner**:所有 `list` / `get` 都经 `IMessageService`,真相来自 wire 日志再归约,必要时只读拼接 context 活尾巴。 +- context 是**唯一的上下文窗口 owner**:`_history` / `tokenCount` / compaction 都在 `ContextMemory`,message 不写它,只在需要活尾巴时经 CoreAPI 读一次 `history`。 +- 协议形状适配的**副作用为零**:`toProtocolMessage` / `deriveMessageId` 是纯函数,不改变 context 或 wire 日志。 + +> `coreApi().getContext` 是 message 消费 context 运行时的**只读入口原语**,不是 `IMessageService` 暴露的方法。message 把它作为还原完整 transcript 的实现细节,对外只暴露 list / get 查询语义。 + +## 关键场景 + +### 场景 A:列出已落盘的 transcript(纯 wire,无活尾巴) + +```ts +messageService.list(sid, { page_size: 50 }); +``` + +内部解析:`_readTranscriptCached` 命中 wire transcript LRU;`getContext().history.length <= foldedLength`,无需拼接活尾巴;`toProtocolMessage` 映射后按 `created_at desc` 分页。无 context 写入。 + +### 场景 B:正在运行的 session,wire 落后内存几条 + +```text +messageService.list(sid, query) + → _readTranscriptCached → WireTranscript{ entries, foldedLength } + → getContext().history.length > foldedLength + → liveTail = history.slice(foldedLength) // 未落盘的 tail + → return [...transcript.entries, ...liveTail] // 只读拼接 +``` + +内部解析:context 的活尾巴经 CoreAPI 只读取出、append 到 wire transcript 之后;message 不调用任何 `appendMessage` / `applyCompaction`。 + +### 场景 C:按 id 取单条消息 + +```ts +messageService.get(sid, 'msg_' + sid + '_000042'); +``` + +内部解析:先 `_getProtocolMessages(sid)` 还原全量,再 `parseMessageId(mid)` 校验 `sessionId` 与 index;id 不属于该 session 或越界时抛 `MessageNotFoundError`(→ 40403)。纯查询。 + +### 场景 D:模型上下文窗口 compaction(context 侧,不影响 transcript) + +```text +context.applyCompaction(result) + → records.logRecord({ type:'context.apply_compaction', ...result }) + → _history = [ compaction_summary, ...tail ] // 当前窗口被折叠 + → _tokenCount = result.tokensAfter +``` + +内部解析:context 折叠的是**模型当前窗口**;wire 日志里的早期记录仍在,`readWireTranscript` 仍能还原完整 transcript。message 侧的查询结果不因 compaction 丢失历史。 + +### 场景 E:message 读 wire 日志失败,降级到 live history + +```text +messageService.list(sid, query) + → _readTranscriptCached throws (wire 缺失 / 解析失败) + → transcript === undefined + → return context.history.map(message => ({ message })) // 降级:仅用当前窗口 +``` + +内部解析:transcript 读取失败时降级为 context 的当前 history(可能不含被折叠前缀),而不是让整个端点失败。这是 message 对 context 的**只读降级**,不是 context 的副作用。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 / 入口 | 角色 | +|---|---|---| +| 列出 session 消息(游标分页 / 角色过滤) | `messageService.list(sid, query)` | query(message) | +| 按 id 取单条消息 | `messageService.get(sid, mid)` | query(message) | +| 合成协议消息 id | `deriveMessageId(sessionId, index)` / `parseMessageId(id)` | query(message,纯函数) | +| ContextMessage → 协议 Message 形状翻译 | `toProtocolMessage(...)` | query(message,纯函数) | +| 还原完整 transcript(wire 再归约) | `readWireTranscript(sessionDir, agentId)` → `WireTranscript` | query(message,内部) | +| 取模型当前上下文窗口 | `context.history` / `context.messages` / `context.tokenCount` | runtime assembly(context) | +| 追加用户 / 系统提醒消息 | `context.appendUserMessage` / `appendSystemReminder` / `appendMessage` | runtime assembly(context) | +| 把 loop 事件装配进上下文 | `context.appendLoopEvent(event)` | runtime assembly(context) | +| 折叠上下文窗口 | `context.applyCompaction(result)` | runtime assembly(context) | +| 投影成送给模型的 Message[] | `context.project(messages)` / `context.messages` | runtime assembly(context,投影) | +| message 拼接活尾巴(只读) | `messageService._getTranscriptEntries` 内 `coreApi().getContext(...)` | query 消费 runtime(只读) | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service + IMessageService (query — daemon/SDK 只读 list / get,ContextMessage → 协议 Message) + IContextService (runtime assembly — 上下文窗口 history / token / compaction / projection) + +Runtime (in-process) + ContextMemory (装配实现:append / applyCompaction / project) + projector (project / trimTrailingOpenToolExchange:内部历史 → kosong Message[]) + +Domain / Policy + MessageListQuery (before_id / after_id / page_size / role — 统一 Query 类型) + Context window state (_history / _tokenCount / openSteps / pendingToolResultIds / deferredMessages) + +Infrastructure + Wire transcript reader (readWireTranscript / WireTranscript:wire.jsonl 再归约) + SDK adapters (toProtocolMessage / deriveMessageId / parseMessageId:内部 → 协议形状) + CoreAPI handle (message 经 ICoreRuntime 取 in-process getContext / listSessions) +``` + +依赖关系: + +```text +IMessageService → Wire transcript reader (transcript 真相:wire.jsonl 再归约) +IMessageService → CoreAPI.getContext / listSessions (只读:活尾巴 + session 存在性校验) +IMessageService → ContextMessage (type only) (仅类型导入,用于 toProtocolMessage 适配) +IContextService → projector / compaction (上下文窗口装配) +``` + +禁止的边界: + +```text +IMessageService → context.appendMessage / applyCompaction / project (message 不写上下文窗口) +IContextService → deriveMessageId / toProtocolMessage / MessageListQuery (context 不表达 transcript 查询 / 协议形状) +IMessageService ⇄ IContextService 的业务方法互相调用 (message 只单向只读 context;context 不回调 message) +``` + +关键不变量: + +- message 侧不持有上下文窗口状态(无 `_history` / `_tokenCount` / compaction 状态机);context 侧不持有 transcript 查询 / 协议 id 合成 / 分页逻辑。 +- transcript 的真相在 wire 日志(`readWireTranscript`),context 的当前窗口只是活尾巴来源,不是 transcript 真相。 +- message 对 context 的引用仅限:(1) 类型导入 `ContextMessage`(形状适配),(2) 只读 `getContext().history`(活尾巴);两者都不改变 context。 +- runtime→协议形状翻译集中在 `toProtocolMessage`,REST 路由不重新解释 transcript 语义。 + +## 决策记录 + +- **DR1:message 与 context 是两个独立 domain。** message 是 query(transcript 只读查询:list / get);context 是 runtime assembly(模型当前上下文窗口的 history / token / compaction / projection)。二者关注点不同、真相不同、副作用不同,不合并。 +- **DR2:不引入 `MessageQueryService`。** `IMessageService` 已经是纯 query facade——只有 `list` / `get` 两个读方法,无写入、无运行时状态、无命令语义。再拆一个 `MessageQueryService` 不会引入新契约,只是把同一个 facade 同名复制;当前 message aggregate 的 query 角色已经由 `IMessageService` 一次性实现完。 +- **DR3:不引入 `ContextQueryService`。** `IContextService.history` / `messages` / `tokenCount` 是上下文窗口的当前快照(装配副产物),不是多 scope 的 list / search / count;context 不在 SDK 边界暴露协议形状,不套用 Query Service 骨架。 +- **DR4:message 不持有上下文窗口。** 查询所需状态(wire transcript LRU)归 message;上下文窗口状态(`_history` / `_tokenCount` / `openSteps` / compaction)归 context。这是“是否需要拆分 / 合并”的唯一硬指标,当前为“边界干净,无需改动”。 +- **DR5:context 不表达 transcript 查询语义。** context 只负责上下文窗口装配(append / compaction / project),不合成协议 id、不做游标分页、不输出协议 `Message`。transcript 查询一律发生在 message。 +- **DR6:transcript 真相在 wire 日志,由 message 再归约。** `getContext().history` 在 compaction 后只剩 `[compaction_summary, ...tail]`,不能作为 transcript 真相;message 读 `wire.jsonl` 并按 `ContextMemory` restore 语义再归约(`readWireTranscript`),保留被折叠前缀。context 的当前窗口仅作为未落盘活尾巴的只读来源。 +- **DR7:message 对 context 的依赖是只读 + 类型导入。** message 只 (1) 类型导入 `ContextMessage` 用于 `toProtocolMessage` 适配,(2) 经 CoreAPI 只读 `getContext().history` 拼接活尾巴;不调用 `appendMessage` / `applyCompaction` / `project`。services → runtime 的方向是 AGENTS.md 允许的方向。 +- **DR8:当前代码布局已满足边界,无需迁移。** message 在 `services/message/`(`IMessageService` / `MessageService`,query facade)+ `transcript.ts`(wire 再归约);context 在 `agent/context/`(`ContextMemory` / `ContextService` / `projector`,runtime assembly)。两个角色已经分离,没有发现重叠或渗漏,因此本次只出概念定稿,不做代码拆分。 diff --git a/.agents/skills/service-skill/explanation/domains/model-catalog.md b/.agents/skills/service-skill/explanation/domains/model-catalog.md new file mode 100644 index 000000000..ed9b1403d --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/model-catalog.md @@ -0,0 +1,210 @@ +# Model Catalog Service 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的配置读取模型](#统一的配置读取模型) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里: + +- `ModelCatalogService` 管“模型 / provider 目录”这一份**配置派生 aggregate**:列出可见模型、列出 provider 及其凭据状态、读取单个 provider。 +- 同一个 Service 同时承担该 aggregate 的**命令**:设置默认模型、刷新受管 Kimi provider 的模型清单。 + +**这个 domain 不需要拆成 query / command / runtime 三个 Service。** + +- 目录读取只有三个方法、且都基于同一份配置真相,不构成多 scope 的查询模型,因此不引入单独的 Query Service。 +- 命令只有两个、且与读取共用同一份配置读取入口,拆开只会把共享的配置解析拆成两份。 +- 该 domain 没有事件驱动的活状态、没有状态订阅、没有 per-id 运行时投影,因此**不存在 Runtime 角色**。 + +接口定义见 `modelCatalog.ts` 的 `IModelCatalogService`;本文只承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. 一个 aggregate 只由一个 Service 拥有 + +“模型 / provider 目录”是一个独立的 aggregate: + +- 它描述“当前这份配置里有哪些模型别名、哪些 provider、各自的凭据是否就绪”。 +- 它的真相是 `KimiConfig`(models / providers / defaultModel)。 +- 它不拥有 Session、不拥有 Workspace,也不被它们拥有;其他 aggregate 只通过模型 id / provider id 引用它。 + +因此目录的读取和写入由同一个 Service 承载,不把“配置真相”分散到多个 owner。 + +### 2. 命令 / 查询 / 运行时状态分开(按需要引入) + +按 service-skill 的角色表,本 domain 实际只用到两类: + +| 类型 | 关注 | 归属 | +|---|---|---| +| Query | 列出模型、列出 provider、读取单个 provider | `ModelCatalogService`(读取方法) | +| Command | 设置默认模型、刷新受管 provider 模型清单 | `ModelCatalogService`(写入方法) | +| Runtime | 活状态、运行中信息 | **无**(本 domain 没有活状态) | + +按 [Domain decomposition](../../../../../packages/agent-core/src/services/AGENTS.md) 的规范:“不是每个 domain 都需要五件套,仅当某角色有明确 owner 且契约非空时才引入”。本 domain 的读取契约是“基于同一份配置的平坦目录”,不是多 scope 查询,因此不为它单独开 Query Service。 + +### 3. 读取不写入,写入不返回查询模型 + +边界保持干净: + +- 读取方法(list / get)只读配置,不调用任何写入原语。 +- 写入方法(set / refresh)调用配置的写入原语,返回**命令结果**(被设置的模型、刷新产生的变更摘要),不返回 list / search 形态的查询模型。 + +这条边界是“是否需要拆分”的唯一硬指标:只要读取不写入、写入不伪装成查询,单 Service 就是清晰的。 + +### 4. 凭据状态是派生,不是活状态 + +provider 的 `has_api_key` / `has_oauth_token` / `status` 是在读取时由“配置 + 已缓存的 OAuth token”**当场派生**的快照: + +- 它不是事件流驱动的活状态,没有 `onDidChange` 订阅。 +- 它不写入真相,只是读模型上的一个派生字段。 + +因此它属于 Query 的派生字段,**不构成 Runtime Service**。 + +### 5. Service 层解析业务标识 + +`providerId` / `modelId` 的有效性校验(不存在则抛 `ProviderNotFoundError` / `ModelNotFoundError`)、受管 Kimi provider 的识别、OAuth token provider 的解析,都在 Service 层完成;transport 层只做参数映射,不承载这些业务规则。 + +## Service 拆分概览 + +| Service | 一句话职责 | 角色 | +|---|---|---| +| `IModelCatalogService` | 模型 / provider 目录的读取,以及默认模型与受管 provider 模型清单的写入 | query + command(合并) | + +> 只有这一个 Service。不引入 `IModelCatalogQueryService`,不引入 `IModelCatalogRuntimeService`。 +> 共享类型(`ModelCatalogItem` / `ProviderCatalogItem` / `ProviderCredentialState` / 错误类型)与协议映射函数见 `modelCatalog.ts`。 + +模式参考: + +- 命令侧对齐 [`command-service.md`](../../reference/patterns/command-service.md):本 domain 的写入是“配置 update + 受管刷新”,不是 aggregate 的 create/archive/purge 生命周期,因此只取其中的“业务标识解析 + 唯一写入入口”两点,不强行套用 archive/restore/purge。 +- 查询侧对齐 [`query-service.md`](../../reference/patterns/query-service.md) 的“读取不依赖运行时、不写入”原则;但不套用其多 scope `list()` 骨架,因为目录读取没有 scope / 分页 / 归档维度。 + +## 统一的配置读取模型 + +目录读取和写入共用**同一份配置真相** `KimiConfig`: + +```text +KimiConfig + ├─ models : { [alias]: ModelAlias } // 模型别名 → provider / model / capabilities + ├─ providers : { [id]: ProviderConfig } // provider → type / baseUrl / apiKey / oauth + ├─ defaultModel : alias | undefined + └─ defaultThinking: boolean | undefined +``` + +所有读取方法都先取得这一份配置,再做投影: + +```text +listModels() → read config → map(models, toProtocolModel) +listProviders() → read config → map(providers, toProtocolProvider + credential) +getProvider(id) → read config → toProtocolProvider(id) + credential +``` + +写入方法也先读配置、再写回: + +```text +setDefaultModel(modelId) + → read config → 校验 modelId 存在 → 写 defaultModel → 返回被设置的模型 + +refreshOAuthProviderModels() + → read config → 拉取受管 Kimi provider 模型 → 计算 next config + → 写回 providers/models/defaultModel → 返回变更摘要 +``` + +> `getKimiConfig` / `setKimiConfig` / `removeKimiProvider` 是**底层配置的读写原语**(经由 core 的 in-process 通道),不是 `IModelCatalogService` 暴露的方法。Service 把它们作为实现细节,对外只暴露目录语义的读取和命令。 + +## 关键场景 + +### 场景 A:列出可用模型 + +```ts +modelCatalogService.listModels(); +``` + +内部解析:`read config → Object.entries(models).map(toProtocolModel)`。纯读取,无写入。 + +### 场景 B:列出 provider(含凭据状态) + +```ts +modelCatalogService.listProviders(); +``` + +内部解析:`read config → 对每个 provider 派生 hasApiKey / hasOAuthToken → toProtocolProvider`。纯读取,无写入;凭据状态为当场派生,非活状态。 + +### 场景 C:设置默认模型 + +```ts +modelCatalogService.setDefaultModel(modelId); +``` + +内部解析:`read config → 校验 modelId → setKimiConfig({ defaultModel }) → 返回 { default_model, model }`。写入;返回的是命令结果(被设置的单个模型),不是查询模型。 + +### 场景 D:刷新受管 Kimi provider 的模型清单 + +```ts +modelCatalogService.refreshOAuthProviderModels(); +``` + +内部解析:`read config → 解析受管 Kimi provider 的 OAuth token → fetchManagedKimiCodeModels → 计算 next config → 必要时 removeKimiProvider + setKimiConfig → 返回 { changed, unchanged, failed }`。写入;返回的是刷新的变更摘要,不是查询模型。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 | 角色 | +|---|---|---| +| 查看可用模型列表 | `ModelCatalogService.listModels()` | query | +| 查看 provider 列表 | `ModelCatalogService.listProviders()` | query | +| 查看单个 provider 详情 | `ModelCatalogService.getProvider(id)` | query | +| 切换默认模型 | `ModelCatalogService.setDefaultModel(modelId)` | command | +| 刷新受管 Kimi provider 模型 | `ModelCatalogService.refreshOAuthProviderModels()` | command | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service + IModelCatalogService (query + command, 合并) + +Domain / Persistence + KimiConfig (真相) (models / providers / defaultModel) + 受管 Kimi OAuth 配置 (managed-kimi-code-oauth) + +Infrastructure + core 的 in-process 配置通道 (getKimiConfig / setKimiConfig / removeKimiProvider) + 受管模型拉取 (fetchManagedKimiCodeModels) + OAuth token provider (resolveOAuthTokenProvider / getCachedAccessToken) +``` + +依赖关系: + +```text +IModelCatalogService → KimiConfig 读写原语 (经 core in-process 通道) +IModelCatalogService → 受管 Kimi OAuth facade (token provider / fetch models) +``` + +禁止的边界: + +```text +IModelCatalogService ⇄ 任何 transport / RPC 展示逻辑 +IModelCatalogService → 其他 domain 的 Service (目录不依赖 Session / Workspace / Tool) +``` + +读取路径只读配置原语,写入路径只调配置写入原语;不在读取中混入 `setKimiConfig`,也不在写入中返回 list 形态的数据。 + +## 决策记录 + +- **DR1:目录是配置派生的单一 aggregate。** 真相是 `KimiConfig`;读取和写入由同一个 `ModelCatalogService` 拥有,不分散 owner。 +- **DR2:不拆 Query / Command Service。** 读取只有三个方法、基于同一份配置、无多 scope 查询;写入只有两个、与读取共用配置入口。拆开只会复制共享的配置解析,不增加清晰度。拆分的硬指标(读取是否写入、写入是否伪装成查询)当前均为“否”。 +- **DR3:不存在 Runtime Service。** 本 domain 无事件驱动活状态、无状态订阅、无 per-id 运行时投影。provider 凭据状态是读取时的派生快照,不是活状态。 +- **DR4:读取不写入,写入不返回查询模型。** 读取方法只调配置读取原语;写入方法返回命令结果(被设置的模型 / 刷新变更摘要),不返回 list / search 形态数据。这是“是否需要拆分”的唯一硬指标。 +- **DR5:`getKimiConfig` / `setKimiConfig` / `removeKimiProvider` 是底层原语,不是 Service 方法。** Service 把它们作为实现细节,对外只暴露目录语义的读取与命令。 +- **DR6:业务校验在 Service 层。** `providerId` / `modelId` 存在性、受管 Kimi provider 识别、OAuth token provider 解析均在 Service 层完成;transport 只做参数映射。 +- **DR7:受管 Kimi provider 刷新是命令,不是查询。** 它会写回配置(可能先 removeKimiProvider 再 setKimiConfig),并返回变更摘要;即使“无变更”也属于命令结果,不伪装成读取。 diff --git a/.agents/skills/service-skill/explanation/domains/permission-approval.md b/.agents/skills/service-skill/explanation/domains/permission-approval.md new file mode 100644 index 000000000..511884844 --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/permission-approval.md @@ -0,0 +1,246 @@ +# Permission / Approval Service 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的 permission-approval 决策流](#统一的-permission-approval-决策流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,**permission** 与 **approval** 是两个相邻但职责不同的 domain: + +- `permission` = **command / policy(规则决策)**:管理权限模式(mode)、规则集(rules)、以及 `beforeToolCall` 时的规则引擎决策(approve / deny / ask)。它是 agent 进程内的策略引擎,决定一次工具调用“该不该放行、该不该拒绝、该不该问人”。 +- `approval` = **runtime event(请求 / 解决事件)**:当 permission 决策为 `ask` 时,把一次“向人请示”的动作建模为一个**一次性请求-响应**(one-shot broker):`request(req): Promise` + `resolve(id, resp)`,并伴随 `event.approval.*` 事件。它是 permission 决策的人类介入(human-in-the-loop)升级通道。 + +**这两个 domain 不需要合并、也不需要进一步拆分。** 边界当前就是干净的: + +- permission 只做**规则决策**(mode / rules / beforeToolCall),不直接持有任何“等待中的请求”或 WS/REST 通道;当它需要人介入时,只通过一次 `requestApproval` 调用把决策权交给 approval。 +- approval 只做**请求 / 解决的事件中转**(one-shot broker),不表达任何规则语义——它不知道为什么会发起这次请求、也不知道决策为 `approved` 之后要不要写 session 缓存;那些都留在 permission 侧。 + +**关系一句话:permission 自动决策;当规则要求人介入时,approval 是那条 human-in-the-loop 升级通道。** + +接口定义见 `permission/index.ts` 的 `IPermissionService` 与 `services/approval/approval.ts` 的 `IApprovalService`;本文只承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. “决策”与“等待人介入”是两个不同的关注点 + +一次工具调用的放行问题由两个步骤组成: + +- **决策(decide)**:给定模式 + 规则集 + 调用上下文,算出 approve / deny / ask。这是**纯策略**,可以同步、可重放、可单测,不需要任何外部通道。 +- **请示(escalate)**:仅当决策为 `ask` 时,需要把问题呈现给一个外部 waiter(Web 客户端、TUI、测试 mock),等待其返回 `approved` / `rejected` / `cancelled`。这是**异步、跨进程、带超时与取消**的一次性交互。 + +这两步的生命周期、依赖、失败语义都不同: + +- 决策可以在没有客户端连接时照常运行(`ask` 退化为本地默认)。 +- 请示必须绑定一个外部 waiter,有超时 / 取消 / 断连等运行时态。 + +因此它们分属两个 domain:permission 拥有决策,approval 拥有请示。 + +### 2. 命令 / 查询 / 运行时状态分开(按需要引入) + +按 service-skill 的角色表,本组 domain 实际用到两类: + +| 类型 | 关注 | 归属 | +|---|---|---| +| Command | 模式 / 规则的写入,工具调用前的规则决策 | `permission`(`PermissionManager` / `IPermissionService`) | +| Runtime | 等待人介入的请求 / 解决事件、pending 状态投影 | `approval`(`IApprovalService`) | +| Query | 多 scope 列表 / 搜索 / 计数 | **无**(permission 的 `data()` 是单份配置快照;approval 的 `listPending` 是运行时 per-session pending 投影,不是查询模型) | + +按 [Domain decomposition](../../../../../packages/agent-core/src/services/AGENTS.md) 的规范:“不是每个 domain 都需要五件套,仅当某角色有明确 owner 且契约非空时才引入”。本组 domain 没有多 scope 查询模型,因此不引入 Query Service。 + +### 3. permission 不持有“等待中的请求”,approval 不表达“规则语义” + +边界保持干净: + +- permission 侧只持有**决策所需的状态**:mode、rules、session 级放行规则缓存(`sessionApprovalRulePatterns`)。它不知道某个 `ask` 当前是否已经发出请求、有没有 waiter、是否超时。 +- approval 侧只持有**等待中的请求**:按 `toolCallId` 关联的 pending Promise,以及把它们广播给 waiter 的事件通道。它不知道为什么会发起这次请求,也不知道响应回来后要不要写 session 缓存。 + +这条边界是“是否需要拆分 / 合并”的唯一硬指标:只要 permission 不混入 pending-request 状态、approval 不混入规则语义,两个 domain 就是清晰的。 + +### 4. “approve for session” 是 permission 的规则写入,不是 approval 的状态 + +当用户对某次 `ask` 选择 `approved` + `scope: session` 时,系统会把一条 session 级放行规则记下来,使后续同类调用直接放行、不再请示。这条规则: + +- 是 **permission 的规则集**的一部分(运行时 scope = `session-runtime`),由 permission 在收到 approval 响应后写回自己的规则缓存。 +- **不是** approval 的状态——approval 只是把响应交给 permission,写完规则后 approval 不再持有它。 + +这避免了“规则缓存到底在谁手里”的二义性:所有规则(无论来自静态配置还是 session 运行时放行)都归 permission。 + +### 5. Service 层解析业务标识,transport 层只做形状适配 + +- `permission`:tool call 上下文、mode、rule pattern 的解析与匹配都在 agent 进程内的策略引擎完成;transport(`setPermission` RPC)只负责把 mode 写到 manager,不承载规则语义。 +- `approval`:in-process SDK 形状(camelCase)与协议 wire 形状(snake_case)之间的字段翻译集中在 approval adapter(`toBrokerRequest` / `toAgentCoreResponse`);REST / WS 路由不重新解释 approval 语义。 + +## Service 拆分概览 + +| Service | 一句话职责 | 角色 | +|---|---|---| +| `IPermissionService` | 权限模式 / 规则集的管理,以及 `beforeToolCall` 的规则决策 | command(policy) | +| `IApprovalService` | 把一次 `ask` 升级为“请求 / 解决”的一次性事件中转 | runtime(one-shot broker) | + +> 只有这两个 Service。不引入 `IPermissionQueryService` / `IPermissionRuntimeService`,也不把 permission 与 approval 合并成一个 Service。 +> 共享类型(`PermissionMode` / `PermissionRule` / `PermissionPolicyResult` / `ApprovalRequest` / `ApprovalResponse` 等)见 `permission/types.ts` 与 `services/approval/approval.ts`。 + +模式参考: + +- permission 侧对齐 [`command-service.md`](../../reference/patterns/command-service.md):mode / rules 的写入是这份 aggregate 的唯一写入入口;`beforeToolCall` 的决策是“命令驱动的策略判定”,不套用 create/archive/purge 生命周期骨架。 +- approval 侧对齐 [`runtime-service.md`](../../reference/patterns/runtime-service.md):pending approval 是事件驱动的活状态投影,`request` / `resolve` 是其中转入口,`event.approval.*` 是其对外事件;它不写入真相(permission 规则由 permission 自己写)。 + +## 统一的 permission-approval 决策流 + +一次工具调用从“进入 permission”到“拿到放行结果”只有一条主路径: + +```text +beforeToolCall(context) + ├─ evaluatePolicies(context) + │ ├─ approve → 直接放行(可能带 executionMetadata) + │ ├─ deny → 直接拒绝(block + reason) + │ └─ ask → 进入 approval 升级通道 + │ + └─ (ask) requestToolApproval(context, askResult) + ├─ approval.request(req) ──────────────→ IApprovalService.request + │ ├─ 登记 pending Promise (by toolCallId) + │ └─ 广播 event.approval.requested 给 waiter + │ + ├─ (waiter 返回) approval.resolve(id, resp) ─→ IApprovalService.resolve + │ ├─ settle pending Promise + │ └─ 广播 event.approval.resolved + │ + └─ 回到 permission:recordApprovalResult(resp) + ├─ 若 approved + scope=session → 写入 sessionApprovalRulePatterns + └─ resolveApproval? / block + reason +``` + +要点: + +- permission 是**唯一的决策点**:所有 approve / deny / ask 都由 `evaluatePolicies` 产出,外部通道(approval)只在 `ask` 分支出现。 +- approval 是**唯一的请示中转**:所有“问人”都走 `IApprovalService.request` / `resolve`,并由它统一广播 `event.approval.*`;permission 不直接和 WS / REST / TUI 打交道。 +- 决策结果的**副作用落在 permission**:session 级放行规则由 permission 写回自己的缓存,approval 只负责把响应交回。 + +> `agent.rpc.requestApproval` / `BridgeClientAPI.requestApproval` 是 approval 的**调用入口原语**,不是 `IPermissionService` 暴露的方法。permission 把它作为升级到 approval 的实现细节,对外只暴露规则决策语义。 + +## 关键场景 + +### 场景 A:规则直接放行(不触发 approval) + +```ts +permissionService.beforeToolCall(context); +``` + +内部解析:`evaluatePolicies → 命中 approve policy → 返回 undefined / { executionMetadata }`。无 approval 交互,无事件广播。 + +### 场景 B:规则直接拒绝(不触发 approval) + +```ts +permissionService.beforeToolCall(context); +``` + +内部解析:`evaluatePolicies → 命中 deny policy → 返回 { block: true, reason }`。无 approval 交互。 + +### 场景 C:规则要求请示(进入 approval 升级通道) + +```ts +permissionService.beforeToolCall(context); +``` + +内部解析: + +```text +evaluatePolicies → 命中 ask policy + → requestToolApproval + → approval.request(req) // IApprovalService:登记 pending + 广播 event.approval.requested + → ...等待... + → approval.resolve(id, resp) // IApprovalService:settle + 广播 event.approval.resolved + → recordApprovalResult(resp) // permission:写 session 缓存(如 scope=session) + → resolveApproval? / block + reason +``` + +### 场景 D:切换权限模式(command) + +```ts +// 经由 setPermission RPC → permission.setMode(mode) +``` + +内部解析:`rpc-controller.setPermission → permission.setMode(mode)`。这是 permission 的命令写入:记录 `permission.set_mode` 记录、推送 `permission_updated` 重放事件、更新 mode。不经过 approval。 + +### 场景 E:列出某 session 当前等待中的 approval(runtime 投影) + +```ts +approvalService.listPending(sessionId); +``` + +内部解析:读取 approval 侧按 `toolCallId` 维护的 pending 集合,过滤出该 session 的协议形状请求。这是 approval 的运行时投影(用于 session status 生命周期判定 `awaiting_approval`),不是 permission 的查询,也不是查询模型。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 / 入口 | 角色 | +|---|---|---| +| 切换权限模式(manual / yolo / auto) | `setPermission` RPC → `permission.setMode(mode)` | command(permission) | +| 工具调用前的规则决策 | `permission.beforeToolCall(context)` | command(permission) | +| 读取当前模式 / 规则快照 | `permission.mode` / `permission.data()` | command-side read(permission) | +| 发起一次 human-in-the-loop 请示 | `approval.request(req)` | runtime(approval) | +| waiter 返回响应 | `approval.resolve(id, resp)` | runtime(approval) | +| 查看 session 等待中的请示 | `approval.listPending(sessionId)` | runtime(approval) | +| 订阅请示事件 | `event.approval.requested` / `resolved` / `expired` | runtime(approval) | +| session 级放行规则缓存 | `permission.recordApprovalResult` → `sessionApprovalRulePatterns` | command(permission) | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service + IPermissionService (command / policy — 模式、规则、beforeToolCall 决策) + IApprovalService (runtime — 请求 / 解决 one-shot broker、pending 投影) + +Domain / Policy + PermissionRule[] (规则真相:mode + rules + session-runtime 缓存) + PermissionPolicy[] (决策策略链) + +Infrastructure + Approval 事件通道 (event.approval.requested / resolved / expired) + Approval adapter (toBrokerRequest / toAgentCoreResponse:SDK↔协议形状翻译) + 外部 waiter (Web 客户端 over WS / TUI / 测试 mock) +``` + +依赖关系: + +```text +IPermissionService → PermissionPolicy[] (决策策略链) +IPermissionService → IApprovalService (仅 ask 分支:requestApproval 升级) +IApprovalService → Approval 事件通道 / adapter / 外部 waiter +``` + +禁止的边界: + +```text +IPermissionService → 任何 transport / WS / REST 展示逻辑 (permission 不直接和 waiter 打交道) +IApprovalService → PermissionPolicy / 规则语义 (approval 不解释为什么请示、不写规则缓存) +IPermissionService ⇄ IApprovalService 的业务方法互相调用 (permission 只单向升级到 approval;approval 不回调 permission 决策) +``` + +关键不变量: + +- permission 侧不持有 pending-request 状态;approval 侧不持有规则语义。 +- “approve for session” 的规则写回发生在 permission,approval 只交付响应。 +- SDK↔协议形状翻译集中在 approval adapter,REST / WS 路由不重新解释 approval 语义。 + +## 决策记录 + +- **DR1:permission 与 approval 是两个独立 domain。** permission 是 command / policy(模式 + 规则 + beforeToolCall 决策);approval 是 runtime(请求 / 解决 one-shot broker)。二者关注点不同、生命周期不同、失败语义不同,不合并。 +- **DR2:不引入 Query Service。** permission 的 `mode` / `data()` 是单份配置快照,approval 的 `listPending` 是 per-session 运行时 pending 投影;两者都不是多 scope 查询模型,因此不开 `IPermissionQueryService` / `IApprovalQueryService`。 +- **DR3:permission 不持有“等待中的请求”。** 决策所需状态(mode / rules / session 缓存)归 permission;pending Promise / 超时 / 断连等运行时态归 approval。这是“是否需要拆分 / 合并”的唯一硬指标,当前为“边界干净,无需改动”。 +- **DR4:approval 不表达规则语义。** approval 只负责请求 / 解决中转与事件广播,不解释为什么发起请示、不写 session 放行规则。规则写回一律发生在 permission。 +- **DR5:“approve for session” 是 permission 的规则写入。** 来自 session 运行时放行的规则与静态规则一样归 permission 的规则集;approval 只把响应交回 permission。 +- **DR6:permission 单向升级到 approval。** `ask` 分支通过 `requestApproval` 进入 approval;approval 不回调 permission 的决策方法。决策结果(放行 / 拒绝 + session 缓存)由 permission 在收到响应后自己收尾。 +- **DR7:SDK↔协议形状翻译集中在 approval adapter。** `toBrokerRequest` / `toAgentCoreResponse` 是唯一发生 camelCase↔snake_case 翻译的地方;REST / WS 路由只做转发,不重新解释 approval 语义。 +- **DR8:当前代码布局已满足边界,无需迁移。** permission 在 `agent/permission/`(`PermissionManager` / `IPermissionService` / policies),approval 在 `services/approval/`(`IApprovalService` 契约)+ server 侧的 broker 实现。两个角色已经分离,没有发现重叠或渗漏,因此本次只出概念定稿,不做代码拆分。 diff --git a/.agents/skills/service-skill/explanation/domains/plugin.md b/.agents/skills/service-skill/explanation/domains/plugin.md new file mode 100644 index 000000000..ef850a744 --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/plugin.md @@ -0,0 +1,502 @@ +# Plugin 目标架构定稿 + +本文是**概念定稿**:不预设迁移路径。描述 plugin domain 的目标形态、依赖方 +向、决策记录,并**显式记录它落在哪一层**——这是 M4.9 的核心结论。 + +> 范围说明:ROADMAP M4.9 字面上写 “`services/plugin/pluginService.ts` +> (new) + 从 `KimiCore` 迁出 7 个 plugin 方法”。但 plugin domain **早已** +> 被抽取成一个完整的 runtime 模块,挂在 `#/plugin`( +> `packages/agent-core/src/plugin/`)。按 service-skill 的 M1.1 分层规则 +> (**runtime-consumed domain 留在 runtime,不进 `services/`**),M4.9 +> **不是**一次代码抽取,而是一次**边界确认 + 概念定稿**:确认 plugin +> domain 留在 `#/plugin`,确认 `KimiCore` 上的 7 个 CoreAPI plugin 方法是 +> wire protocol(留在 `KimiCore`),并说明为什么**不能**把它搬到 +> `services/`。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的插件流](#统一的插件流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,plugin domain 是一个**已抽取完成、位于 runtime 层的聚合**: + +- **plugin domain(插件生命周期 + 注册表 + 会话启动投影)**:管理 `kimiHomeDir` + 下已安装插件的**安装 / 启用 / 禁用 / 卸载 / 重载**,并把启用态插件投影成 + core runtime 在 session 启动时直接消费的三类派生数据——**skill 根目录** + (`pluginSkillRoots`)、**session-start 注入**(`enabledSessionStarts`)、 + **MCP 服务器**(`enabledMcpServers`)。真相是 **`kimiHomeDir` 下的插件文件 + (`plugins/managed//`)+ 注册表索引(`installed.json`)+ 进程内 + `records: Map`**。 + - **command(生命周期写入)**:`install` / `setEnabled` / + `setMcpServerEnabled` / `remove` / `reload`——修改注册表(落盘到 + `installed.json`),`install` 还会把源码物化到 `plugins/managed//`。 + - **query(读模型)**:`list` / `get` / `summaries` / `info`——只读 + `PluginRecord` 的派生快照(`PluginSummary` / `PluginInfo`)。无副作用。 + - **runtime projection(runtime 投影,非 service facade)**: + `pluginSkillRoots` / `enabledSessionStarts` / `enabledMcpServers`——从 + `records` 推导的**进程内派生读模型**,被 `KimiCore` 在 + `createSession` / `resumeSession` 启动时消费,用来接线 skills / MCP / + session-start。它们**不是** daemon / SDK 的 `*Service` facade,而是 + runtime 内部的投影——因此**留在 runtime 层**,不进 `services/`。 +- **落点**:`#/plugin`(`packages/agent-core/src/plugin/`),即 runtime + 模块(与 `src/session/` / `src/skill/` / `src/agent/` 同级)。契约 + `IPluginService` + 实现 `PluginService`(= `PluginManager` + + `_serviceBrand` + `unwrap` 迁移桥)见 `src/plugin/manager.ts`( + `PluginManager` `:35`,`IPluginService` `:284`,`PluginService` `:292`)。 +- **wire methods(留在 `KimiCore`)**:`KimiCore` 上的 7 个 CoreAPI plugin + 方法(`core-impl.ts:755-817`)是 **wire protocol**——它们是 CoreAPI 的 + JSON-RPC 表面,本身只做 `await this.pluginsReady` + + `assertPluginsLoaded()` + 委托给 `this.plugins` + 错误码映射(reload 失败 + → `PLUGIN_LOAD_FAILED`,info 未找到 → `PLUGIN_NOT_FOUND`)。它们**不是** + plugin domain 的业务逻辑,而是 wire 表面,因此**留在 `KimiCore`**。 + +**M4.9 结论:不移动、不拆分、不抽取。** plugin domain 已经在 `#/plugin` +(runtime)完整抽取,边界干净;`KimiCore` 上的 7 个方法是 wire protocol, +留在 `KimiCore`。把它搬到 `services/plugin/` 会强制 `rpc/core-impl.ts`( +runtime)→ `services/` 的反向 import,**违反 M0.1 fence**(见 +[依赖方向与边界](#依赖方向与边界))。 + +接口 / 实现落点见 `packages/agent-core/src/plugin/manager.ts` 的 +`PluginManager`(35 行)/ `IPluginService`(284 行)/ `PluginService` +(292 行),以及 `packages/agent-core/src/plugin/index.ts`(再导出,6–7 +行)。注册表持久化见 `src/plugin/store.ts`(`readInstalled` / +`writeInstalled`);派生类型见 `src/plugin/types.ts`(`PluginSummary` 99 +行 / `PluginInfo` 114 行 / `ReloadSummary` 131 行 / +`EnabledPluginSessionStart` 126 行 / `PluginMcpServerInfo` 50 行)。wire +表面见 `packages/agent-core/src/rpc/core-impl.ts`(7 个方法 755–817 行, +`assertPluginsLoaded` 819 行,`this.plugins` 字段 157 行、构造 205 行、 +ready 信号 210–212 行)。本文只确认边界;代码已在 `#/plugin`,无需变更。 + +## 第一性原理 + +### 1. plugin 是一个完整的 runtime 聚合,不是 “挂在 services/ 顶层的 facade” + +plugin domain 拥有**自己的真相 + 自己的生命周期 + 自己的派生投影**: + +- **真相**:`kimiHomeDir` 下的插件文件(每个插件物化到 + `plugins/managed//`)+ 注册表索引(`installed.json`,由 + `store.ts` 读写)+ 进程内 `records: Map` + (`manager.ts:37`)。`load()` / `reload()` 从 `installed.json` 重建 + `records`;`install` / `setEnabled` / `setMcpServerEnabled` / `remove` + 改 `records` 后 `persist()` 回 `installed.json`。 +- **生命周期写入(command)**:`install(source)` 解析 source + (local-path / zip-url / github)→ 下载 / 拷贝 → `parseManifest` → + 物化到 `plugins/managed//` → 登记 record → persist;`setEnabled` / + `setMcpServerEnabled` / `remove` 改 record + persist;`reload` 从磁盘 + 重建整份 `records` 并返回 `ReloadSummary`(added / removed / errors)。 +- **读模型(query)**:`list` / `get` 返回 `PluginRecord`; + `summaries` / `info` 把 record 投影成对外的 `PluginSummary` / + `PluginInfo`(含 `displayName` / `version` / `state` / + `skillCount` / `mcpServerCount` / `enabledMcpServerCount` / + `hasErrors` / `mcpServers` / `diagnostics` 等派生字段)。 +- **runtime 投影(被 core runtime 消费)**:`pluginSkillRoots`(启 + 用插件的 skill 根目录,喂给 skill discovery)、`enabledSessionStarts` + (启用插件声明的 session-start skill,喂给 session 启动注入)、 + `enabledMcpServers`(启用插件声明的 MCP 服务器,经 + `withPluginMcpRuntime` 注入 `KIMI_CODE_HOME` / `KIMI_PLUGIN_ROOT` 环境 + + `node` fallback 重写)。 + +这四类关注点**共享同一份 `records` Map 与同一个 `kimiHomeDir` 真相**,是 +同一个聚合的不同面,不是多个 domain。 + +### 2. command / query / runtime projection 各就其位,但共享同一份 record + +plugin domain 的方法可以按 service-skill 的角色清晰归类: + +- **command**:`install` / `setEnabled` / `setMcpServerEnabled` / `remove` + / `reload` 是 plugin 注册表的**唯一写入入口**。它们改 `records` 并 + `persist()` 到 `installed.json`;`install` 额外物化文件。 +- **query**:`list` / `get` / `summaries` / `info` 只读 `records`,无 + 副作用。 +- **runtime projection**:`pluginSkillRoots` / `enabledSessionStarts` / + `enabledMcpServers` 是**从 `records` 推导的派生读模型**,被 core + runtime 在 session 启动时消费。 + +三者共用同一份 `records`(`manager.ts:37`),但**业务方法互不调用**: +command 不调 query / projection 的业务方法;projection 不调 command。 +它们通过共享的 `PluginRecord` 协作,符合 service-skill 的 “command / +query / runtime 角色不互相调用业务方法”。 + +### 3. runtime projection 留在 runtime,不进 `services/` + +`pluginSkillRoots` / `enabledSessionStarts` / `enabledMcpServers` 看起来 +像 “给上层用的读模型”,但它们**不是** daemon / SDK 的 `*Service` +facade: + +- 它们是**进程内派生读模型**,从 `records` 直接推导,不经 JSON 序列化、 + 不经 RPC、不经 daemon。 +- 它们的**唯一消费者是 `KimiCore`**(runtime)——`createSession` / + `resumeSession` 在 session 启动时调用 `this.plugins.enabledSessionStarts()` + (`core-impl.ts:253` / `367`)与 + `this.mergePluginMcpConfig(...)` → `this.plugins.enabledMcpServers()` + (`core-impl.ts:872`),把派生数据接进 `Session` 构造。 +- 它们不属于 “经 daemon / SDK 对外暴露” 的表面;对外暴露给 daemon / SDK + 的是 `KimiCore` 上的 7 个 CoreAPI wire 方法(见下一节),不是这三个 + projection。 + +按 service-skill 的 M1.1 规则(“被 runtime aggregate 直接消费的 +repository / index / 派生读模型,**留在 runtime 层**,因为 runtime 不能 +反向 import `services/`”),这三个 projection 与它们的 owner +`PluginManager` **必须留在 runtime**。 + +### 4. `KimiCore` 上的 7 个 CoreAPI plugin 方法是 wire protocol,留在 `KimiCore` + +`KimiCore` 上有 7 个 CoreAPI plugin 方法(`core-impl.ts:755-817`): + +| CoreAPI 方法 | 行 | 行为 | +|---|---|---| +| `installPlugin` | 755 | `await pluginsReady` + `assertPluginsLoaded` → `this.plugins.install(source)` → 在 `summaries()` 里找到对应 `PluginSummary` 返回 | +| `listPlugins` | 762 | `await pluginsReady` + `assertPluginsLoaded` → `this.plugins.summaries()` | +| `setPluginEnabled` | 768 | `await pluginsReady` + `assertPluginsLoaded` → `this.plugins.setEnabled(id, enabled)` | +| `setPluginMcpServerEnabled` | 774 | `await pluginsReady` + `assertPluginsLoaded` → `this.plugins.setMcpServerEnabled(id, server, enabled)` | +| `removePlugin` | 784 | `await pluginsReady` + `assertPluginsLoaded` → `this.plugins.remove(id)` | +| `reloadPlugins` | 790 | `this.plugins.reload()`,失败时把 error 记入 `pluginsLoadError` 并抛 `KimiError(PLUGIN_LOAD_FAILED)`(**不**调 `assertPluginsLoaded`——reload 是恢复路径) | +| `getPluginInfo` | 805 | `await pluginsReady` + `assertPluginsLoaded` → `this.plugins.info(id)`,未找到抛 `KimiError(PLUGIN_NOT_FOUND)` | + +它们**不是** plugin domain 的业务逻辑——业务逻辑全在 `#/plugin` 的 +`PluginManager`。它们是 **CoreAPI 的 wire 表面**,职责只有三件: + +1. **就绪门控**:`await this.pluginsReady`(`core-impl.ts:210-212`, + 由 `this.plugins.load()` 驱动)+ `assertPluginsLoaded()`( + `core-impl.ts:819`,把 `load` 时捕获的 `pluginsLoadError` 以 + `PLUGIN_LOAD_FAILED` 抛出)——保证 wire 调用前插件已加载。 +2. **委托**:直接转发到 `this.plugins.`。 +3. **错误码映射**:把 runtime 异常投影成 CoreAPI 错误码(reload → + `PLUGIN_LOAD_FAILED`;info 未找到 → `PLUGIN_NOT_FOUND`),这是 + **wire 层职责**(CoreAPI 协议语义),不是 plugin domain 的业务规则。 + +wire 表面属于 `KimiCore`(CoreAPI 的实现体),不拆、不迁。把错误码映射 +留在 `KimiCore` 也是对的:`PLUGIN_LOAD_FAILED` / `PLUGIN_NOT_FOUND` 是 +CoreAPI 协议错误码,由 wire 层拥有。 + +### 5. 把 plugin 搬到 `services/` 会强制 runtime→services 反向 import + +`KimiCore`(`rpc/core-impl.ts`)**直接持有** `this.plugins: IPluginService` +(字段 `core-impl.ts:157`),并在构造里 `new PluginService({ kimiHomeDir })` +(`core-impl.ts:205`),还在 session 启动路径里同步调用 +`this.plugins.enabledSessionStarts()` / `enabledMcpServers()` +(`core-impl.ts:253, 367, 872`)。 + +如果把 plugin domain 搬到 `services/plugin/`,那么 `rpc/core-impl.ts` 必须 +`import { PluginService, type IPluginService } from +'@moonshot-ai/agent-core/services/plugin'`(或相对 `../services/plugin/...`)。 +这是一条 **runtime(`rpc/`)→ `services/` 的反向 import**,直接违反 +service-skill 的依赖方向规则,并被 M0.1 fence 当场拦截—— +`packages/agent-core/test/dependency-direction.test.ts` 的 `RUNTIME_DIRS` +包含 `rpc`(`:24`),它会扫描 `rpc/**` 并把任何解析进 `src/services` 的 +specifier 判为违规(`:64-74`)。 + +因此:**plugin domain 不能进 `services/`**。它留在 `#/plugin`(runtime), +由 `KimiCore` 直接消费,依赖方向合法(runtime 内部同级模块互引)。 + +## Service 拆分概览 + +| Service / 角色 | 一句话职责 | 角色 | Domain | +|---|---|---|---| +| `IPluginService`(`manager.ts:284`) | plugin 聚合 facade:`install` / `setEnabled` / `setMcpServerEnabled` / `remove` / `reload`(command)+ `list` / `get` / `summaries` / `info`(query)+ `pluginSkillRoots` / `enabledSessionStarts` / `enabledMcpServers`(runtime projection)。DI 装饰器 `createDecorator('pluginService')`(`manager.ts:290`) | command + query + runtime projection(facade) | plugin | +| `PluginService`(`manager.ts:292`) | `PluginManager` + `_serviceBrand` + `unwrap()` 迁移桥;构造签名 `PluginManagerOptions`(`kimiHomeDir`) | command + query + runtime projection(impl) | plugin | +| `PluginManager`(`manager.ts:35`) | plugin 真相 owner:`records: Map` + `kimiHomeDir`;install / enable / disable / remove / reload / load + 三类投影 | command + query + runtime projection(impl) | plugin | +| `store.ts`(`readInstalled` / `writeInstalled`) | 注册表 `installed.json` 持久化(版本化 `InstalledFile`) | persistence(非 service) | plugin | +| `manifest.ts`(`parseManifest`) / `source.ts`(`resolveInstallSource`) / `github-resolver.ts` / `archive.ts` | 插件清单解析 + source 解析 + github tarball 解析 + zip 下载 / 解压 | infrastructure(非 service) | plugin | +| `types.ts`(`PluginRecord` / `PluginSummary` / `PluginInfo` / `ReloadSummary` / `EnabledPluginSessionStart` / `PluginMcpServerInfo` / `normalizePluginId`) | plugin 聚合类型契约 | infrastructure(非 service) | plugin | +| `KimiCore` 7 个 plugin 方法(`core-impl.ts:755-817`)+ `assertPluginsLoaded`(`:819`) | CoreAPI wire 表面:就绪门控 + 委托 `this.plugins` + 错误码映射 | wire protocol(留在 `KimiCore`,非 service) | (CoreAPI wire) | + +> 只有这些角色。**不为 plugin 拆出 `IPluginCommandService` / +> `IPluginQueryService` / `IPluginRuntimeService`**——plugin 的 command / +> query / runtime projection 已按方法语义在同一份 `records` Map 上清晰分 +> 层,业务方法互不调用;为它们各抽接口只是把同一份 `records` Map 拆成三份 +> 同名复制 + 管道复制。**不把 plugin 搬到 `services/plugin/`**——它会被 +> runtime(`KimiCore`)直接消费,搬到 `services/` 会强制 runtime→services +> 反向 import,违反 M0.1 fence。**不把 `KimiCore` 上的 7 个 wire 方法下沉 +> 到 plugin domain**——它们是 CoreAPI wire 表面(就绪门控 + 错误码映射), +> 属于 `KimiCore`,不属于 plugin 业务逻辑。 + +模式参考: + +- query 侧对齐 [`query-service.md`](../../reference/patterns/query-service.md) + 的**只读 list / get 语义**:plugin 的 `list` / `get` / `summaries` / + `info` 都是只读读模型入口;但它们读的是进程内 `records` Map(不是跨 + scope 的 repository),无统一分页 / search / count,所以**不套用**完整的 + `BaseQuery` + scope 便捷方法骨架。 +- command 侧对齐 [`command-service.md`](../../reference/patterns/command-service.md) + 的**唯一写入入口**语义:plugin 的 `install` / `setEnabled` / + `setMcpServerEnabled` / `remove` / `reload` 是注册表的唯一写入入口;但 + plugin 的生命周期是 install / enable / disable / remove(不是 create / + update / archive / restore / purge / fork 族),所以**不套用**完整的 + `ICommandService` 生命周期骨架。 +- runtime 侧对齐 [`runtime-service.md`](../../reference/patterns/runtime-service.md) + 描述的 “由进程内对象 / 事件流推导的活状态” 的 owner 精神:plugin 的 + `pluginSkillRoots` / `enabledSessionStarts` / `enabledMcpServers` 是从 + 进程内 `records` 推导的派生读模型,被 core runtime 直接消费;但它**不 + 是** daemon / SDK 的 runtime facade(无 per-id 活状态订阅、无事件流投 + 递),所以**不抽出**独立的 `IPluginRuntimeService`,而是作为 + `IPluginService` 上的 projection 方法留在 runtime。 + +## 统一的插件流 + +plugin domain 的统一流分两条:**生命周期流**(command / query,经 wire 暴 +露给 daemon / SDK)和**会话启动投影流**(runtime projection,被 `KimiCore` +内部消费)。 + +### 生命周期流(daemon / SDK → wire → plugin domain) + +```text +daemon / SDK + → CoreAPI (JSON-RPC wire) + → KimiCore.installPlugin / listPlugins / setPluginEnabled / + setPluginMcpServerEnabled / removePlugin / reloadPlugins / getPluginInfo + (core-impl.ts:755-817 — await pluginsReady + assertPluginsLoaded + 错误码映射) + → this.plugins. (IPluginService) + → PluginManager 改 records Map + persist() → installed.json + (install 额外物化到 plugins/managed//) +``` + +- 读路径(`listPlugins` / `getPluginInfo`):wire → `this.plugins.summaries()` + / `info(id)` → 从 `records` 投影 `PluginSummary` / `PluginInfo` 返回。 +- 写路径(`installPlugin` / `setPluginEnabled` / + `setPluginMcpServerEnabled` / `removePlugin`):wire → + `this.plugins.` → 改 `records` + `persist()`。 +- 重载路径(`reloadPlugins`):wire → `this.plugins.reload()` → 从 + `installed.json` 重建整份 `records`,返回 `ReloadSummary`;失败时 + `KimiCore` 把 error 记入 `pluginsLoadError` 并抛 `PLUGIN_LOAD_FAILED` + (`core-impl.ts:790-803`)。 + +### 会话启动投影流(KimiCore 内部 → plugin domain → Session) + +```text +KimiCore.createSession / resumeSession + → await this.pluginsReady + → this.plugins.enabledSessionStarts() (core-impl.ts:253 / 367) + → EnabledPluginSessionStart[] (pluginId + skillName) + → 注入 Session 的 session-start skill + → this.mergePluginMcpConfig(callerMcp) + → this.plugins.enabledMcpServers() (core-impl.ts:872) + → Record (经 withPluginMcpRuntime 注入 env) + → merge 进 SessionMcpConfig → MCP 连接 + → (skill discovery 路径) this.plugins.pluginSkillRoots() + → SkillRoot[] → skill discovery +``` + +这条流**完全在 runtime 内部**——`KimiCore`(runtime)直接消费 +`IPluginService`(runtime)的 projection,不经 `services/`、不经 RPC、不 +经 daemon。这正是 plugin domain 必须留在 runtime 的根本原因。 + +## 关键场景 + +### S1:安装插件(local-path) + +1. daemon / SDK → `KimiCore.installPlugin({ source: '/abs/path' })`。 +2. wire:`await pluginsReady` + `assertPluginsLoaded`。 +3. `this.plugins.install(source)`:`resolveInstallSource` → `local-path` → + `normalizeInstallRoot`(必须绝对路径 + 存在 + 是目录)→ `parseManifest` + → `copyPluginToManagedRoot`(物化到 `plugins/managed//`)→ + `recordFrom` → `records.set(id, record)` → `persist()`。 +4. wire 从 `this.plugins.summaries()` 找到对应 `PluginSummary` 返回。 + +### S2:启用 / 禁用插件 + +1. daemon / SDK → `KimiCore.setPluginEnabled({ id, enabled })`。 +2. wire → `this.plugins.setEnabled(id, enabled)`:id 规范化 → 校验存在 → + 若状态变化则更新 record(`enabled` + `updatedAt`)+ `persist()`。 +3. 下一次 session 启动时,`enabledSessionStarts()` / `enabledMcpServers()` + 会跳过被禁用的插件。 + +### S3:启用 / 禁用某个插件的 MCP server + +1. daemon / SDK → `KimiCore.setPluginMcpServerEnabled({ id, server, enabled })`。 +2. wire → `this.plugins.setMcpServerEnabled(id, server, enabled)`:校验插件 + 存在 + 清单声明了该 MCP server → 更新 `record.capabilities.mcpServers[server]` + + `persist()`。 +3. 下一次 session 启动时,`enabledMcpServers()` 会据此过滤。 + +### S4:会话启动时注入插件 skill / MCP / session-start + +1. `KimiCore.createSession` / `resumeSession`:`await pluginsReady`。 +2. `this.plugins.enabledSessionStarts()` → 启用且 `state === 'ok'` 且声明了 + `manifest.sessionStart.skill` 的插件 → `EnabledPluginSessionStart[]` → + session 启动时加载对应 skill。 +3. `this.plugins.enabledMcpServers()` → 启用且 `state === 'ok'` 的插件声明 + 的 MCP server(经 `isMcpServerEnabled` 过滤 + `withPluginMcpRuntime` 注入 + `KIMI_CODE_HOME` / `KIMI_PLUGIN_ROOT` env + native-binary `node` + fallback 重写)→ merge 进 `SessionMcpConfig`。 +4. skill discovery 路径:`this.plugins.pluginSkillRoots()` → 启用插件声明 + 的 skill 目录 → 喂给 `discoverSkills`。 + +### S5:重载插件(恢复路径) + +1. daemon / SDK → `KimiCore.reloadPlugins({})`。 +2. wire **不**调 `assertPluginsLoaded`(reload 本身就是从磁盘恢复的路径)。 +3. `this.plugins.reload()`:从 `installed.json` 重建 `records`,逐个 + `materialize`,失败项记入 `ReloadSummary.errors`,返回 `{ added, removed, + errors }`。 +4. wire:成功则清 `pluginsLoadError`;失败则记入 `pluginsLoadError` 并抛 + `KimiError(PLUGIN_LOAD_FAILED)`。 + +### S6:插件加载失败时的降级 + +1. `KimiCore` 构造:`this.pluginsReady = this.plugins.load().catch(e => + this.pluginsLoadError = e)`(`core-impl.ts:210-212`)——**捕获而非吞 + 掉**错误。 +2. mutator / 显式 `/plugins` 读(`installPlugin` / `listPlugins` / + `setPluginEnabled` / `setPluginMcpServerEnabled` / `removePlugin` / + `getPluginInfo`)调 `assertPluginsLoaded()` → 抛 `PLUGIN_LOAD_FAILED`, + 让用户看到问题。 +3. `createSession` / `resumeSession` `await pluginsReady` 后直接读投影 + (不 assert)→ 静默降级(无 plugin skill、无 session-start 注入),保证 + harness 仍能启动。`reloadPlugins` 成功后清 `pluginsLoadError`。 + +## 派生交互映射 + +| 对外动作 | CoreAPI wire 方法 | plugin domain 方法 | 真相 / 副作用 | +|---|---|---|---| +| 安装插件 | `installPlugin` (`:755`) | `PluginManager.install` (`manager.ts:60`) | 物化文件到 `plugins/managed//` + 改 `records` + `persist()` | +| 列出插件 | `listPlugins` (`:762`) | `PluginManager.summaries` (`manager.ts:243`) | 只读 `records` → `PluginSummary[]` | +| 启用 / 禁用插件 | `setPluginEnabled` (`:768`) | `PluginManager.setEnabled` (`manager.ts:140`) | 改 `records` + `persist()` | +| 启用 / 禁用插件 MCP server | `setPluginMcpServerEnabled` (`:774`) | `PluginManager.setMcpServerEnabled` (`manager.ts:150`) | 改 `records.capabilities` + `persist()` | +| 卸载插件 | `removePlugin` (`:784`) | `PluginManager.remove` (`manager.ts:173`) | 改 `records` + `persist()` | +| 重载插件 | `reloadPlugins` (`:790`) | `PluginManager.reload` (`manager.ts:181`) | 从 `installed.json` 重建 `records`;失败抛 `PLUGIN_LOAD_FAILED` | +| 查询插件详情 | `getPluginInfo` (`:805`) | `PluginManager.info` (`manager.ts:247`) | 只读 `records` → `PluginInfo`;未找到抛 `PLUGIN_NOT_FOUND` | +| (内部)会话启动注入 | — | `PluginManager.enabledSessionStarts` (`manager.ts:216`) | 只读 `records` → `EnabledPluginSessionStart[]` | +| (内部)会话启动 MCP | — | `PluginManager.enabledMcpServers` (`manager.ts:227`) | 只读 `records` → `Record` | +| (内部)skill discovery | — | `PluginManager.pluginSkillRoots` (`manager.ts:201`) | 只读 `records` → `SkillRoot[]` | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service,标注实际落点): + +```text +Runtime / Aggregate (in-process, #/plugin = src/plugin/) + IPluginService / PluginService / PluginManager (plugin command + query + runtime projection) + store.ts (installed.json persistence) / manifest.ts / source.ts / github-resolver.ts / archive.ts + types.ts (PluginRecord / PluginSummary / PluginInfo / ReloadSummary / EnabledPluginSessionStart / PluginMcpServerInfo) + +Runtime / CoreAPI wire (in-process, src/rpc/) + KimiCore.installPlugin / listPlugins / setPluginEnabled / setPluginMcpServerEnabled / + removePlugin / reloadPlugins / getPluginInfo (core-impl.ts:755-817 — wire protocol) + KimiCore.assertPluginsLoaded (core-impl.ts:819 — 就绪门控) + KimiCore (this.plugins field :157, construction :205, ready signal :210-212) + KimiCore.createSession / resumeSession (消费 enabledSessionStarts + enabledMcpServers) + +Persistence / Truth + kimiHomeDir/plugins/managed// (物化的插件文件) + kimiHomeDir/.../installed.json (注册表索引,store.ts 读写) + records: Map (进程内注册表,PluginManager 持有) + +Transport (above agent-core) + CoreAPI (JSON-RPC) (7 个 plugin 方法经 KimiCore → SDK) +``` + +依赖关系: + +```text +IPluginService.install/setEnabled/setMcpServerEnabled/remove → records Map + store.persist (command → 真相) +IPluginService.reload → store.readInstalled + materialize + records Map (command → 重建真相) +IPluginService.list/get/summaries/info → records Map (query → 只读) +IPluginService.enabledSessionStarts/enabledMcpServers/pluginSkillRoots → records Map (runtime projection → 只读派生) +KimiCore.installPlugin/listPlugins/... → await pluginsReady + assertPluginsLoaded + this.plugins. (wire → plugin) +KimiCore.createSession/resumeSession → await pluginsReady + this.plugins.enabledSessionStarts/enabledMcpServers (runtime → plugin projection) +store.ts (readInstalled/writeInstalled) → installed.json (persistence → 磁盘) +parseManifest / resolveInstallSource / resolveGithubSource / downloadZip / extractZip → 文件 / 网络 (infrastructure) +``` + +禁止的边界: + +```text +#/plugin/** → services/** (plugin 是 runtime;不得反向 import services/) +rpc/core-impl.ts → services/plugin/** (若 plugin 搬到 services/,core-impl 会违反 M0.1 fence) +services/** → (持有 plugin 真相) (plugin 真相在 #/plugin + kimiHomeDir,不在 services/) +KimiCore (wire) → (在 wire 里实现 plugin 业务规则) (业务逻辑在 PluginManager;wire 只门控 + 委托 + 错误码映射) +PluginManager (command) → enabledSessionStarts/enabledMcpServers/pluginSkillRoots 业务方法 (command 不调 projection 业务方法;共享 records 协作) +PluginManager (projection) → install/setEnabled/remove (projection 不回调 command) +getPluginInfo (wire) → (未找到时返回 undefined) (wire 必须把 “未找到” 映射成 PLUGIN_NOT_FOUND) +reloadPlugins (wire) → (失败时静默吞错) (wire 必须把失败映射成 PLUGIN_LOAD_FAILED + 记 pluginsLoadError) +``` + +关键不变量: + +- plugin domain **物理隔离在 `#/plugin`(`src/plugin/`)**,与 + `src/session/` / `src/skill/` / `src/agent/` 同级,是 runtime 模块。 + `src/services/` 下**没有** `plugin/` 目录(已确认)。 +- `KimiCore` 经 `#/plugin`(bare package subpath)import + `PluginService` / `IPluginService`(`core-impl.ts:6`)——这是 runtime + 内部同级模块互引,**不**经过 `services/`,依赖方向合法。 +- plugin 的真相是 `kimiHomeDir` 下的插件文件 + `installed.json` + 进程内 + `records` Map,三者同源;`services/` 不持有 plugin 真相。 +- `KimiCore` 上的 7 个 plugin 方法是 wire protocol:就绪门控( + `await pluginsReady` + `assertPluginsLoaded`)+ 委托 `this.plugins` + + 错误码映射(`PLUGIN_LOAD_FAILED` / `PLUGIN_NOT_FOUND`)。它们**不**实现 + plugin 业务规则。 +- command / query / runtime projection 共用 `records` Map,但业务方法互不 + 调用,符合 “角色不互相调用业务方法”。 +- `reloadPlugins` 是恢复路径,**不**调 `assertPluginsLoaded`;其余 6 个 + wire 方法都 `await pluginsReady` + `assertPluginsLoaded`(mutator / 显式 + 读把加载错误暴露给用户;`createSession` / `resumeSession` 静默降级)。 +- M0.1 fence(`dependency-direction.test.ts`)的 `RUNTIME_DIRS` 包含 `rpc` + (`:24`):任何 `rpc/**` → `src/services` 的 import 都会被拦截( + `:64-74`)。这把 “plugin 不能进 `services/`” 变成了可执行的硬约束。 + +## 决策记录 + +- **DR1:plugin 是一个独立 domain,留在 `#/plugin`(runtime),不进 + `services/`。** plugin domain 拥有自己的真相(`kimiHomeDir` 插件文件 + + `installed.json` + `records` Map)、生命周期(install / enable / disable + / remove / reload)、读模型(list / get / summaries / info)与 runtime + 投影(skill roots / session-starts / MCP servers)。它被 `KimiCore` + (runtime,`rpc/core-impl.ts`)**直接消费**——`this.plugins` 字段( + `core-impl.ts:157`)、构造(`core-impl.ts:205`)、session 启动投影调用 + (`core-impl.ts:253, 367, 872`)。按 service-skill M1.1 规则 + (runtime-consumed domain 留在 runtime),plugin **必须**留在 + `#/plugin`。搬到 `services/plugin/` 会强制 `rpc/core-impl.ts` → + `services/` 的反向 import,违反 M0.1 fence( + `dependency-direction.test.ts:24` 扫描 `rpc`)。 +- **DR2:plugin 是 command + query + runtime projection 三角色聚合,共用 + 同一份 `records` Map。** `install` / `setEnabled` / `setMcpServerEnabled` + / `remove` / `reload` = command(注册表写入 + `persist()`);`list` / + `get` / `summaries` / `info` = query(只读 `PluginRecord` 投影); + `pluginSkillRoots` / `enabledSessionStarts` / `enabledMcpServers` = + runtime projection(被 core runtime 消费的派生读模型)。三者业务方法互不 + 调用,经共享 `records` 协作。 +- **DR3:`KimiCore` 上的 7 个 plugin 方法是 wire protocol,留在 + `KimiCore`。** 它们(`core-impl.ts:755-817`)不是 plugin 业务逻辑,而是 + CoreAPI wire 表面:就绪门控(`await pluginsReady` + + `assertPluginsLoaded`)+ 委托 `this.plugins` + 错误码映射(reload → + `PLUGIN_LOAD_FAILED`,info 未找到 → `PLUGIN_NOT_FOUND`)。错误码是 + CoreAPI 协议语义,由 wire 层拥有;业务规则全在 `PluginManager`。 +- **DR4:`reloadPlugins` 不调 `assertPluginsLoaded` 不构成 muddle。** + reload 是**恢复路径**——它从 `installed.json` 重建 `records`,本身就是在 + 加载失败时重新尝试的入口。其余 6 个 wire 方法(mutator / 显式读)调 + `assertPluginsLoaded` 把加载错误暴露给用户;`createSession` / + `resumeSession` `await pluginsReady` 后直接读投影以静默降级。三层语义各 + 就其位,不需要统一。 +- **DR5:不为 plugin 拆 `IPluginCommandService` / `IPluginQueryService` / + `IPluginRuntimeService`。** plugin 的 command / query / runtime + projection 已按方法语义在同一份 `records` Map 上清晰分层,业务方法互不 + 调用。再抽三层接口只是把同一份 `records` Map 拆成三份同名复制 + 管道复 + 制,不带来新契约。projection 是 `IPluginService` 上的方法(留在 + runtime),不是独立的 `IPluginRuntimeService` facade(无 per-id 活状态 + 订阅、无事件流投递)。 +- **DR6:不把 `KimiCore` 的 7 个 wire 方法下沉到 plugin domain。** 把 + wire 方法搬进 `PluginManager` 会把 CoreAPI 协议错误码( + `PLUGIN_LOAD_FAILED` / `PLUGIN_NOT_FOUND`)和就绪门控( + `pluginsReady` / `assertPluginsLoaded`)带进 plugin domain,污染聚合 + 的业务纯净性。wire 表面属于 `KimiCore`(CoreAPI 实现体),plugin domain + 只负责业务。 +- **DR7:不需要改名。** `plugin` / `IPluginService` / `PluginService` / + `PluginManager` 的命名已精确反映其职责(plugin = 插件生命周期 + 注册表 + + 会话启动投影)。`PluginManager`(业务 owner)+ `PluginService`(DI + facade + `_serviceBrand` + `unwrap` 迁移桥)的分层与 service-skill 对 + “manager + service” 形状的容忍一致。 +- **DR8:不需要移动、拆分或抽取。** plugin domain 已物理隔离在 `#/plugin` + (runtime),`KimiCore` 的 7 个 wire 方法已正确留在 `KimiCore`, + `services/` 下无 `plugin/` 目录,无 god 残留(plugin 不知道 config / + session / mcp 等其他 domain,只被它们在 session 启动时消费投影),M0.1 + fence 干净。M4.9 结论:**保持现状**,仅在本概念定稿中固化边界——这是 + 一次**边界确认 + 概念定稿**,不是代码抽取。 diff --git a/.agents/skills/service-skill/explanation/domains/session-workspace.md b/.agents/skills/service-skill/explanation/domains/session-workspace.md new file mode 100644 index 000000000..26e83553b --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/session-workspace.md @@ -0,0 +1,234 @@ +# Session / Workspace Service 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一 Session Query 模型](#统一-session-query-模型) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里: + +- `WorkspaceService` 管“目录上下文”:workspace 注册表、root 解析、最近打开、目录浏览。 +- `SessionService` 管“会话生命周期”:create / get / update / archive / restore / purge / fork / child。 +- `SessionQueryService` 管“会话读模型”:list / search / count / children。 +- `SessionRuntimeService` 管“活状态”:status、active turn、approval、question、prompt 状态。 + +**workspace 下 list Session 和全局 list Session 不是两套接口,而是同一个 Session Query 的两个 scope。** + +接口定义见 `reference/domains/session-workspace/`,本文只承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. 一个 aggregate 只由一个 Service 拥有 + +Session 和 Workspace 是两个独立 aggregate: + +- Workspace 描述“某个根目录 / 项目上下文”。 +- Session 描述“一次会话 / agent 运行上下文”。 + +因此: + +- Workspace 删除注册项,不级联删除 Session。 +- Session 引用 Workspace,但生命周期不由 Workspace 拥有。 +- Workspace 可作为 Session 查询 scope,但不重复实现 Session list。 + +### 2. 命令 / 查询 / 运行时状态分开 + +| 类型 | 关注 | 归属 | +|---|---|---| +| Command | 改变生命周期 | `SessionService` | +| Query | 列表、搜索、筛选、计数 | `SessionQueryService` | +| Runtime | 活状态、运行中信息 | `SessionRuntimeService` | + +普通列表不为了显示状态而 resume 所有 Session。列表默认读 index;状态增强只作用于当前页或用户明确打开的 Session。 + +### 3. 统一查询,而不是按入口重复实现 + +`workspace 下 list` 和 `全局 list` 只是同一个查询的不同 scope: + +```text +listSessions(scope = workspace) +listSessions(scope = global) +listSessions(scope = children) +``` + +所有过滤、排序、分页、归档可见性只能有一份实现。 + +### 4. Service 层解析标识 + +`workspace_id`、`workDir`、`parentSessionId` 都在 Service 层解析和校验,不让 transport 层承载业务规则。 + +### 5. 持久化真相与派生索引分开 + +- Repository 保存 aggregate 真相。 +- Index 保存用于 list / search / count 的轻量读模型。 +- `workspace_id` 是 `workDir` 的派生字段;查询索引可冗余存储以提升效率,但真相是 `workDir`。 + +## Service 拆分概览 + +| Service | 一句话职责 | 详细契约 | +|---|---|---| +| `IWorkspaceService` | Workspace 注册表、root 解析、目录浏览 | [reference](../../reference/domains/session-workspace/workspace-service.md) | +| `ISessionService` | Session aggregate 的生命周期命令 | [reference](../../reference/domains/session-workspace/session-service.md) | +| `ISessionQueryService` | Session 的读模型(list / search / count / children) | [reference](../../reference/domains/session-workspace/session-query-service.md) | +| `ISessionRuntimeService` | Session 的活状态(status / live state / 事件) | [reference](../../reference/domains/session-workspace/session-runtime-service.md) | + +共享类型见 [types.md](../../reference/domains/session-workspace/types.md)。 + +## 统一 Session Query 模型 + +```ts +type SessionQueryScope = + | { kind: "global" } + | { kind: "workspace"; workspaceId: string } + | { kind: "workDir"; workDir: string } + | { kind: "children"; parentSessionId: string }; + +interface SessionListQuery extends CursorQuery { + scope?: SessionQueryScope; + + status?: SessionStatus | SessionStatus[]; + archived?: "exclude" | "include" | "only"; + + parentSessionId?: string | null; + childKind?: "fork" | "child"; + + search?: string; + tags?: string[]; + + createdAfter?: string; + createdBefore?: string; + updatedAfter?: string; + updatedBefore?: string; + + orderBy?: "updatedAt" | "createdAt" | "title" | "lastOpenedAt"; + orderDir?: "asc" | "desc"; +} +``` + +默认行为: + +- `scope` 省略时等价于 `{ kind: "global" }`。 +- `archived` 默认 `"exclude"`。 +- `orderBy` 默认 `"updatedAt"`,`orderDir` 默认 `"desc"`。 + +## 关键场景 + +### 场景 A:在某个 Workspace 下列 Session + +```ts +sessionQueryService.listByWorkspace(workspaceId, query); +``` + +内部解析: + +```text +workspace_id → root → workDir → SessionIndex.list({ scope: { kind: "workDir", workDir } }) +``` + +### 场景 B:全局 list Session + +```ts +sessionQueryService.listGlobal(query); +``` + +内部解析: + +```text +SessionIndex.list({ scope: { kind: "global" } }) +``` + +### 场景 C:查看 children + +```ts +sessionQueryService.listChildren(parentSessionId, query); +``` + +内部解析: + +```text +SessionIndex.list({ scope: { kind: "children", parentSessionId } }) +``` + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 | +|---|---| +| 从 workspace 创建 session | `SessionService.create({ workspaceId })` | +| 从绝对目录创建 session | `SessionService.create({ workDir })` | +| 查看 session 详情 | `SessionService.get(id)` | +| 重命名 session | `SessionService.update(id, { title })` | +| 更新 metadata | `SessionService.update(id, { metadata })` | +| 标记最近打开 | `SessionService.touch(id)` | +| 归档 session | `SessionService.archive(id)` | +| 恢复 session | `SessionService.restore(id)` | +| 永久删除 | `SessionService.purge(id)` | +| fork session | `SessionService.fork(id, input)` | +| 创建 child session | `SessionService.createChild(id, input)` | +| workspace 列表 | `SessionQueryService.listByWorkspace(workspaceId, query)` | +| 全局列表 | `SessionQueryService.listGlobal(query)` | +| 查看 children | `SessionQueryService.listChildren(parentId, query)` | +| 搜索 session | `SessionQueryService.list({ search })` | +| 查看已归档 | `SessionQueryService.list({ archived: "only" })` | +| 查看运行状态 | `SessionRuntimeService.getStatus(id)` | +| 订阅状态变化 | `SessionRuntimeService.onDidChangeStatus` | +| workspace 注册表 | `WorkspaceService.list()` / `WorkspaceService.recent()` | +| workspace 目录浏览 | `WorkspaceService.browse()` | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service + IWorkspaceService + ISessionService + ISessionQueryService + ISessionRuntimeService + +Domain / Persistence + IWorkspaceStore + ISessionRepository + ISessionIndex + +Infrastructure + 事件总线 + 外部进程 / 运行时 + 文件系统 +``` + +依赖关系: + +```text +ISessionService → ISessionRepository, ISessionIndex, IWorkspaceService +ISessionQueryService → ISessionIndex, IWorkspaceService +ISessionRuntimeService → (Runtime projection sources) +IWorkspaceService → IWorkspaceStore, ISessionIndex +``` + +禁止的循环: + +```text +IWorkspaceService ⇄ ISessionQueryService +``` + +如果 `WorkspaceService` 需要 `session_count`,依赖低层 `ISessionIndex`,而不是 `ISessionQueryService`。 + +## 决策记录 + +- **DR1:Workspace 不拥有 Session 生命周期。** Workspace 只是 Session 查询 scope 和 root 解析来源。 +- **DR2:Session 删除默认是 archive。** 硬删除使用显式 `purge`。 +- **DR3:global list 与 workspace list 共用同一查询模型。** 区别只是 `scope`。 +- **DR4:普通 Session list 不依赖 runtime。** 列表读 index,状态由 `SessionRuntimeService` 单独提供。 +- **DR5:业务解析放在 Service 层。** transport 只负责参数映射,不承载 `workspace_id → workDir` 这类业务规则。 +- **DR6:`workspace_id` 是 `workDir` 的派生字段。** Session 持久化真相是 `workDir`;同时传入时必须校验一致。 +- **DR7:跨 aggregate 删除必须显式命名。** `WorkspaceService.delete` 不删 Session;要级联删除时使用专门的高阶命令。 diff --git a/.agents/skills/service-skill/explanation/domains/skill.md b/.agents/skills/service-skill/explanation/domains/skill.md new file mode 100644 index 000000000..5a2421e19 --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/skill.md @@ -0,0 +1,316 @@ +# Skill Service 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的 skill 激活 / 加载流](#统一的-skill-激活--加载流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,**skill** 是一个 domain,承载四类相邻但职责不同的关注点: + +- **registry / truth(注册表 / 真相)**:某个 session 当前可见的 skill 定义集合——`SessionSkillRegistry` 维护 `byName` / `byPluginAndName` / `roots` / `skipped`,负责从磁盘发现(`loadRoots`)、注册内置 skill(`registerBuiltinSkill`)、按名查找(`getSkill`)、渲染 skill prompt(`renderSkillPrompt`)、列出可见 skill(`listSkills`)。它是 skill aggregate 的**真相与发现层**,不是 daemon facade。 +- **query(描述符查询)**:面向 daemon / SDK 的**只读 skill 描述符查询**——`ISkillService.list(sessionId)`,把内部的 `SkillSummary` 适配成协议的 `SkillDescriptor`(`toProtocolSkill`)。它回答“这个 session 有哪些 skill 可用”。它**没有任何写入入口**。 +- **command(激活)**:面向 daemon / SDK 的**激活命令**——`ISkillService.activate(sessionId, skillName, args)`,等价于在 TUI 里输入 `/ `。它在 session 的 main agent 上启动一个 turn、发出 `skill.activated` 事件、记录 telemetry。它是 skill aggregate 对外的**唯一写入入口**。 +- **runtime loading(加载)**:把 skill 定义**加载进 agent 进程**——`loadSkills()` 在 session 启动时解析 skill roots(project / user / extra / builtin)、调用 `registry.loadRoots(roots)` 发现并注册、注册内置 skill。它是**每次 session 启动重新发现**的运行时动作,不是持久化真相。M5.1 将把它移入一个 `SkillRuntime`,订阅 `onSessionDidStart` 生命周期钩子。 + +**这四类关注点不需要合并、也不需要进一步拆分。** 边界当前就是干净的: + +- query(`list`)只做**只读描述符查询 + 协议形状适配**,不写 registry、不触发激活、不持有运行时状态。 +- command(`activate`)只做**激活命令**(启动 turn + 事件 + telemetry),不重新实现 skill 发现、不直接读 registry 真相——它经 CoreAPI 到达 in-process 的 `SkillManager.activate`,后者才查 registry、渲染 prompt、装配 turn。 +- runtime loading(`loadSkills`)只做**启动期发现与注册**,不暴露 daemon 形状、不处理激活命令。 +- registry(`SessionSkillRegistry`)只做**真相 + 发现 + prompt 渲染**,是下层被 facade / runtime 消费的 contract,自身不暴露在 SDK 边界。 + +**关系一句话:registry 拥有 skill 定义的真相与发现;query 经 `ISkillService.list` 把描述符暴露给 daemon / SDK;command 经 `ISkillService.activate`(落到 in-process `SkillManager.activate`)触发激活;runtime loading 在 session 启动时把定义加载进 registry,M5.1 改由 `SkillRuntime` 订阅 `onSessionDidStart` 完成。** + +接口定义见 `services/skill/skill.ts` 的 `ISkillService`(daemon/SDK query + command facade)、`agent/skill/index.ts` 的 `SkillManager` / `IAgentSkillService`(in-process 激活实现)、`skill/registry.ts` 的 `SessionSkillRegistry` / `ISkillRegistryService`(真相 + 发现层)、`session/index.ts` 的 `loadSkills`(runtime loading)。本文只承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. “发现真相”“查询描述符”“激活命令”“启动期加载”是四个不同的关注点 + +skill 这个 domain 同时涉及四件事,它们的生命周期、真相、副作用都不同: + +- **发现真相(registry)**:给定一个 session,从磁盘(project / user / extra / builtin)发现并持有一份 skill 定义集合,支持按名查找、按 plugin 查找、prompt 渲染、列表。真相在磁盘 + 内存索引,随 session 重建。 +- **描述符查询(query)**:把当前 registry 的可见 skill 列表**适配成协议形状**返回给 daemon / SDK。只读、可重入、无副作用,scope 固定为单个 session。 +- **激活命令(command)**:在 session 的 main agent 上**触发一次激活**——校验 skill 可被用户激活、渲染 prompt、启动 turn、发出事件、记录 telemetry。有副作用(改变 agent 状态、产生事件流)。 +- **启动期加载(runtime loading)**:session 启动时**重新发现** skill 并填入 registry,是一次性的运行时动作;重启后由同一路径重建,不写回任何持久化真相。 + +因此:registry 是下层 truth;query 与 command 是 daemon / SDK 边界的 facade;runtime loading 是把 truth 装入 registry 的启动动作。 + +### 2. 命令 / 查询 / 运行时状态分开(按需要引入) + +按 service-skill 的角色表,本 domain 实际用到三类: + +| 类型 | 关注 | 归属 | +|---|---|---| +| Query | 单 scope skill 描述符列表:`list`(协议形状适配) | `ISkillService.list`(`SkillService`) | +| Command | skill 激活:`activate`(启动 turn + 事件 + telemetry) | `ISkillService.activate`(`SkillService`)→ in-process `SkillManager.activate` | +| Runtime loading | session 启动期 skill 发现与注册 | `loadSkills`(M5.1 移入 `SkillRuntime`,订阅 `onSessionDidStart`) | +| Registry / truth | skill 定义集合 + 发现 + prompt 渲染 | `SessionSkillRegistry`(`ISkillRegistryService`) | + +按 [Domain decomposition](../../../../../packages/agent-core/src/services/AGENTS.md) 的规范:“不是每个 domain 都需要五件套,仅当某角色有明确 owner 且契约非空时才引入”。 + +- **query 已经是单方法 facade。** `ISkillService.list` 只有一个读方法,scope 固定为单 session,无分页 / search / count——它**就是** skill aggregate 在 SDK 边界的 query 角色。再拆一个 `ISkillQueryService` 不会引入新的契约,只是把同一个方法换个接口名。 +- **command 已经是单方法 facade。** `ISkillService.activate` 只有一个写方法,没有 create / update / archive / fork 等生命周期族——它**就是** skill aggregate 在 SDK 边界的 command 入口。再拆一个 `ISkillCommandService` 同样是同名复制。 +- **runtime loading 不是查询模型,也不是命令。** `loadSkills` 是 session 启动期的发现动作,没有 per-id 状态读取、没有 list / search / count、不暴露 SDK 形状;它最接近 [`runtime-service.md`](../../reference/patterns/runtime-service.md) 描述的“由进程内对象 / 事件流推导的活状态”的**填充动作**,但目前是 Session 构造器里的一段 fire-and-forget,M5.1 才升级为正式 `SkillRuntime` 角色。 + +### 3. registry 不表达 SDK 形状,facade 不持有发现逻辑 + +边界保持干净: + +- registry(`SessionSkillRegistry`)只持有**定义真相 + 发现 + prompt 渲染**:`byName` / `byPluginAndName` / `roots` / `skipped`、`discoverSkills`、`renderSkillPrompt`。它不知道 daemon 的 `SkillDescriptor` 形状、不做 snake_case / camelCase 适配、不发出 `skill.activated` 事件。 +- facade(`ISkillService`)只持有**SDK 适配 + session 解析**:`toProtocolSkill` 的形状翻译、`_requireLoadedSession` 的 session 存在性校验、`coreApi()` 的 in-process 派发。它不直接读 registry 的 `byName`,不重新实现 skill 发现。 + +这条边界是“是否需要拆分 / 合并”的唯一硬指标:只要 registry 不混入 SDK 形状、facade 不混入发现逻辑,两类关注点就是清晰的。 + +### 4. 激活是 command,落点是 in-process `SkillManager.activate` + +`ISkillService.activate` 是 daemon / SDK 的**命令 facade**——它的实现非常薄:校验 session → 经 `coreApi().activateSkill(...)` 派发。真正的激活副作用发生在 in-process 的 agent 侧: + +- `KimiCore.activateSkill` → `sessionApi.activateSkill` → `rpc-controller` 的 `activateSkill` handler → `this.host.skills.activate(payload)`(即 `SkillManager.activate`)。 +- `SkillManager.activate` 查 registry(`getSkill`)、校验类型可用户激活(`isUserActivatableSkillType`)、渲染 prompt(`renderSkillPrompt` + `renderUserSlashSkillPrompt`)、调 `recordActivation`。 +- `recordActivation` 发出 `skill.activated` 事件、记录 `skill_invoked` / `flow_invoked` telemetry,并把渲染后的 prompt 通过 `agent.turn.prompt(input, origin)` 推入 turn(origin = `skill_activation`,trigger = `user-slash`)。 + +所以 command 角色横跨两层:`ISkillService.activate` 是对外 facade,`SkillManager.activate` 是 in-process 实现。二者经 CoreAPI 单向连接,facade 不重新实现激活逻辑。 + +### 5. 启动期加载是 runtime,不是持久化动作 + +skill 定义不写回任何 aggregate 真相——每次 session 启动都由 `loadSkills` 重新发现: + +- `loadSkills` 解析 skill roots(user home / brand home / workDir / explicit / extra / plugin / builtin),调 `registry.loadRoots(roots)`(内部 `discoverSkills`),再 `registerBuiltinSkills` 注入内置 skill。 +- 它作为 `this.skillsReady` 这个 fire-and-forget promise 挂在 Session 构造器上;`listSkills` / `flushMetadata` / 需要 skill 的路径会先 `await this.skillsReady`。 +- 重启后由同一路径重建 registry,没有任何“加载状态”需要持久化。 + +因此 runtime loading 是一次性的、可重建的启动动作。当前它直接住在 Session 构造器里;M5.1 将把它抽到 `SkillRuntime`,由 `SkillRuntime` 订阅 `onSessionDidStart` 触发——这样 skill 加载与其它 session 启动期副作用(如 MCP 连接)共享同一生命周期编排,而不是各自挂在构造器上。 + +### 6. Service 层 facade 暴露 query + command,transport 层只做形状适配 + +- **query**:registry 的描述符收集(`listSkills` → `summarizeSkill`)在 agent 进程内完成;SDK 边界 `ISkillService.list` 只做 `SkillSummary` → `SkillDescriptor` 的形状翻译(`toProtocolSkill`);REST 路由只负责 session 校验与错误码映射(`SessionNotFoundError` → 40401),不重新解释 skill 语义。 +- **command**:激活副作用在 agent 进程内的 `SkillManager.activate` 完成;SDK 边界 `ISkillService.activate` 只做 session 解析 + in-process 派发 + 错误码翻译(`SKILL_NOT_FOUND` / `SKILL_NAME_EMPTY` → `SkillNotFoundError` → 40415,`SKILL_TYPE_UNSUPPORTED` → `SkillNotActivatableError` → 40912)。 +- **runtime loading**:`loadSkills` 在 Session 启动期完成,不暴露到 daemon / SDK 边界;daemon 只在 `list` / `activate` 时间接地 `await skillsReady`。 + +## Service 拆分概览 + +| Service / 角色 | 一句话职责 | 角色 | +|---|---|---| +| `ISkillService` | daemon/SDK skill facade:`list`(query,描述符查询)+ `activate`(command,激活) | query + command(facade) | +| `SkillService` | `ISkillService` 实现:session 解析 + `toProtocolSkill` 适配 + `coreApi()` in-process 派发 + 错误码翻译 | query + command(impl) | +| `IAgentSkillService` / `AgentSkillService` | in-process 激活实现:`activate`(查 registry / 渲染 prompt / 启动 turn)+ `recordActivation`(事件 + telemetry) | command(runtime impl) | +| `SkillManager` | `AgentSkillService` 的基类,承载激活副作用 | command(runtime impl) | +| `ISkillRegistryService` / `SkillRegistryService` | skill 定义真相 + 发现 + prompt 渲染 | registry / truth | +| `SessionSkillRegistry` | registry 实现:`byName` / `byPluginAndName` / `loadRoots` / `renderSkillPrompt` / `listSkills` | registry / truth(impl) | +| `loadSkills`(M5.1 → `SkillRuntime`) | session 启动期 skill 发现与注册 | runtime loading | + +> 只有这些角色。**不引入 `ISkillQueryService` / `SkillQueryService`**——`ISkillService.list` 已经是单方法、单 scope、无分页的 query facade,再拆一层只是同名复制。 +> **不引入 `ISkillCommandService` / `SkillCommandService`**——`ISkillService.activate` 已经是单方法 command facade,没有 create / update / archive / fork 族,再拆一层同样只是同名复制。 +> **runtime loading 当前住在 Session 构造器里**(`session/index.ts:325` 的 `loadSkills` / `:189` 的 `skillsReady`),M5.1 才升级为正式 `SkillRuntime` 角色;本阶段只记录方向,不做迁移。 +> 共享类型(`SkillDescriptor` / `SkillSummary` / `SkillDefinition` / `SkillRegistry` / `SkillActivationOrigin` 等)见 `@moonshot-ai/protocol`、`rpc/core-api.ts`、`skill/types.ts`、`agent/skill/types.ts`、`agent/context/`。 + +模式参考: + +- query 侧对齐 [`query-service.md`](../../reference/patterns/query-service.md) 的**只读 list 语义**:`ISkillService.list` 是这个 aggregate 的读模型入口;但 scope 固定为单个 session、无 `Query` 类型、无分页 / search / count,所以**不套用**完整的 `BaseQuery` + scope 便捷方法骨架。`ISkillService.list` 已把 query 角色的契约(单 scope list + 协议形状适配)一次性实现完,无需再拆。 +- command 侧对齐 [`command-service.md`](../../reference/patterns/command-service.md) 的**唯一写入入口**语义:`ISkillService.activate` 是这个 aggregate 对 daemon / SDK 的唯一命令入口;但它没有 create / update / archive / fork 等生命周期族,所以**不套用**完整的 `ICommandService` 骨架。激活本身不是“创建 / 修改 aggregate”,而是“在 agent 上触发一个 turn”——更接近一个动作命令。 +- runtime loading 侧最接近 [`runtime-service.md`](../../reference/patterns/runtime-service.md) 描述的“由进程内对象 / 事件流推导的活状态”的**填充动作**:skill registry 的内容由 session 启动期的发现事件流填充,重启后重建,不写回真相。M5.1 把它升级为正式 `SkillRuntime` 后,会补全 `onSessionDidStart` 订阅 +(如需)per-id 状态读取。 + +## 统一的 skill 激活 / 加载流 + +### 启动期加载流(runtime loading) + +```text +Session constructor + └─ this.skillsReady = this.loadSkills() // fire-and-forget,挂到 Session + ├─ resolveSkillRoots({ paths, explicitDirs, extraDirs, pluginSkillRoots, ... }) + ├─ this.skills.loadRoots(roots) // registry:discoverSkills → byName / byPluginAndName + └─ registerBuiltinSkills(this.skills) // 注入 builtin skill + └─ new SessionHost({ session, scope, skillsReady }) + └─ void this.loadMcpServers() // 同类启动期副作用(MCP) +``` + +要点: + +- `loadSkills` 是**唯一的 skill 发现 owner**:所有 skill 定义经它进入 registry;facade 不自己发现 skill。 +- registry 是**唯一的定义真相**:`byName` / `byPluginAndName` / `roots` / `skipped` 都在 `SessionSkillRegistry`;facade / runtime 都消费它,不重复持有。 +- `skillsReady` 是加载完成的**同步点**:`listSkills` / `flushMetadata` 等路径先 `await this.skillsReady`,保证 registry 已填充。 + +### 激活流(command) + +```text +skillService.activate(sid, skillName, args?) // ISkillService:command facade + ├─ _requireLoadedSession(sid) // 确认 session 存在并加载(→ SessionNotFoundError / 40401) + └─ coreApi().activateSkill({sid, agentId:'main', name, args}) + └─ KimiCore.activateSkill // in-process 派发 + └─ sessionApi.activateSkill + └─ rpc-controller.activateSkill // agent 侧 handler + └─ host.skills.activate(payload) // SkillManager.activate + ├─ registry.getSkill(name) // 查真相(→ SKILL_NOT_FOUND) + ├─ isUserActivatableSkillType(type) // 校验可激活(→ SKILL_TYPE_UNSUPPORTED) + ├─ registry.renderSkillPrompt(...) // 渲染 skill prompt + └─ recordActivation(origin, wrapped) + ├─ emitEvent({ type:'skill.activated', ... }) // 事件 + ├─ telemetry.track('skill_invoked' | 'flow_invoked') // telemetry + └─ agent.turn.prompt(input, origin) // 启动 turn +``` + +要点: + +- `ISkillService.activate` 是**唯一的激活 facade**:所有 daemon / SDK 的激活都经它;它只做 session 解析 + 派发 + 错误码翻译。 +- `SkillManager.activate` 是**唯一的激活副作用 owner**:查 registry / 渲染 / 事件 / telemetry / 启动 turn 都在它;facade 不重新实现这些。 +- facade 对 in-process 的引用是**单向 CoreAPI**:`services/skill/` 不直接 import `agent/skill/`,二者经 `coreApi().activateSkill` 连接。 + +> `coreApi().activateSkill` 是 command facade 消费 command runtime 的**派发原语**,不是 `ISkillService` 暴露的方法。facade 把它作为激活的实现细节,对外只暴露 `activate(sid, name, args)` 命令语义。 + +## 关键场景 + +### 场景 A:列出 session 的可用 skill(纯 query) + +```ts +skillService.list(sid); +``` + +内部解析:`_requireLoadedSession(sid)` 确认 session 存在并加载;`coreApi().listSkills({sid})` 返回 `SkillSummary[]`(内部 `await skillsReady` 后 `registry.listSkills().map(summarizeSkill)`);`toProtocolSkill` 把 camelCase `SkillSummary` 映射成 snake_case `SkillDescriptor`。无 registry 写入、无激活。 + +### 场景 B:激活一个 prompt 类型 skill + +```ts +skillService.activate(sid, 'review', 'src/foo.ts'); +``` + +内部解析:facade 派发到 in-process `SkillManager.activate`;registry 命中 `review`、类型可用户激活;`renderSkillPrompt` 把 `'src/foo.ts'` 填入 skill 模板参数;`recordActivation` 发出 `skill.activated` 事件 + `skill_invoked` telemetry,并把包装后的 prompt 经 `agent.turn.prompt` 启动一个 turn(trigger `user-slash`)。 + +### 场景 C:激活不存在的 skill + +```ts +skillService.activate(sid, 'nope'); +``` + +内部解析:in-process `registry.getSkill('nope')` 返回 `undefined` → `KimiError(SKILL_NOT_FOUND)`;facade 在 `activate` 的 `catch` 里把它翻译成 `SkillNotFoundError`(→ 40415)。registry 与 agent 状态不变。 + +### 场景 D:激活不可用户激活的 skill(如 `reference` 类型) + +```ts +skillService.activate(sid, 'some-reference-skill'); +``` + +内部解析:in-process `isUserActivatableSkillType(type)` 返回 `false` → `KimiError(SKILL_TYPE_UNSUPPORTED)`;facade 翻译成 `SkillNotActivatableError`(→ 40912)。无 turn 启动。 + +### 场景 E:session 启动期加载 skill(runtime loading) + +```text +new Session(...) + → this.skillsReady = this.loadSkills() + → resolveSkillRoots({ workDir, userHomeDir, brandHomeDir, ... }) + → registry.loadRoots(roots) // discoverSkills 扫描 project / user / extra / builtin + → registerBuiltinSkills(registry) // 注入 builtin + → skillsReady.then(() => host.refreshAgentBuiltinTools()) +``` + +内部解析:skill 定义经 `discoverSkills` 进入 registry 的 `byName` / `byPluginAndName`;`skillsReady` 是加载完成的同步点;daemon 的首次 `list` / `activate` 会隐式 `await skillsReady`。M5.1 这一段将由 `SkillRuntime.onSessionDidStart` 触发,而不是直接挂在构造器上。 + +### 场景 F:daemon 重启后,session 在磁盘但不在活跃 map + +```text +skillService.list(sid) / activate(sid, name) + → _requireLoadedSession(sid) + → coreApi().listSessions({}) // 确认 session 存在 + → coreApi().resumeSession({sid}) // 幂等加载进活跃 map + → 后续 listSkills / activateSkill 不会 miss +``` + +内部解析:facade 在每次 `list` / `activate` 前先保证 session 已加载,避免 daemon 重启后 SessionAPI 派发到不存在的活跃 session。这与 `PromptService.submit` / `SessionService.undo` 是同一模式。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 / 入口 | 角色 | +|---|---|---| +| 列出 session 可用 skill | `skillService.list(sid)` | query(skill facade) | +| 激活 skill(等价 `/ `) | `skillService.activate(sid, name, args?)` | command(skill facade) | +| SkillSummary → 协议 SkillDescriptor 形状翻译 | `toProtocolSkill(info)` | query(skill,纯函数) | +| in-process 激活副作用 | `SkillManager.activate(payload)` | command(runtime impl) | +| 记录激活(事件 + telemetry + 启动 turn) | `SkillManager.recordActivation(origin, input?)` | command(runtime impl) | +| 渲染 skill prompt(参数展开) | `SessionSkillRegistry.renderSkillPrompt(skill, args)` | registry / truth | +| 包装用户 slash skill prompt | `renderUserSlashSkillPrompt(...)`(`agent/skill/prompt.ts`) | command(runtime impl,纯函数) | +| 按名查找 skill | `registry.getSkill(name)` / `getPluginSkill(pluginId, name)` | registry / truth | +| 列出可见 skill(内部) | `registry.listSkills()` / `listInvocableSkills()` | registry / truth | +| 发现 skill(扫描 roots) | `registry.loadRoots(roots)` → `discoverSkills` | registry / truth | +| 注册内置 skill | `registry.registerBuiltinSkill(skill)` / `registerBuiltinSkills(registry)` | registry / truth | +| session 启动期加载 skill | `Session.loadSkills()`(M5.1 → `SkillRuntime.onSessionDidStart`) | runtime loading | +| 校验 skill 可用户激活 | `isUserActivatableSkillType(type)`(`skill/types.ts`) | registry / truth(纯函数) | +| facade 派发激活(CoreAPI) | `skillService.activate` 内 `coreApi().activateSkill(...)` | command facade 消费 runtime(单向) | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service (daemon / SDK facade) + ISkillService (query + command — list 描述符查询 / activate 激活命令,SkillSummary → SkillDescriptor) + +Runtime (in-process) + SkillManager / IAgentSkillService (command impl — 查 registry / 渲染 prompt / 启动 turn / 事件 / telemetry) + SessionSkillRegistry / ISkillRegistryService (registry / truth — 定义集合 + 发现 + prompt 渲染) + loadSkills (M5.1 → SkillRuntime) (runtime loading — session 启动期发现与注册,订阅 onSessionDidStart) + +Domain / Policy + SkillDefinition (skill 定义:name / description / metadata / content / source / plugin) + SkillRegistry (registry 抽象接口:getSkill / listInvocableSkills / renderSkillPrompt / getModelSkillListing) + SkillActivationOrigin (skill_activation 事件来源:trigger user-slash / 类型 / 参数) + +Infrastructure + Skill discovery (scanner.discoverSkills / parser.expandSkillParameters:磁盘扫描 + 模板参数展开) + SDK adapters (toProtocolSkill:内部 SkillSummary → 协议 SkillDescriptor) + CoreAPI handle (skill facade 经 ICoreRuntime 取 in-process listSkills / activateSkill) + Lifecycle hooks (onSessionDidStart:M5.1 SkillRuntime 的订阅入口) +``` + +依赖关系: + +```text +ISkillService.activate → CoreAPI.activateSkill (command facade → in-process runtime,单向派发) +ISkillService.list → CoreAPI.listSkills (query facade → in-process registry 只读) +ISkillService → toProtocolSkill (协议形状适配) +SkillManager.activate → SkillRegistry.getSkill / renderSkillPrompt (command impl 读 registry 真相) +SkillManager.recordActivation → Agent.emitEvent / telemetry / turn.prompt (事件 + telemetry + 启动 turn) +SessionSkillRegistry → discoverSkills / parser (发现 + 参数展开) +SessionSkillRegistry → SkillRegistry (type only) (实现 agent/skill/types 的接口,仅类型导入) +loadSkills → resolveSkillRoots / loadRoots / registerBuiltinSkills (启动期发现) +loadSkills (M5.1) → ILifecycleService.onSessionDidStart (runtime 订阅启动钩子) +``` + +禁止的边界: + +```text +ISkillService → SkillManager.activate / registry.loadRoots / discoverSkills (facade 不直接触达 runtime impl / 发现逻辑;只能经 CoreAPI) +SessionSkillRegistry → toProtocolSkill / SkillDescriptor / SkillSummary (registry 不表达 SDK 形状) +SkillManager → ISkillService / services/skill (runtime impl 不回调 facade) +SessionSkillRegistry → services/** (registry 不依赖 daemon facade) +loadSkills → ISkillService (runtime loading 不依赖 facade;只填 registry) +``` + +关键不变量: + +- registry 侧不持有 SDK 形状(无 `SkillDescriptor` / snake_case 适配);facade 侧不持有发现逻辑(无 `discoverSkills` / `loadRoots`)。 +- skill 定义的真相在 registry(`SessionSkillRegistry.byName`),facade 只在 `list` 时经 CoreAPI 只读 `listSkills`,不自己扫描磁盘。 +- facade 对 runtime impl 的引用仅限:经 CoreAPI 的 `activateSkill` / `listSkills`(in-process 派发,去序列化);`services/skill/` 不直接 import `agent/skill/` 或 `skill/registry.ts` 的实现。 +- command 副作用(事件 / telemetry / 启动 turn)集中在 `SkillManager.recordActivation`,REST 路由与 facade 不重新解释激活语义。 +- runtime loading 当前是 Session 构造器上的 fire-and-forget;M5.1 改为 `SkillRuntime` 订阅 `onSessionDidStart` 后,仍是单向填 registry,不引入新的跨层依赖。 + +## 决策记录 + +- **DR1:skill 是一个 domain,承载四类关注点。** registry / truth(定义集合 + 发现 + prompt 渲染)、query(`list` 描述符查询)、command(`activate` 激活)、runtime loading(启动期加载)同属一个 skill domain;四者关注点不同、真相不同、副作用不同,但共享同一份 registry 真相,不拆成多个 domain。 +- **DR2:激活 = command。** `ISkillService.activate(sessionId, skillName, args?)` 是 skill aggregate 对 daemon / SDK 的唯一写入入口;它的副作用(启动 turn、`skill.activated` 事件、`skill_invoked` telemetry)落在 in-process `SkillManager.activate` / `recordActivation`。facade 只做 session 解析 + CoreAPI 派发 + 错误码翻译,不重新实现激活逻辑。 +- **DR3:描述符列表 = query(与 command 共用 facade)。** `ISkillService.list(sessionId)` 是只读描述符查询 + `toProtocolSkill` 形状适配;scope 固定为单 session、无分页 / search / count。它不持有运行时状态、不触发激活、不写 registry。 +- **DR4:加载 = runtime(M5.1 接入生命周期)。** `loadSkills()` 在 session 启动时重新发现 skill 并填入 registry,是一次性、可重建的运行时动作;当前挂在 Session 构造器的 `skillsReady` 上。M5.1 将把它抽入 `SkillRuntime`,由 `SkillRuntime` 订阅 `onSessionDidStart` 触发,与其它 session 启动期副作用共享同一生命周期编排。本阶段只记录方向,不做迁移。 +- **DR5:registry = 真相 / 发现层,不是 service facade。** `SessionSkillRegistry`(`ISkillRegistryService`)拥有 skill 定义集合 + `discoverSkills` 发现 + `renderSkillPrompt` 渲染;它是被 facade / runtime 消费的下层 contract,不暴露在 SDK 边界,不表达 `SkillDescriptor` 形状。 +- **DR6:不引入 `SkillQueryService`。** `ISkillService.list` 已经是单方法、单 scope、无分页的 query facade;再拆一个 `ISkillQueryService` 不会引入新契约,只是把同一个方法换个接口名,并被迫复制 `_requireLoadedSession` / `coreApi()` 派发管道。当前 skill aggregate 的 query 角色已经由 `ISkillService.list` 一次性实现完。 +- **DR7:不引入 `SkillCommandService`。** `ISkillService.activate` 已经是单方法 command facade,没有 create / update / archive / fork 等生命周期族;再拆一个 `ISkillCommandService` 同样是同名复制 + 管道复制。当前 skill aggregate 的 command 角色已经由 `ISkillService.activate`(facade)+ `SkillManager.activate`(runtime impl)一次性实现完。 +- **DR8:`list` 与 `activate` 共用 `ISkillService` 不构成 muddle。** 二者是同一个薄 SDK 适配器上的两个独立方法,实现互不调用(`SkillService.list` 不调 `activate`,反之亦然)——AGENTS.md 的“command / query 角色不互相调用业务方法”针对的是实现耦合,不是接口同址。共用 facade 避免了为两个单方法角色各复制一份 session 解析 + CoreAPI 派发管道。真正的角色分离(query/command facade vs runtime impl vs registry truth)已经按文件 / 层物理分离,没有重叠或渗漏。 +- **DR9:当前代码布局已满足边界,无需迁移。** facade 在 `services/skill/`(`ISkillService` / `SkillService` / `toProtocolSkill`,query + command);command runtime impl 在 `agent/skill/`(`SkillManager` / `IAgentSkillService` / `AgentSkillService`);registry truth 在 `skill/registry.ts`(`SessionSkillRegistry` / `ISkillRegistryService`);runtime loading 在 `session/index.ts`(`loadSkills` / `skillsReady`,M5.1 移入 `SkillRuntime`)。依赖方向单向:`services/skill` → CoreAPI → `agent/skill` / `skill/registry`;`agent/skill` → `skill/registry`(接口)+ `agent/context`(origin);`skill/registry` → `skill/`(scanner/parser/types)+ `agent/skill/types`(仅类型)。三层都没有反向 import `services/skill`,M0.1 fence 干净。本次只出概念定稿,不做代码拆分。 diff --git a/.agents/skills/service-skill/explanation/domains/task.md b/.agents/skills/service-skill/explanation/domains/task.md new file mode 100644 index 000000000..8e47d2c66 --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/task.md @@ -0,0 +1,632 @@ +# Task Service 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +> 范围说明:ROADMAP M4.6 把 `task` / `background` / `cron` / `goal` 放在同一个 +> step 里确认边界。它们名字都带 “task”,但**不是同一个 domain**——本文先 +> 把它们拆清楚,再分别确认 query / command / runtime / 持久化各自落在哪 +> 一层。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的 task 生命周期流](#统一的-task-生命周期流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,标题里的 “task” 实际上是**三个独立的 aggregate**,共享同一个 +“后台/异步工作单元”的直觉,但真相、生命周期、副作用、对外入口都不同: + +- **background task aggregate(后台任务)**:由 agent 进程内启动、可查询 / 取消 + 的异步任务(bash / subagent / question)。 + - **query + command(facade)**:`services/task/` 的 `ITaskService`—— + `list` / `get`(只读查询 + 协议形状适配)+ `cancel`(取消命令)。这是 + background task aggregate 对 daemon / SDK 的**唯一 facade**。 + - **runtime(运行时)**:`agent/background/` 的 `BackgroundManager`——注册 / + 启动 / 停止 / 输出捕获 / 持久化 / 重启 reconcile。它持有活的 `ManagedTask` + 状态,是 background task aggregate 的**运行时真相**。 + - **persistence(持久化)**:`agent/background/persist.ts` 的 + `BackgroundTaskPersistence`——`/tasks/.json` + + `output.log`,被 runtime 直接消费,不是顶层 `*Service`。 +- **cron aggregate(定时任务)**:按 cron 表达式周期 / 一次性触发的调度任务。 + - **runtime**:`agent/cron/` 的 `CronManager`——`SessionCronStore` + 调度器 + tick 循环 + fire 处理(`steer` + telemetry)+ 持久化 + start/stop。 + - **store / scheduler / persist**:`tools/cron/`(`session-store.ts` / + `scheduler.ts` / `persist.ts` / `clock.ts`)——被 runtime 包装的下层 + contract,不是 `services/` facade。 + - **query / command**:cron **没有 `services/` facade**。它的“写”(create / + delete)由模型工具 `tools/cron/cron-create.ts` / `cron-delete.ts` 触发; + 它的“读”(list / next-fire)由 `CronList` 工具 + `CronManager.getNextFireTime` + 提供。入口在 **tool / slash 层**,不在 daemon/SDK `services/` 层。 +- **goal aggregate(目标模式)**:每个 agent 至多一个的“持续目标”,由记录日志 + 重建,驱动 continuation turn。 + - **runtime**:`agent/goal/` 的 `GoalMode`——durable 状态机(active / paused / + blocked / complete)+ 生命周期(create / pause / resume / cancel / + markBlocked / markComplete)+ 预算记账。 + - **truth(真相)**:agent 记录日志(`goal.create` / `goal.update` / + `goal.clear` / `forked`),经 `restoreCreate` / `restoreUpdate` / + `restoreClear` / `restoreForked` 重建;持久化经 `IRecordsService.logRecord`。 + 真相在 records / replay 层,不在 `GoalMode` 内存里。 + - **query / command**:goal **没有 `services/` facade**。命令经 CoreAPI + (`rpc-controller.ts` 的 `createGoal` / `getGoal` / `pauseGoal` / + `resumeGoal` / `cancelGoal`)+ 模型工具 `UpdateGoal` + `/goal` slash 触发; + 查询经 CoreAPI `getGoal` + 工具提供。 + +**三者不是同一个 domain,也不需要进一步拆分。** 边界当前就是干净的: + +- `services/task`(facade)只做**只读查询 + 取消命令 + 协议形状适配**,不持有 + 运行时状态、不注册 / 停止任务、不直接读 `BackgroundManager` 的 `tasks` map。 + 它经 `coreApi()`(in-process CoreAPI)派发到 runtime,对 runtime 的直接 + import 只有 `import type { BackgroundTaskInfo }`(type-only)。 +- `agent/background`(runtime)只做**后台任务运行时**,不表达 SDK 的 + `BackgroundTask` 形状、不做 snake_case / ISO 适配、不解析 session 存在性。 +- `agent/cron` + `tools/cron`(runtime + store / scheduler)只做**定时调度**, + 不暴露 daemon/SDK `services/` 形状;入口在 tool / slash。 +- `agent/goal`(runtime)+ records / replay(truth)只做**目标模式**,不暴露 + `services/` facade;入口在 CoreAPI / 工具 / slash。 + +**关系一句话:background task 是一个 aggregate,`services/task` 是它的 +daemon/SDK facade、`agent/background` 是它的 runtime、`agent/background/persist` +是它的持久化;cron 与 goal 是另外两个 aggregate,二者都是 runtime-only(无 +`services/` facade),分别由 tool / slash 与 CoreAPI / 工具 / slash 驱动。三者 +共享 “异步工作单元” 的直觉但不共享真相、不共享生命周期、不应合并。** + +接口 / 实现落点见 `services/task/task.ts` 的 `ITaskService`(daemon/SDK query + +command facade)、`services/task/taskService.ts` 的 `TaskService`(facade 实现)、 +`agent/background/index.ts` 的 `BackgroundManager` / `IBackgroundService` +(background runtime)、`agent/background/persist.ts` 的 `BackgroundTaskPersistence` +(持久化 contract)、`agent/cron/manager.ts` 的 `CronManager` / `ICronService` +(cron runtime)、`tools/cron/`(cron store / scheduler / persist)、 +`agent/goal/index.ts` 的 `GoalMode` / `IGoalService`(goal runtime)、 +`agent/factory.ts`(三者的 per-agent DI 注册)、`agent/rpc-controller.ts` +(goal 的 CoreAPI 暴露)。本文只承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. “task” 这个词指代三个不同的 aggregate,不是单一 domain + +“task” 在代码里同时指三件生命周期 / 真相 / 副作用完全不同的事: + +- **background task(后台任务)**:agent 进程内启动的异步子任务(bash 命令、 + subagent、question 工具派生的 flow)。真相是 runtime 内存里的 `ManagedTask` + map + 磁盘 `/tasks/.json` + `output.log`。生命周期由 + `BackgroundManager` 拥有(register → run → settle / stop → persist → reconcile)。 +- **cron job(定时任务)**:按 cron 表达式触发的调度条目。真相是 + `SessionCronStore`(内存)+ `/cron/.json`(磁盘镜像)。 + 生命周期由 `CronManager` 拥有(add → scheduler tick → fire → steer → 持久化 + cursor / remove)。 +- **goal(目标)**:每个 agent 至多一个的持续目标,驱动 continuation turn。 + 真相是 agent 记录日志(event-sourced),`GoalMode` 内存状态是从 records 重建 + 的投影。生命周期由 `GoalMode` 拥有(create → pause / resume → complete / + cancel / blocked)。 + +因此它们不是 “一个 task domain 的三个角色”,而是**三个 aggregate**。把它们 +合并成一个 domain 会混淆三种完全不同的真相与生命周期;把它们各自拆成 +command / query / runtime 子 service 也没有新契约可拆(见 DR 系列)。 + +### 2. `services/task` 是 background-task aggregate 的 SDK facade,不是独立 aggregate + +`ITaskService`(`services/task/task.ts:142`)的三个方法——`list` / `get` / +`cancel`——全部围绕 background task: + +- 数据来自 `coreApi().getBackground({sessionId, agentId})`(即 in-process + `BackgroundManager.list` 的 RPC 投影),不是独立的 task store。 +- `cancel` 经 `coreApi().stopBackground(...)` 派发到 in-process + `BackgroundManager.stop`。 +- `toProtocolTask`(`task.ts:103`)把 runtime 的 `BackgroundTaskInfo` + (camelCase + ms 时间戳 + agent-core literal 集)适配成协议 + `BackgroundTask`(snake_case + ISO + spec literal 集)。 + +所以 `services/task` 不是 “第四个 aggregate”,它是 **background-task +aggregate 在 daemon / SDK 边界的 query + command facade**——和 +`services/skill` 是 skill aggregate 的 facade、`agent/skill` 是其 runtime 是 +同一模式(见 `skill.md`)。区别在于:skill facade 背后有 `SessionSkillRegistry` +这个独立 truth;background task 的 truth 直接住在 runtime 的 `ManagedTask` +map + 磁盘 persist 里,没有独立 registry 层。 + +### 3. 命令 / 查询 / 运行时 / 持久化 各就其位(按 aggregate 列角色) + +按 service-skill 的角色表,三个 aggregate 实际用到的角色如下: + +| Aggregate | Query | Command | Runtime | Persistence / Truth | +|---|---|---|---|---| +| background task | `ITaskService.list` / `get`(facade) | `ITaskService.cancel`(facade) | `BackgroundManager`(`agent/background`) | `BackgroundTaskPersistence`(runtime 持有,磁盘 mirror) | +| cron | `CronList` 工具 / `getNextFireTime`(tool 层) | `CronCreate` / `CronDelete` 工具(tool 层) | `CronManager`(`agent/cron`)+ `CronScheduler`(`tools/cron`) | `SessionCronStore` + `createCronPersistStore`(`tools/cron`) | +| goal | `getGoal` CoreAPI / 工具 | CoreAPI(create/pause/resume/cancel)+ `UpdateGoal` 工具 + `/goal` slash | `GoalMode`(`agent/goal`) | agent 记录日志(`IRecordsService` / `IReplayService`) | + +按 [Domain decomposition](../../../../../packages/agent-core/src/services/AGENTS.md) +的规范:“不是每个 domain 都需要五件套,仅当某角色有明确 owner 且契约非空时才 +引入”。 + +- **background task 的 query / command 已经是单 facade。** `ITaskService` + 只有 `list` / `get`(读)+ `cancel`(写)三个方法,scope 固定为单 session, + 无分页 / search / count(`TaskListQuery` 只有一个可选 `status`)。它**就是** + background task aggregate 在 SDK 边界的 query + command 角色。再拆 + `ITaskQueryService` / `ITaskCommandService` 不会引入新契约,只是把同一个 + 薄适配器上的方法换个接口名,并被迫复制 `_requireSession` / `_getAllRaw` / + `coreApi()` 派发管道。 +- **background task 的 runtime 是 `BackgroundManager`,不是 facade。** + `BackgroundManager` 持有活的 `ManagedTask` map,拥有注册 / 启动 / 停止 / + 输出捕获 / 持久化 / reconcile;它从不暴露在 SDK 边界,facade 只经 CoreAPI + 读它的投影。这是 [`runtime-service.md`](../../reference/patterns/runtime-service.md) + 描述的“由进程内对象推导的活状态”的 owner。 +- **background task 的 persistence 是 runtime 持有的 contract,不是顶层 + service。** `BackgroundTaskPersistence` 由 `BackgroundManager` 在构造时注入 + (`agent/factory.ts:51` 的 `backgroundPersistence`),由 runtime 直接调 + `writeTask` / `appendTaskOutput` / `listTasks`。按 AGENTS.md “被 runtime + aggregate 直接消费的 repository 住在 runtime 层”,它住在 `agent/background/` + 而不是 `services/`,也不是 `*Service` DI 单例。 +- **cron / goal 没有 `services/` facade,是 runtime-only aggregate。** 它们 + 的 query / command 入口在 tool / slash / CoreAPI 层,不在 daemon/SDK + `services/` 层。这不是缺失,而是它们本来就没有 REST/SDK 形状需要适配—— + cron 的 create / delete / list 是模型工具,`/cron` 是 slash;goal 的 + create / pause / resume / cancel 是 CoreAPI + `UpdateGoal` 工具 + `/goal` + slash。 + +### 4. `services/task` 的 query + command 共用 `ITaskService` 不构成 muddle + +`list` / `get`(query)与 `cancel`(command)共用一个 `ITaskService` 接口, +但这是同一个薄 SDK 适配器上的三个独立方法: + +- 实现互不调用:`TaskService.list`(`taskService.ts:41`)/ `get`(`:51`)/ + `cancel`(`:84`)各自经 `_getAllRaw`(`:115`)取 runtime 投影,互不调用 + 对方的业务方法。 +- 共享的只是“session 存在性校验 + CoreAPI 派发”这条管道:`_requireSession` + (`:108`)/ `coreApi()`(`:137`)。这条管道是 SDK 适配器的基础设施,不是 + query 或 command 的业务逻辑。 +- AGENTS.md 的 “command / query 角色不互相调用业务方法” 针对的是**实现耦合**, + 不是**接口同址**。`ITaskService` 的三个方法满足这条规则。 + +真正的角色分离(query+command facade vs runtime impl vs persistence +contract)已经按文件 / 层物理分离,没有重叠或渗漏(见 DR7)。 + +### 5. cron / goal 是 tool / slash / CoreAPI 驱动的 runtime-only aggregate + +cron 与 goal 都没有 `services//` facade,这不是遗漏: + +- **cron**:`CronManager`(`agent/cron/manager.ts:97`)包装 `tools/cron/` 的 + `SessionCronStore` + `CronScheduler`。create / delete 由模型工具 + `tools/cron/cron-create.ts` / `cron-delete.ts` 调 `manager.addTask` / + `removeTasks`;list / next-fire 由 `CronList` 工具读 store + + `manager.getNextFireTime`;`/cron` slash 也走工具层。daemon/SDK 没有 cron + 的 REST 端点,`rpc-controller.ts` 也不暴露 cron。 +- **goal**:`GoalMode`(`agent/goal/index.ts:223`)是 durable 状态机。命令经 + CoreAPI(`agent/rpc-controller.ts:170-174` 的 `createGoal` / `getGoal` / + `pauseGoal` / `resumeGoal` / `cancelGoal`)+ 模型工具 `UpdateGoal`(触发 + `markComplete` / `markBlocked`)+ `/goal` slash;查询经 CoreAPI `getGoal` + + 工具。同样没有 `services/goal/` facade。 + +把 cron / goal 包一层 `services/` facade 不会带来新契约——它们没有需要适配 +成协议形状的 SDK 读模型,也没有 daemon 直接消费的命令入口。它们的对外入口 +已经落在 tool / slash / CoreAPI,且这些入口直接消费 per-agent runtime +(`ICronService` / `IGoalService`),不需要再经一层 `services/` 适配。 + +### 6. facade 不直接 import runtime impl;经 CoreAPI 单向连接(type-only 例外) + +`services/task` 对 `agent/background` 的唯一引用是 type-only: + +```ts +// services/task/task.ts:42 +import type { BackgroundTaskInfo } from '../../agent/background'; +``` + +运行时数据全部经 CoreAPI 流动: + +- `list` / `get`:`coreApi().getBackground({sessionId, agentId, activeOnly, limit})` + → in-process `BackgroundManager.list`(`taskService.ts:115-127`)。 +- `cancel`:`coreApi().stopBackground({sessionId, agentId, taskId, reason})` + → in-process `BackgroundManager.stop`(`taskService.ts:98-103`)。 +- `get(withOutput)`:`coreApi().getBackgroundOutput({sessionId, agentId, taskId, tail})` + → in-process `BackgroundManager.readOutput`(`taskService.ts:67-72`)。 + +所以 facade 不持有 runtime 的活状态、不调 runtime 的方法、不 import runtime +的实现类;它只借用 `BackgroundTaskInfo` 这个**类型**来做协议形状适配。这条 +边界是“是否需要拆分 / 合并”的硬指标:只要 facade 不混入 runtime 状态、 +runtime 不混入 SDK 形状,两类关注点就是清晰的。 + +## Service 拆分概览 + +| Service / 角色 | 一句话职责 | 角色 | Aggregate | +|---|---|---|---| +| `ITaskService` | daemon/SDK background task facade:`list` / `get`(query)+ `cancel`(command) | query + command(facade) | background task | +| `TaskService` | `ITaskService` 实现:session 解析 + `toProtocolTask` 适配 + `coreApi()` 派发 + 错误码翻译 | query + command(impl) | background task | +| `IBackgroundService` / `BackgroundService` | `BackgroundManager` 的 per-agent DI 桥(`unwrap()` 取裸 manager) | runtime(DI 桥) | background task | +| `BackgroundManager` | background task 运行时:register / start / stop / settle / output / persist / reconcile | runtime(impl) | background task | +| `BackgroundTaskPersistence` | background task 持久化:`/tasks/.json` + `output.log` | persistence(runtime 持有) | background task | +| `BackgroundTask`(task.ts) | 具体任务抽象(`AgentBackgroundTask` / `ProcessBackgroundTask` / `QuestionBackgroundTask`) | runtime(任务实现) | background task | +| `ICronService` / `CronService` | `CronManager` 的 per-agent DI 桥 | runtime(DI 桥) | cron | +| `CronManager` | cron 运行时:store + scheduler + fire(steer + telemetry)+ persist + start/stop | runtime(impl) | cron | +| `SessionCronStore` | cron 内存真相:add / remove / list / adopt / markFired | store / truth(runtime 持有) | cron | +| `CronScheduler`(`tools/cron/scheduler.ts`) | cron tick 循环 + jitter + fire 回调 | scheduler(runtime 持有) | cron | +| `createCronPersistStore`(`tools/cron/persist.ts`) | cron 磁盘镜像:`/cron/.json` | persistence(runtime 持有) | cron | +| `CronCreate` / `CronDelete` / `CronList`(`tools/cron/cron-*.ts`) | cron 的模型工具入口(create / delete / list) | query + command(tool 层) | cron | +| `IGoalService` / `GoalService` | `GoalMode` 的 per-agent DI 桥 | runtime(DI 桥) | goal | +| `GoalMode` | goal 运行时:durable 状态机 + 生命周期 + 预算记账 | runtime(impl) | goal | +| `IRecordsService` / `IReplayService` | goal 的真相层:记录日志 + 重建投影 | truth(records / replay) | goal | +| `UpdateGoal` 工具 + `/goal` slash + `rpc-controller` goal CoreAPI | goal 的命令入口(tool / slash / CoreAPI 层) | command(tool / slash / CoreAPI 层) | goal | + +> 只有这些角色。**不引入 `ITaskQueryService` / `TaskQueryService` 或 +> `ITaskCommandService` / `TaskCommandService`**——`ITaskService` 的 +> `list` / `get` / `cancel` 已经是单 facade 上的三个独立方法,再拆一层只是 +> 同名复制 + 管道复制。 +> **不为 cron / goal 引入 `services/cron` / `services/goal` facade**——它们 +> 没有需要适配成协议形状的 SDK 读模型,对外入口已经在 tool / slash / CoreAPI +> 层,且直接消费 per-agent runtime,再加一层 `services/` 适配不带来新契约。 +> **不引入独立的 background task registry**——background task 的真相直接住在 +> `BackgroundManager` 的 `ManagedTask` map + `BackgroundTaskPersistence` 磁盘 +> mirror 里,没有 skill 那种“跨层共享的 registry truth”;facade 经 CoreAPI +> 读 runtime 投影即可。 +> 共享类型(`BackgroundTask` / `BackgroundTaskInfo` / `BackgroundTaskStatus` / +> `CronTask` / `GoalSnapshot` / `GoalStatus` 等)见 `@moonshot-ai/protocol`、 +> `agent/background/task.ts`、`tools/cron/types.ts`、`agent/goal/index.ts`。 + +模式参考: + +- query 侧对齐 [`query-service.md`](../../reference/patterns/query-service.md) + 的**只读 list / get 语义**:`ITaskService.list` / `get` 是 background task + aggregate 的读模型入口;但 scope 固定为单 session、`TaskListQuery` 只有一个 + 可选 `status`、无分页 / search / count,所以**不套用**完整的 `BaseQuery` + + scope 便捷方法骨架。`ITaskService` 已把 query 角色的契约(单 scope list / + get + 协议形状适配)一次性实现完,无需再拆。 +- command 侧对齐 [`command-service.md`](../../reference/patterns/command-service.md) + 的**唯一写入入口**语义:`ITaskService.cancel` 是 background task aggregate + 对 daemon / SDK 的唯一命令入口;但它没有 create / update / archive / fork + 等生命周期族(任务 create 发生在 runtime 的 `registerTask`,由工具层触发, + 不经过 SDK facade),所以**不套用**完整的 `ICommandService` 骨架。`cancel` + 是一个动作命令,不是 “创建 / 修改 aggregate”。 +- runtime 侧对齐 [`runtime-service.md`](../../reference/patterns/runtime-service.md) + 描述的“由进程内对象 / 事件流推导的活状态”的 owner:`BackgroundManager` / + `CronManager` / `GoalMode` 各自持有自己 aggregate 的活状态,并由事件 / + telemetry / 记录日志向外投影;它们都不是 daemon/SDK facade。 + +## 统一的 task 生命周期流 + +### background task 生命周期(runtime) + +```text +工具层(Bash run_in_background / Agent subagent / Question) + └─ BackgroundManager.registerTask(task) // 生成 taskId,建 ManagedTask + ├─ assertCanRegister() // maxRunningTasks 闸门 + ├─ task.start({ signal, appendOutput, settle }) // 启动具体任务(process/agent/question) + ├─ persistLive(entry) // 初始快照 → /tasks/.json + └─ emitTaskStarted(info) // background.task.started + telemetry + …(运行中:appendOutput 经 ring buffer + outputWriteQueue → output.log)… + ├─ 自然结束:settleTask(entry, { status }) // completed / failed / timed_out + └─ 主动取消:BackgroundManager.stop(taskId) // SIGTERM → 5s grace → SIGKILL → settleTask('killed') + └─ fireTerminalEffects(entry) // notifyBackgroundTask + background.task.terminated + telemetry + +# 重启 reconcile +BackgroundManager.loadFromDisk() // 磁盘记录 → ghosts map + └─ reconcile() // running ghosts → lost;emit terminated;恢复通知 +``` + +要点: + +- `BackgroundManager` 是**唯一的 background task 运行时 owner**:所有任务经 + `registerTask` 进入;facade 不自己注册 / 停止任务,只经 CoreAPI 派发。 +- `BackgroundTaskPersistence` 是**唯一的持久化 owner**:`.json` 状态 + + `output.log` 完整输出;ring buffer 只是 UI / 通知的轻量 tail,不是权威输出。 +- facade(`ITaskService`)只在 `list` / `get` / `cancel` 时经 CoreAPI 读 + runtime 投影,不直接触达 `tasks` map / persist。 + +### cron 生命周期(runtime) + +```text +CronCreate 工具 + └─ CronManager.addTask(init) // store.add + persistEnqueue(write) + └─ emitScheduled(task) // cron_scheduled telemetry + +CronManager.start() // scheduler.start() + bindSigusr1(手动 tick) + └─ scheduler.tick() 周期触发 + └─ onFire(task, ctx) → handleFire(task, ctx) + ├─ isStale(task) // 7 天 auto-expire 判定 + ├─ agent.turn.steer(content, CronJobOrigin) // 注入 cron_job 提醒 + ├─ emitEvent('cron.fired') + telemetry // cron_fired + └─ stale && recurring → removeTasks([id]) + emitDeleted(id) + +# resume +CronManager.loadFromDisk() // /cron/*.json → store.adopt +``` + +要点: + +- `CronManager` 是**唯一的 cron 运行时 owner**:store / scheduler / persist 都 + 由它编排;工具层只调 `addTask` / `removeTasks` / `getNextFireTime`。 +- `SessionCronStore` 是内存真相,`createCronPersistStore` 是磁盘 mirror;两者 + 都不是顶层 service,由 runtime 持有。 +- cron 没有 `services/` facade;对外入口在 tool / slash 层。 + +### goal 生命周期(runtime + records truth) + +```text +/goal create 或模型 UpdateGoal('active') + └─ GoalMode.createGoal(input) // 校验 + persistState + records.logRecord('goal.create') + └─ track('goal_created') + +continuation turn 循环(status === 'active') + ├─ incrementTurn() / recordTokenUsage() // 预算记账 + persistState + └─ 预算到顶 → markBlocked({ reason }) // blocked(resumable) + +用户 / 系统干预 + ├─ pauseGoal / pauseOnInterrupt / pauseActiveGoal // paused(resumable) + ├─ resumeGoal // paused/blocked → active + ├─ cancelGoal // clearInternal(丢弃记录) + └─ markComplete // complete(transient)→ emit completion → clearInternal + +# resume / replay +restoreCreate / restoreUpdate / restoreClear / restoreForked // 从 records 重建投影 +normalizeAfterReplay() // active → paused(重启后不能还在跑) +``` + +要点: + +- `GoalMode` 是**唯一的 goal 运行时 owner**:状态机 + 生命周期 + 预算都在它; + tool / slash / CoreAPI 只调它的方法。 +- 真相在 **records 日志**(event-sourced):`GoalMode` 内存状态是投影,重启经 + `restore*` 重建;`normalizeAfterReplay` 把 `active` 降级为 `paused`。 +- goal 没有 `services/` facade;对外入口在 CoreAPI / 工具 / slash 层。 + +## 关键场景 + +### 场景 A:列出 session 的后台任务(纯 query) + +```ts +taskService.list(sid, { status: 'running' }); +``` + +内部解析:`TaskService.list`(`taskService.ts:41`)→ `_requireSession(sid)` +确认 session 存在 → `_getAllRaw(sid)` 经 `coreApi().getBackground({sid, +agentId:'main'})` 取 runtime 投影 → `toProtocolTask` 适配成协议 +`BackgroundTask` → 按 `query.status` 过滤。无 runtime 写入、无任务注册。 + +### 场景 B:取消一个运行中的后台任务(command) + +```ts +taskService.cancel(sid, 'process-abcd1234'); +``` + +内部解析:`TaskService.cancel`(`taskService.ts:84`)→ `_requireSession` → +预取 runtime 投影区分 40406(不存在)/ 40904(已 terminal)→ +`coreApi().stopBackground({sid, agentId:'main', taskId})` 派发到 in-process +`BackgroundManager.stop` → runtime 走 SIGTERM → grace → SIGKILL → settleTask。 +facade 不自己实现停止逻辑。 + +### 场景 C:cron 任务到时触发(runtime fire) + +```text +CronManager.start() → scheduler.tick() + → onFire(task) → handleFire(task) + → agent.turn.steer(cronFireXml, CronJobOrigin) + → emit 'cron.fired' + cron_fired telemetry +``` + +内部解析:scheduler 读到 `SessionCronStore` 里到期的 task,调 `handleFire` +(`manager.ts:401`);`handleFire` 经 `agent.turn.steer` 把 cron 提醒注入 +turn,发 `cron.fired` 事件 + `cron_fired` telemetry;若 stale 且 recurring, +再 `removeTasks([id])` + `emitDeleted`。整个过程不经过 `services/` 层。 + +### 场景 D:goal 从 create 到 complete(runtime + records truth) + +```ts +goalMode.createGoal({ objective: '...' }); // records.logRecord('goal.create') + track('goal_created') +// …continuation turns:incrementTurn / recordTokenUsage… +goalMode.markComplete({ reason: 'done' }); // status 'complete' → emit completion → clearInternal +``` + +内部解析:create 写一条 `goal.create` record 并把内存状态置 `active`; +continuation turn 期间预算记账经 `incrementTurn` / `recordTokenUsage` 更新内存 ++ `records.logRecord('goal.update')`;`markComplete` 把状态置 `complete`、发 +`goal.updated` completion 事件,再 `clearInternal` 丢弃记录(`complete` 不持久化)。 +重启后经 `restoreCreate` / `restoreUpdate` 从 records 重建;`active` 经 +`normalizeAfterReplay` 降级为 `paused`。 + +### 场景 E:daemon 重启后,列出 session 的后台任务(facade + reconcile) + +```text +taskService.list(sid) + → _requireSession(sid) // coreApi().listSessions({}) 确认存在 + → coreApi().getBackground({sid}) // in-process BackgroundManager.list + └─ list(false) 包含 ghosts(lost) // reconcile 后,磁盘上 running → lost + → toProtocolTask 适配 // lost → status failed(lossy) +``` + +内部解析:重启后 `BackgroundManager.loadFromDisk` + `reconcile` 把磁盘上 +`running` 的任务重分类为 `lost`;facade `list` 经 CoreAPI 读到包含 ghost 的 +投影,再经 `toProtocolTask`(`mapStatus` 把 `lost` 映射成协议 `failed`)返回。 +facade 不知道 reconcile 细节,runtime 不知道协议形状。 + +### 场景 F:goal resume 后从 records 重建(truth 在 records,不在 runtime) + +```text +agent resume + → GoalMode.restoreCreate(record) // 从 'goal.create' 重建内存状态 + → GoalMode.restoreUpdate(record) × N // 回放 'goal.update' + → GoalMode.normalizeAfterReplay() // active → paused(重启后不能还在跑) + → 用户 /goal resume → resumeGoal() // paused → active,wallClockResumedAt 重置 +``` + +内部解析:goal 的真相是 records 日志;runtime 内存状态只是投影。重启后先经 +`restore*` 重建,再经 `normalizeAfterReplay` 把不可能还在跑的 `active` 降级为 +`paused`;用户显式 `resumeGoal` 才重新激活。`GoalMode` 不自己持久化真相——它 +只经 `records.logRecord` 追加 record。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 / 入口 | 角色 | Aggregate | +|---|---|---|---| +| 列出 session 后台任务 | `taskService.list(sid, query)` | query(facade) | background task | +| 取单个后台任务(含输出) | `taskService.get(sid, tid, { withOutput })` | query(facade) | background task | +| 取消后台任务 | `taskService.cancel(sid, tid)` | command(facade) | background task | +| BackgroundTaskInfo → 协议 BackgroundTask | `toProtocolTask(info)` | query(facade,纯函数) | background task | +| 注册后台任务 | `BackgroundManager.registerTask(task)` | runtime(impl) | background task | +| 停止后台任务 | `BackgroundManager.stop(taskId)` / `stopAll()` | runtime(impl) | background task | +| 读后台任务输出 | `BackgroundManager.getOutputSnapshot(taskId, maxBytes)` | runtime(impl) | background task | +| 持久化后台任务 | `BackgroundTaskPersistence.writeTask / appendTaskOutput / listTasks` | persistence(runtime 持有) | background task | +| 重启 reconcile 后台任务 | `BackgroundManager.loadFromDisk()` + `reconcile()` | runtime(impl) | background task | +| 创建 cron 任务 | `CronCreate` 工具 → `CronManager.addTask(init)` | command(tool → runtime) | cron | +| 删除 cron 任务 | `CronDelete` 工具 → `CronManager.removeTasks(ids)` | command(tool → runtime) | cron | +| 列出 cron 任务 | `CronList` 工具 → `SessionCronStore.list()` + `getNextFireTime` | query(tool 层) | cron | +| cron fire | `CronScheduler.tick()` → `CronManager.handleFire(task, ctx)` | runtime(impl) | cron | +| cron 持久化 | `createCronPersistStore`(`/cron/.json`) | persistence(runtime 持有) | cron | +| 创建 goal | `/goal create` / `UpdateGoal` → `GoalMode.createGoal(input)` | command(slash/tool → runtime) | goal | +| 暂停 / 恢复 goal | `GoalMode.pauseGoal()` / `resumeGoal()` | command(runtime) | goal | +| 取消 goal | `GoalMode.cancelGoal()` | command(runtime) | goal | +| goal 完成 / 阻塞 | `UpdateGoal('complete'/'blocked')` → `markComplete` / `markBlocked` | command(tool → runtime) | goal | +| goal CoreAPI | `rpc-controller` 的 `createGoal` / `getGoal` / `pauseGoal` / `resumeGoal` / `cancelGoal` | query + command(CoreAPI 层) | goal | +| goal 真相(records) | `IRecordsService.logRecord` + `restoreCreate/Update/Clear/Forked` | truth(records / replay) | goal | +| facade 派发(CoreAPI) | `taskService.*` 内 `coreApi().getBackground / stopBackground / getBackgroundOutput` | facade 消费 runtime(单向) | background task | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service (daemon / SDK facade) + ITaskService (background task query + command — list/get 查询 / cancel 命令, + BackgroundTaskInfo → 协议 BackgroundTask) + +Runtime (in-process, per-agent) + BackgroundManager / IBackgroundService (background task runtime — register/start/stop/output/persist/reconcile) + CronManager / ICronService (cron runtime — store + scheduler + fire + persist + start/stop) + GoalMode / IGoalService (goal runtime — durable 状态机 + 生命周期 + 预算) + +Runtime-owned contracts (not top-level *Service) + BackgroundTaskPersistence (background task 持久化 — /tasks/.json + output.log) + SessionCronStore (cron 内存真相) + CronScheduler (cron tick 调度器) + createCronPersistStore (cron 磁盘镜像 — /cron/.json) + +Domain / Policy + BackgroundTask / BackgroundTaskInfo / BackgroundTaskStatus (background task 抽象 + 状态) + CronTask / CronJobOrigin (cron 任务 + fire origin) + GoalSnapshot / GoalStatus / GoalChange (goal 投影 + 状态 + 变更) + +Infrastructure / Truth + Agent record log (IRecordsService / IReplayService) (goal 真相:event-sourced) + SDK adapters (toProtocolTask) (BackgroundTaskInfo → 协议 BackgroundTask) + CoreAPI handle (coreApi()) (task facade 经 ICoreRuntime 取 in-process + getBackground / stopBackground / getBackgroundOutput) + Tool / slash layer (CronCreate/CronDelete/CronList, UpdateGoal, /goal, /cron) (cron / goal 的对外入口) +``` + +依赖关系: + +```text +ITaskService.list/get → CoreAPI.getBackground (query facade → in-process runtime 投影,单向) +ITaskService.cancel → CoreAPI.stopBackground (command facade → in-process runtime,单向派发) +ITaskService.get(withOutput) → CoreAPI.getBackgroundOutput (query facade → runtime 输出读取) +ITaskService → toProtocolTask (协议形状适配) +services/task/task.ts → agent/background (type only) (仅 BackgroundTaskInfo 类型,无运行时值) +BackgroundManager → BackgroundTaskPersistence (runtime 持有持久化 contract) +BackgroundManager → Agent.emitEvent / telemetry / turn.steer (事件 + telemetry + 通知) +CronManager → SessionCronStore / CronScheduler / createCronPersistStore (runtime 持有 store/scheduler/persist) +CronManager → Agent.turn.steer / emitEvent / telemetry (fire 副作用) +GoalMode → IRecordsService / IReplayService / IContextService (truth 在 records/replay) +GoalMode → Agent.emitEvent / telemetry (goal.updated 事件 + telemetry) +agent/factory → BackgroundService / CronService / GoalService (per-agent DI 注册) +agent/rpc-controller → IGoalService (goal 的 CoreAPI 暴露) +tools/cron/cron-* → ICronService (cron 的 tool 入口) +``` + +禁止的边界: + +```text +ITaskService → BackgroundManager (value import) / registerTask / stop / reconcile (facade 不直接触达 runtime impl;只能经 CoreAPI) +services/task/** → agent/background/** (value import) (只允许 type-only import) +BackgroundManager → ITaskService / services/task (runtime impl 不回调 facade) +BackgroundTaskPersistence → services/** (持久化 contract 不依赖 daemon facade) +CronManager → services/** (cron runtime 不依赖 daemon facade) +GoalMode → services/** (goal runtime 不依赖 daemon facade) +services/** → agent/cron / agent/goal (value import) (无 services/cron / services/goal;不允许反向补 facade) +``` + +关键不变量: + +- facade 侧不持有 runtime 状态(无 `ManagedTask` map / `registerTask` / + `reconcile`);runtime 侧不持有 SDK 形状(无 `BackgroundTask` / snake_case / + ISO 适配)。两者唯一的直接引用是 `services/task/task.ts:42` 的 + `import type { BackgroundTaskInfo }`(type-only)。 +- background task 的真相在 runtime(`BackgroundManager.tasks`)+ 磁盘 + (`BackgroundTaskPersistence`);facade 只在 `list` / `get` / `cancel` 时经 + CoreAPI 读 runtime 投影,不自己扫描磁盘、不自己注册 / 停止任务。 +- facade 对 runtime 的引用仅限:经 CoreAPI 的 `getBackground` / + `stopBackground` / `getBackgroundOutput`(in-process 派发,去序列化); + `services/task/` 不直接 import `agent/background/` 的实现类或 persist。 +- cron 的 store / scheduler / persist 都在 `tools/cron/`,由 `CronManager` + 包装;cron 没有 `services/` facade,对外入口在 tool / slash 层。 +- goal 的真相在 records 日志(event-sourced),`GoalMode` 内存状态是投影; + goal 没有 `services/` facade,对外入口在 CoreAPI / 工具 / slash 层。 +- command 副作用(事件 / telemetry / 通知 / steer)集中在各自的 runtime + (`BackgroundManager.fireTerminalEffects` / `CronManager.handleFire` / + `GoalMode.persistState`),REST 路由与 facade 不重新解释 runtime 语义。 + +## 决策记录 + +- **DR1:“task” 是三个 aggregate,不是一个 domain。** background task(后台 + 任务)、cron(定时任务)、goal(目标模式)共享 “异步工作单元” 的直觉,但 + 真相(runtime map + 磁盘 / cron store + 磁盘 / records 日志)、生命周期、 + 副作用、对外入口都不同。它们不合并成一个 “task domain”,也不互相调用。 +- **DR2:`services/task` 是 background-task aggregate 的 SDK facade,不是 + 独立 aggregate。** `ITaskService` 的 `list` / `get` / `cancel` 全部围绕 + background task:数据来自 `coreApi().getBackground`(runtime 投影), + `cancel` 经 `coreApi().stopBackground` 派发到 runtime,`toProtocolTask` 把 + `BackgroundTaskInfo` 适配成协议 `BackgroundTask`。它和 `services/skill` 是 + skill aggregate 的 facade 是同一模式,区别仅在于 background task 的 truth + 直接住在 runtime 里、没有独立 registry 层。 +- **DR3:`list` / `get` = query,`cancel` = command(共用 `ITaskService` + facade)。** `list` / `get` 是只读查询 + 协议形状适配;`cancel` 是取消命令 + (派发 runtime 的 `stop`)。三者 scope 固定为单 session、无分页 / search / + count(`TaskListQuery` 只有一个可选 `status`)。它们不持有运行时状态、不 + 注册 / 停止任务、不写 persist。 +- **DR4:`BackgroundManager` = background task runtime。** 它持有活的 + `ManagedTask` map,拥有 register / start / stop / settle / output / + persist / reconcile;从不暴露在 SDK 边界,facade 只经 CoreAPI 读它的投影。 + 它对齐 `runtime-service.md` 描述的“由进程内对象推导的活状态”的 owner。 +- **DR5:`BackgroundTaskPersistence` = runtime 持有的持久化 contract,不是 + 顶层 service。** 它由 `BackgroundManager` 构造时注入(`agent/factory.ts:51`), + 由 runtime 直接调 `writeTask` / `appendTaskOutput` / `listTasks`;按 + AGENTS.md “被 runtime aggregate 直接消费的 repository 住在 runtime 层”,它 + 住在 `agent/background/` 而不是 `services/`,不是 `*Service` DI 单例。 +- **DR6:cron / goal 是 runtime-only aggregate,无 `services/` facade。** + cron 的 create / delete / list 是模型工具(`tools/cron/cron-*.ts`)+ `/cron` + slash;goal 的 create / pause / resume / cancel 是 CoreAPI + (`rpc-controller.ts:170-174`)+ `UpdateGoal` 工具 + `/goal` slash。二者都 + 没有需要适配成协议形状的 SDK 读模型,对外入口已经在 tool / slash / CoreAPI + 层且直接消费 per-agent runtime。为它们补 `services/cron` / `services/goal` + facade 不带来新契约,反而复制派发管道。 +- **DR7:`list` / `get` 与 `cancel` 共用 `ITaskService` 不构成 muddle。** + 三者是同一个薄 SDK 适配器上的独立方法,实现互不调用(`TaskService.list` + 不调 `cancel`,反之亦然),共享的只是 `_requireSession` / `_getAllRaw` / + `coreApi()` 这条 session 解析 + CoreAPI 派发管道——这是 SDK 适配器的基础 + 设施,不是 query / command 的业务逻辑。AGENTS.md 的 “command / query 角色 + 不互相调用业务方法” 针对的是实现耦合,不是接口同址。共用 facade 避免了为 + 三个单方法角色各复制一份 session 解析 + CoreAPI 派发管道。真正的角色分离 + (query+command facade vs runtime impl vs persistence contract)已经按文件 + / 层物理分离,没有重叠或渗漏。 +- **DR8:不引入独立的 background task registry。** background task 的真相直接 + 住在 `BackgroundManager.tasks` + `BackgroundTaskPersistence` 磁盘 mirror 里, + 没有 skill 那种“跨层共享的 registry truth”(`SessionSkillRegistry` 被 + facade / runtime / runtime loading 三方消费)。facade 经 CoreAPI 读 runtime + 投影即可,不需要抽一层 registry contract。 +- **DR9:当前代码布局已满足边界,无需迁移。** background task:facade 在 + `services/task/`(`ITaskService` / `TaskService` / `toProtocolTask`,query + + command),runtime 在 `agent/background/`(`BackgroundManager` / + `IBackgroundService` / `BackgroundService` + 具体 `BackgroundTask` 实现), + persistence 在 `agent/background/persist.ts`(`BackgroundTaskPersistence`)。 + cron:runtime 在 `agent/cron/`(`CronManager` / `ICronService` / + `CronService`),store / scheduler / persist 在 `tools/cron/`,tool 入口在 + `tools/cron/cron-*.ts`。goal:runtime 在 `agent/goal/`(`GoalMode` / + `IGoalService` / `GoalService`),truth 在 records / replay,CoreAPI 入口在 + `agent/rpc-controller.ts`。三者经 `agent/factory.ts` 注册为 per-agent DI + (`BackgroundService` / `CronService` / `GoalService`)。依赖方向单向: + `services/task` → CoreAPI → `agent/background`(type-only 直接 import); + `agent/cron` → `tools/cron`;`agent/goal` → records / replay / context; + `agent/rpc-controller` / `tools/cron/*` → `IGoalService` / `ICronService`。 + 三层都没有反向 import `services/task`,M0.1 fence 干净。本次只出概念定稿, + 不做代码拆分。 diff --git a/.agents/skills/service-skill/explanation/domains/terminal-config.md b/.agents/skills/service-skill/explanation/domains/terminal-config.md new file mode 100644 index 000000000..9118ebd66 --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/terminal-config.md @@ -0,0 +1,521 @@ +# Terminal / Config 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +> 范围说明:ROADMAP M4.8 把 `terminal` / `config` 放在同一个 step 里确认 +> 边界。它们名字短、都挂在 `services/` 顶层、都经 REST 暴露,但**不是同一 +> 个 domain**——本文先把它们拆清楚,再分别确认 query / command / runtime +> 各自落在哪一层,并说明为什么**不需要**代码拆分、改名或合并。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的终端与配置流](#统一的终端与配置流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,标题里的 “terminal / config” 是**两个相互独立的 domain**,共 +享 “经 daemon / SDK 对外暴露” 的形状,但真相、键、作用域、副作用、对外入 +口都不同: + +- **terminal domain(PTY 终端生命周期 + 活帧流)**:在**某个 session 内** + 创建并持有交互式 PTY 进程。键是 `(sessionId, terminalId)`,真相是**进程内 + 的 node-pty 子进程 + 其输出 / 退出事件流**(不落盘;服务进程退出即消 + 失)。所有 terminal 都 scoped 到 session:创建时经 `ISessionService.get` + 取 cwd,再经 `resolveSafePath` 把 `input.cwd` 约束在 `session.metadata.cwd` + 内。 + - **query(查询)**:`ITerminalService.list(sessionId)` / `get(sessionId, + terminalId)`——读 terminal 元数据快照(`Terminal`:`id` / `session_id` / + `cwd` / `shell` / `cols` / `rows` / `status` / `created_at` / `exited_at` + / `exit_code`)。 + - **command(命令)**:`ITerminalService.create`(spawn 一个 PTY,分配 + `term_`)/ `write`(向进程 stdin 写)/ `resize`(改 PTY 窗口尺 + 寸)/ `close`(kill 进程并标记 exited)。 + - **runtime(运行时 attach / spawn)**:`ITerminalService.attach` / + `detach` / `detachAllForSink`——connection-scoped 的活订阅,按 + `(sessionId, terminalId, sink.id)` 持有 `TerminalAttachSink`,把 + node-pty 的 `onData` / `onExit` 事件推导成 `terminal_output` / + `terminal_exit` 帧向外投递,并维护一个 ring buffer 用于 `sinceSeq` 重 + 放。`TerminalBackend.spawn`(`NodePtyTerminalBackend`)是 runtime 的进 + 程创建后端——它把 “活进程” 接入 terminal domain。这是 terminal domain + 的活状态 owner。 + - **infrastructure(基础设施,非 service)**:`TerminalBackend` / + `TerminalProcess` / `TerminalFrame` / `TerminalAttachSink`(`terminal.ts` + 的类型契约)+ `disposeAll`(`terminal.ts:90`)——描述 “活进程 / 帧 / 订 + 阅 sink” 的形状与批量 dispose,不是 `*Service`。 +- **config domain(KimiConfig 读 / 写 facade)**:daemon / SDK 读写全局 + `KimiConfig` 的**薄 facade**。键是单一的 “the config”(无 id;全局只有一 + 份),真相是 **core 进程持有的 config 文件(`configPath`)**——config + domain **自己不拥有真相**,而是经 `ICoreRuntime` 的 in-process + CoreAPI(`getCoreApi()`)委托给 `KimiCore.getKimiConfig` / + `setKimiConfig`,由 core 负责读盘 / 合并 / 写盘。config domain 只负责: + (a) 把 `KimiConfig` 投影成对外的 `ConfigResponse`(隐藏 apiKey 原文、派生 + `has_api_key`、snake↔camel 键转换);(b) 在 `set` 成功后发布 + `event.config.changed`,通知下游(main agent、permission、thinking、 + telemetry 等)配置已变。 + - **query(查询)**:`IConfigService.get()` → `ConfigResponse`(reload + + 投影)。 + - **command(命令)**:`IConfigService.set(patch)` → 投影后的 + `ConfigResponse`,副作用 = 写盘(在 core)+ 发布 + `event.config.changed`。 + - config domain 本质上是 **core config 真相之上的投影 + 变更通知 + facade**,按 service-skill 的 “daemon / SDK facade” 形状暴露为顶层 + `IConfigService`;它不是 repository(真相在 core 进程),也不引入 + command / query 拆分。 + +**两者不是同一个 domain,也不需要进一步拆分、改名或合并。** 边界当前就是干 +净的: + +- `services/terminal`(terminal domain)只做**会话内 PTY 生命周期 + 活帧 + 流**,不知道 config、不写 config、不读 config;唯一的 session 外依赖是 + `resolveSafePath`(cwd 约束)和 `ISessionService`(取 cwd)。 +- `services/config`(config domain)只做**KimiConfig 投影 + 变更通知**,不 + 知道 terminal、不持有任何进程、不 scoped 到 session;唯一的依赖是 + `ICoreRuntime`(真相 owner)和 `IEventService`(变更通知)。 + +**关系一句话:terminal 是会话内 PTY 生命周期 + 活帧流 domain(query + +command + runtime attach/spawn),config 是 core config 真相之上的投影 + +变更通知 facade(query + command)。两者不共享真相、不共享键、不共享作用域 +、不互相调用,不应合并;当前代码已按目录物理分离,无需拆分或改名。** + +接口 / 实现落点见 `services/terminal/terminal.ts` 的 `ITerminalService` +(terminal query + command + runtime facade,49–73 行)、 +`services/terminal/terminalService.ts` 的 `TerminalService`(实现,43 行) +与 `NodePtyTerminalBackend`(spawn 后端,237 行)、 +`services/config/config.ts` 的 `IConfigService`(config query + command +facade,4–9 行)、`services/config/configService.ts` 的 `ConfigService` +(实现 + 投影 + 变更通知,20 行)。共享协议类型(`Terminal` / +`CreateTerminalRequest` / `TerminalOutputMessage` / `TerminalExitMessage` / +`ConfigResponse` / `PatchConfigRequest`)见 `@moonshot-ai/protocol`。本文只 +承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. “terminal / config” 指代两件不同的事,不是单一 domain + +它们都挂在 `services/` 顶层、都经 REST 暴露、都只有少量方法,但键 / 作用域 +/ 真相完全不同: + +- **terminal(会话内 PTY)**:在某个 session 内创建并持有交互式终端。键是 + `(sessionId, terminalId)`;真相是进程内的 node-pty 子进程 + 输出 / 退出事 + 件流;每次 `create` 经 `ISessionService.get(sessionId)` 取 cwd,再经 + `resolveSafePath` 把 `input.cwd` 约束在 cwd 内。生命周期跟随 session 与 + 服务进程——进程退出,terminal 消失;不落盘。 +- **config(全局配置投影)**:读写全局 `KimiConfig`。键是单一的 “the + config”(无 id);真相是 core 进程持有的 config 文件;config domain 自己 + 不持真相,经 `ICoreRuntime.getCoreApi()` 委托给 core。生命周期由 + `get` / `set` 驱动,`set` 成功后向全进程广播 `event.config.changed`。 + +把两者并成一个 “runtime / core service domain” 是误判:terminal 的真相是 +**活进程**(易失、session-scoped),config 的真相是**磁盘文件**(持久、 +global)。它们既不共享真相,也不共享键空间,也不共享作用域。 + +### 2. terminal 是会话内 PTY domain,query / command / runtime 各就其位 + +terminal domain 的职责可以按 “读快照 / 改生命周期 / 订阅活流” 清晰分层: + +- **query**:`list` / `get` 只读 terminal 元数据快照(`Terminal`)。无副作 + 用,不写进程、不持订阅。 +- **command**:`create` / `write` / `resize` / `close` 是 terminal 生命周期 + 的写入入口。`create` 分配 id + spawn 进程;`write` / `resize` 转发到 + `TerminalProcess`;`close` kill 进程并把 `Terminal.status` 置为 + `exited`。它们是 terminal domain 对 daemon / SDK 的写入。 +- **runtime(attach / spawn)**:`attach` / `detach` / `detachAllForSink` + 持有 connection-scoped 的活订阅 sink;`onData` / `onExit` 把 node-pty 事 + 件推导成 `terminal_output` / `terminal_exit` 帧,写入 ring buffer(受 + `maxBufferedFrames` 限额)并向所有 sink 投递;`attach(sinceSeq)` 用 + buffer 重放增量帧。`TerminalBackend.spawn`(`NodePtyTerminalBackend`)是 + runtime 的进程创建后端。runtime 不直接写 `Terminal` 真相之外的任何持久 + 状态——它是进程内事件流的投影。 + +三者共用同一份 in-memory `records: Map` +(`terminalService.ts:51`),但**业务方法互不调用**:`create` / `write` / +`resize` / `close` 不调 `attach` / `detach`;`attach` / `detach` 也不调 +command。它们通过共享的 `TerminalRecord`(process + sinks + buffer)协作, +而非通过业务方法互相调用——这正符合 service-skill 的 “command / query / +runtime 角色不互相调用业务方法”。 + +### 3. terminal 的 `create` 内嵌 `spawn` 不构成 muddle + +`create` 内部调用 `this.backend.spawn(...)`(`terminalService.ts:74`)来创 +建 node-pty 进程。这不意味着 “command 角色偷做了 runtime 的事”——`spawn` +是 terminal 生命周期创建的原子一步:`create` 的语义就是 “分配 id + 拉起进 +程 + 登记 record + 返回元数据”。`TerminalBackend` 是注入的进程创建后端 +(`TerminalServiceOptions.backend`,生产 = `NodePtyTerminalBackend`,测试 +可注入 fake),它把 “如何拉起一个活进程” 与 “何时拉起 / 如何登记” 解耦。 +`attach` / `detach` / 帧投递这条 runtime 流仍然独立,不被 `create` 复用。 + +### 4. config 是 core config 真相之上的投影 facade,不是 repository + +`IConfigService.get` / `set` 看起来像 repository 的 `get` / `update`,但它 +**不是** repository: + +- config domain **不持有真相**:`KimiConfig` 的真相在 core 进程( + `core-impl.ts` 的 `this.config` + `configPath` + `loadRuntimeConfigSafe` + / `mergeConfigPatch` / `writeConfigFile`)。`ConfigService` 经 + `ICoreRuntime.getCoreApi()`(in-process CoreAPI,跳过 JSON 序列 + 化)调用 `getKimiConfig` / `setKimiConfig`(`configService.ts:59-61`)。 +- config domain 的职责是**投影 + 通知**:`toConfigResponse` + (`configService.ts:64`)把 `KimiConfig` 投影成对外 `ConfigResponse` + (隐藏 apiKey 原文、派生 `has_api_key`、camel→snake 键);`set` 成功后发 + 布 `event.config.changed`(`configService.ts:40-46`)。 + +因此 config domain **不引入** repository / index 角色——它没有自己的真相 +要持久化。它是 core config 真相在 `services/` 层的 daemon / SDK facade。 + +### 5. config 的 query 与 `set` 共用 `IConfigService` 不构成 muddle + +`IConfigService` 只有 `get` / `set` 两个方法,且实现里 `set` 不调 `get` +、各自独立访问 `coreApi()`。它们是同一 config facade 上的两个方法,共享的 +只是 “coreAPI → toConfigResponse” 这条投影管道。为 `get` / `set` 各抽一 +个 `IConfigQueryService` / `IConfigCommandService` 不带来新契约——只是同名 +复制 + 管道复制。query 与 command 在 `IConfigService` 上同址,符合 +service-skill 对 “小聚合 facade” 的容忍(见 AGENTS.md:角色只在 “有清晰 +owner + 非空契约” 时才引入)。 + +### 6. 两者互不引用;向上各自由独立 transport 消费 + +- terminal 的对外入口:`packages/server/src/routes/terminals.ts`(REST + list / create / get / close)+ `packages/server/src/start.ts` 的 + `wsGw.setTerminalHandler`(WebSocket attach / detach / write / resize / + close,start.ts:223-233)。REST 负责生命周期快照,WebSocket 负责活帧流。 +- config 的对外入口:`packages/server/src/routes/config.ts`(REST get / + set,config.ts:40,61)。 + +terminal 不引用 config,config 不引用 terminal(`grep` 交叉引用为空)。 +两者向上各自由独立的 transport 表面消费,不共享 transport、不共享真相、不 +共享状态。 + +## Service 拆分概览 + +| Service / 角色 | 一句话职责 | 角色 | Domain | +|---|---|---|---| +| `ITerminalService` | 会话内 PTY facade:`list` / `get`(query)+ `create` / `write` / `resize` / `close`(command)+ `attach` / `detach` / `detachAllForSink`(runtime) | query + command + runtime(facade) | terminal | +| `TerminalService` | `ITerminalService` 实现:session → cwd → `resolveSafePath` → `TerminalBackend.spawn`;in-memory `records`;onData/onExit 帧推导 + ring buffer + sink 投递 | query + command + runtime(impl) | terminal | +| `NodePtyTerminalBackend`(`terminalService.ts:237`) | node-pty 进程创建后端:spawn shell + 桥接 onData/onExit/write/resize/kill | runtime infrastructure(非 service) | terminal | +| `TerminalBackend` / `TerminalProcess` / `TerminalFrame` / `TerminalAttachSink`(`terminal.ts`) | 活进程 / 帧 / 订阅 sink 的类型契约 | infrastructure(非 service) | terminal | +| `TerminalNotFoundError`(`terminal.ts:78`) / `disposeAll`(`terminal.ts:90`) | terminal 查找错误 + 批量 dispose helper | infrastructure(非 service) | terminal | +| `IConfigService` | 全局 KimiConfig facade:`get`(query)+ `set`(command) | query + command(facade) | config | +| `ConfigService` | `IConfigService` 实现:`coreApi()` → `getKimiConfig` / `setKimiConfig` + `toConfigResponse` 投影 + `event.config.changed` 通知 | query + command(impl) | config | +| `toConfigResponse` / `hasProviderCredential` / `convertKeysSnakeToCamel`(`configService.ts`) | KimiConfig → ConfigResponse 投影 + 凭证检测 + 键转换 | infrastructure(非 service) | config | + +> 只有这些角色。**不为 terminal 拆出 `ITerminalQueryService` / +> `ITerminalCommandService` / `ITerminalRuntimeService`**——terminal 的 +> query / command / runtime 已按方法语义在同一份 in-memory record 上清晰分 +> 层,业务方法互不调用;为它们各抽接口只是把同一份 `records` Map 拆成三份 +> 同名复制 + 管道复制。**不为 config 拆 command / query**——`IConfigService` +> 只有 `get` / `set` 两个方法,是 core config 真相的投影 facade 的直接暴 +> 露,拆成两个接口不带来新契约。**不把 terminal / config 合并成一个 +> “runtime service”**——两者键空间(`(sessionId, terminalId)` vs 单例)、 +> 真相(活进程 vs 磁盘文件)、作用域(session vs global)、副作用(帧流 +> vs 变更事件)完全相反,不能共存于一个 domain。 +> 共享协议类型(`Terminal` / `CreateTerminalRequest` / +> `TerminalOutputMessage` / `TerminalExitMessage` / `ConfigResponse` / +> `PatchConfigRequest`)见 `@moonshot-ai/protocol`。 + +模式参考: + +- query 侧对齐 [`query-service.md`](../../reference/patterns/query-service.md) + 的**只读 list / get 语义**:terminal 的 `list` / `get`、config 的 `get` + 都是只读读模型入口;但 terminal scope 是 `(sessionId, terminalId)`、 + config scope 是单例,无跨 scope 的统一分页 / search / count,所以**不 + 套用**完整的 `BaseQuery` + scope 便捷方法骨架。 +- command 侧对齐 [`command-service.md`](../../reference/patterns/command-service.md) + 的**唯一写入入口**语义:terminal 的 `create` / `write` / `resize` / + `close`、config 的 `set` 各自是其 domain 的写入入口;但 terminal 没有 + create / update / archive / fork 生命周期族(`create` 是 spawn 而非持久 + 化 aggregate 创建,`close` 是 kill 而非 archive),config 是单例投影(无 + lifecycle),所以**不套用**完整的 `ICommandService` 生命周期骨架。 +- runtime 侧对齐 [`runtime-service.md`](../../reference/patterns/runtime-service.md) + 描述的 “由进程内对象 / 事件流推导的活状态” 的 owner:`TerminalService` + 的 attach / detach / 帧投递持有 connection-scoped 的活订阅,由 node-pty + 事件推导 `terminal_output` / `terminal_exit` 帧向外投递;它不是 daemon / + SDK 的 query / command facade。config domain **没有** runtime 角色——它 + 的副作用是离散的 `event.config.changed` 通知(经 `IEventService`),不是 + 持续活状态投影。 + +## 统一的终端与配置流 + +### terminal:创建(command) + +```text +RPC/SDK create(sessionId, CreateTerminalRequest) + → ITerminalService.create + → ISessionService.get(sessionId) // 取 session + cwd + → input.cwd ? resolveSafePath(cwd, input.cwd).absolute // 约束在 cwd 内 + : fs.realpath(session.metadata.cwd) + → TerminalBackend.spawn({ cwd, shell, cols, rows }) // NodePtyTerminalBackend → node-pty + → id = `term_${ulid()}`; record = { terminal, process, sinks, buffer, nextSeq, disposables, closed } + → process.onData → onData(record, data); process.onExit → onExit(record, exitCode) + → records.set(recordKey(sessionId, id), record) + → return { ...terminal } // Terminal 快照(status: 'running') +``` + +### terminal:列 / 读(query) + +```text +RPC/SDK list(sessionId) / get(sessionId, terminalId) + → ITerminalService.list / get + → ISessionService.get(sessionId) // 校验 session 存在 + → records filtered by session_id / recordKey + → return 快照(复制 Terminal,不暴露 record 内部 sinks / buffer) +``` + +### terminal:写 / 调整尺寸 / 关闭(command) + +```text +RPC/SDK write / resize / close(sessionId, terminalId, ...) + → ITerminalService.write / resize / close + → requireRecord(sessionId, terminalId) // 校验 session + 取 record(否则 TerminalNotFoundError) + → write → record.process.write(data) + → resize → record.terminal.cols/rows 更新 + record.process.resize(cols, rows) + → close → record.closed = true + record.process.kill() + markExited(record, null) +``` + +### terminal:订阅活帧流(runtime attach / spawn) + +```text +WS attach(sessionId, terminalId, sink, { sinceSeq? }) + → ITerminalService.attach + → requireRecord(...) + → record.sinks.set(sink.id, sink) + → replay = record.buffer.filter(frameSeq > sinceSeq) + → for frame of replay: sink.send(frame) // 增量重放 + → return { replayed: replay.length } + +node-pty onData(data) + → onData(record, data) + → frame = { type: 'terminal_output', seq: ++record.nextSeq, session_id, terminal_id, timestamp, payload: { data } } + → pushFrame(record, frame) // 写 ring buffer + 向所有 sink 投递 + +node-pty onExit({ exitCode }) + → onExit → markExited(record, exitCode) + → record.terminal.status = 'exited' + exited_at + exit_code + → frame = { type: 'terminal_exit', ... } + → pushFrame(...) + disposeAll(record.disposables) // 投递退出帧 + 释放 onData/onExit 订阅 +``` + +### config:读(query) + +```text +RPC/SDK get() + → IConfigService.get + → coreApi().getKimiConfig({ reload: true }) // in-process CoreAPI(core 进程重读盘) + → toConfigResponse(KimiConfig) // 投影:隐藏 apiKey、派生 has_api_key、camel→snake + → return ConfigResponse +``` + +### config:写(command + 变更通知) + +```text +RPC/SDK set(PatchConfigRequest) + → IConfigService.set + → convertKeysSnakeToCamel(patch) // snake→camel 键转换 + → coreApi().setKimiConfig(camelPatch) // core 进程 merge + writeConfigFile + → response = toConfigResponse(updated) + → eventService.publish({ type: 'event.config.changed', agentId: 'main', sessionId: '__global__', + changedFields: Object.keys(patch), config: response }) + → return response +``` + +## 关键场景 + +### 场景 A:在 session 内创建一个交互式终端(terminal command) + +用户在 CLI / Web 里请求 “在 session S 开一个终端”。daemon 经 REST +`routes/terminals.ts` 调用 `ITerminalService.create(S, req)`。`create` 取 +session cwd、约束 `req.cwd`、spawn node-pty、分配 `term_`、登记 +record、返回 `Terminal` 快照(status `running`)。进程此时已活,但还没有订 +阅者——输出帧会进入 ring buffer 等待 attach。 + +### 场景 B:把终端输出流式推到前端(terminal runtime) + +前端经 WebSocket 连上,daemon 经 `start.ts` 的 `wsGw.setTerminalHandler` +调用 `attach(S, T, sink, { sinceSeq })`。`attach` 登记 sink,把 buffer 里 +`seq > sinceSeq` 的帧重放给前端(断线重连不丢帧)。之后 node-pty 每产出 +`onData`,`onData` → `pushFrame` 把帧写入 buffer 并向所有 sink 投递;进程 +退出时 `onExit` → `markExited` 投递 `terminal_exit` 帧并释放订阅。连接断 +开时 `detachAllForSink(sinkId)` 清理该连接在所有 terminal 上的 sink。 + +### 场景 C:向终端输入 / 调整尺寸 / 关闭(terminal command) + +前端经 WebSocket 调用 `write(S, T, data)`(键盘输入)、`resize(S, T, cols, +rows)`(窗口尺寸变化)、`close(S, T)`(关闭终端)。三者经 +`requireRecord` 校验后转发到 `TerminalProcess`;`close` 额外把 +`Terminal.status` 置为 `exited` 并 kill 进程。REST `routes/terminals.ts` +也暴露 `close`,供非 WebSocket 客户端关闭终端。 + +### 场景 D:读取当前配置(config query) + +用户在 CLI / Web 请求 “看当前配置”。daemon 经 REST `routes/config.ts` 调用 +`IConfigService.get()`。`get` 经 `coreApi().getKimiConfig({ reload: true })` +让 core 重读盘,再经 `toConfigResponse` 投影成 `ConfigResponse`(apiKey 不 +外泄,只返回 `has_api_key`)。 + +### 场景 E:修改配置并通知全进程(config command) + +用户修改 default model / provider / permission。daemon 经 REST +`routes/config.ts` 调用 `IConfigService.set(patch)`。`set` 把 snake 键转 +camel,经 `coreApi().setKimiConfig(...)` 让 core 合并 + 写盘,拿到更新后 +的 `KimiConfig`,投影成 `ConfigResponse`,再发布 +`event.config.changed`(`sessionId: '__global__'`)。下游(main agent 的 +modelAlias、permission、thinking、telemetry 等)订阅该事件并刷新运行时配 +置——config domain 自己**不**负责把变更应用到每个运行时,只负责通知。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 / 入口 | 角色 | Domain | +|---|---|---|---| +| 列出 session 内终端 | `terminalService.list(sid)` | query(facade) | terminal | +| 读单个终端元数据 | `terminalService.get(sid, tid)` | query(facade) | terminal | +| 创建终端(spawn PTY) | `terminalService.create(sid, req)` → `backend.spawn` | command(含 runtime spawn) | terminal | +| 向终端写输入 | `terminalService.write(sid, tid, data)` | command | terminal | +| 调整终端尺寸 | `terminalService.resize(sid, tid, cols, rows)` | command | terminal | +| 关闭终端 | `terminalService.close(sid, tid)` | command | terminal | +| 订阅终端输出 | `terminalService.attach(sid, tid, sink, opts)` | runtime | terminal | +| 取消订阅 | `terminalService.detach(sid, tid, sinkId)` / `detachAllForSink(sinkId)` | runtime | terminal | +| 帧推导 + ring buffer + 投递 | `onData` / `onExit` / `markExited` / `pushFrame`(`terminalService.ts`) | runtime(内部) | terminal | +| node-pty 进程后端 | `NodePtyTerminalBackend.spawn`(`terminalService.ts:237`) | runtime infrastructure | terminal | +| 读全局配置 | `configService.get()` → `coreApi().getKimiConfig` | query | config | +| 写全局配置 | `configService.set(patch)` → `coreApi().setKimiConfig` + `event.config.changed` | command | config | +| KimiConfig → ConfigResponse 投影 | `toConfigResponse` / `hasProviderCredential` / `convertKeysSnakeToCamel`(`configService.ts`) | infrastructure | config | +| REST 路由(terminal) | `packages/server/src/routes/terminals.ts` | transport | terminal | +| WebSocket(terminal 活流) | `packages/server/src/start.ts`(`wsGw.setTerminalHandler`) | transport / runtime | terminal | +| REST 路由(config) | `packages/server/src/routes/config.ts` | transport | config | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service (daemon / SDK facade) + ITerminalService (terminal query + command + runtime — 会话内 PTY) + IConfigService (config query + command — 全局 KimiConfig 投影 facade) + +Runtime / Infrastructure (in-process) + TerminalBackend / NodePtyTerminalBackend (terminal runtime — node-pty spawn 后端) + TerminalProcess / TerminalFrame / TerminalAttachSink (terminal 活进程 / 帧 / sink 契约) + toConfigResponse / hasProviderCredential / convertKeysSnakeToCamel (config 投影 helper) + +Persistence / Truth + node-pty 子进程 + onData/onExit 事件流 (terminal 真相 — 进程内、易失、session-scoped) + KimiCore.getKimiConfig / setKimiConfig + configPath 文件 (config 真相 — core 进程持有、持久、global) + +Transport (above agent-core) + packages/server/src/routes/terminals.ts (terminal REST: list/create/get/close) + packages/server/src/start.ts (terminal WebSocket: attach/detach/write/resize/close) + packages/server/src/routes/config.ts (config REST: get/set) +``` + +依赖关系: + +```text +ITerminalService.list/get → ISessionService.get + records Map (query → session + in-memory record) +ITerminalService.create → ISessionService.get + resolveSafePath + TerminalBackend.spawn (command → cwd 约束 + spawn) +ITerminalService.write/resize/close → requireRecord + TerminalProcess (command → 进程转发) +ITerminalService.attach/detach → requireRecord + sinks Map + ring buffer (runtime → 活订阅 + 重放) +onData/onExit/markExited/pushFrame → TerminalFrame + sinks + disposeAll (runtime → 帧推导 + 投递) +NodePtyTerminalBackend.spawn → node-pty (runtime infrastructure → 子进程) +IConfigService.get → ICoreRuntime.getCoreApi().getKimiConfig + toConfigResponse (query → core 投影) +IConfigService.set → ICoreRuntime.getCoreApi().setKimiConfig + IEventService.publish (command → core + 变更通知) +routes/terminals.ts → ITerminalService (transport → terminal) +start.ts (wsGw.setTerminalHandler) → ITerminalService.attach/detach/write/resize/close (transport → terminal runtime) +routes/config.ts → IConfigService (transport → config) +``` + +禁止的边界: + +```text +services/terminal/** ⇄ services/config/** (terminal 与 config 互不引用) +services/terminal/** → IConfigService / KimiConfig (terminal 不读 / 不写 config) +services/config/** → ITerminalService / TerminalProcess (config 不持有任何进程、不 scoped 到 session) +services/terminal/** → (持久化 records 到盘) (terminal 真相是活进程,不落盘) +services/config/** → (自己持有 config 真相) (config 真相在 core,config domain 只投影) +ITerminalService (cmd) → attach/detach 业务方法 (command 不调 runtime 业务方法;共享 record 协作) +ITerminalService (rt) → create/write/resize/close (runtime 不回调 command 业务方法) +ConfigService.set → (跳过 coreApi 直接写盘) (config 写必须经 core 的 setKimiConfig,避免双真相) +ConfigService → (在 ConfigResponse 暴露 apiKey 原文) (投影必须派生 has_api_key,不外泄凭证) +``` + +关键不变量: + +- terminal / config 两目录之间**零 import**(`grep` 交叉引用为空)。两者共 + 享的只是 `@moonshot-ai/protocol` 的协议类型与 `di`,不共享任何 service + / 状态 / 真相。 +- terminal 的真相是 node-pty 子进程 + 事件流(进程内、易失、session- + scoped);config 的真相是 core 进程的 config 文件(core 持有、持久、 + global)。两种真相不重叠、不互相派生。 +- terminal 的所有 `input.cwd` 经 `resolveSafePath` 约束在 session cwd 内 + (`terminalService.ts:67-70`);config 不碰路径、不 scoped 到 session。 + 两种作用域语义互不兼容,不能合并进同一个 domain。 +- terminal 的 command 不调用 attach/detach 业务方法;runtime 也不回调 + command。它们通过共享的 in-memory `TerminalRecord`(process + sinks + + buffer)协作,符合 “command / query / runtime 角色不互相调用业务方 + 法”。 +- config 的所有读 / 写都经 `ICoreRuntime.getCoreApi()`(in-process + CoreAPI),不直接读盘 / 写盘(`configService.ts:59-61`)。这保证 config + domain 不会与 core 形成 “双真相”。 +- config 的 `set` 成功后必须发布 `event.config.changed` + (`configService.ts:40-46`);下游运行时刷新依赖该事件。config domain 自 + 己不把变更应用到每个运行时,只负责投影 + 通知。 + +## 决策记录 + +- **DR1:“terminal / config” 是两个独立 domain,不是一个 domain。** + terminal(会话内 PTY 生命周期 + 活帧流)、config(全局 KimiConfig 投影 + + 变更通知)共享 “经 daemon / SDK 对外暴露” 的形状,但键 + (`(sessionId, terminalId)` / 单例)、作用域(session / global)、真相 + (活进程 / 磁盘文件)、写语义、对外入口都不同。它们不合并成一个 + “runtime / core service domain”,也不互相调用。 +- **DR2:terminal 是会话内 PTY domain,query + command + runtime 各就其 + 位。** `ITerminalService` 的 `list` / `get` = query;`create` / `write` + / `resize` / `close` = command;`attach` / `detach` / `detachAllForSink` + + `onData` / `onExit` / `pushFrame` + `NodePtyTerminalBackend.spawn` = + runtime。所有方法 scoped 到 session,`create` 经 `resolveSafePath` 约束 + cwd。 +- **DR3:terminal 的 `create` 内嵌 `spawn` 不构成 muddle。** `spawn` 是 + terminal 生命周期创建的原子一步(分配 id + 拉起进程 + 登记 record), + `TerminalBackend` 是注入的进程创建后端(生产 node-pty / 测试 fake),把 + “如何拉起活进程” 与 “何时拉起 / 如何登记” 解耦。attach / 帧投递这条 + runtime 流仍独立。 +- **DR4:config 是 core config 真相之上的投影 + 变更通知 facade,不是 + repository。** config domain 不持有真相(真相在 core 进程的 + `configPath` 文件),经 `ICoreRuntime.getCoreApi()` 委托给 + `getKimiConfig` / `setKimiConfig`,只负责 `toConfigResponse` 投影 + + `event.config.changed` 通知。因此 config domain **不引入** repository / + index 角色。 +- **DR5:config 的 `get` / `set` 共用 `IConfigService` 不构成 muddle。** + 它们是同一 config facade 上的两个方法,实现互不调用(`set` 不调 + `get`),共享的只是 “coreAPI → toConfigResponse” 投影管道。为它们各抽 + query / command 接口只是同名复制 + 管道复制。 +- **DR6:两者互不引用 + 各自独立 transport 表面。** terminal → + `routes/terminals.ts`(REST)+ `start.ts`(WebSocket `setTerminalHandler`); + config → `routes/config.ts`(REST)。两目录之间零 import。这条边界是 + “是否需要拆分 / 合并” 的硬指标:不共享真相、不互相调用、不共享 + transport,两类关注点就是清晰的。 +- **DR7:不引入 `ITerminalQueryService` / `ITerminalCommandService` / + `ITerminalRuntimeService`。** terminal 的 query / command / runtime 已按 + 方法语义在同一份 in-memory record 上清晰分层,业务方法互不调用(经共享 + `TerminalRecord` 协作)。再抽三层接口只是把同一份 `records` Map 拆成三 + 份同名复制 + 管道复制。 +- **DR8:不为 config 拆 command / query。** `IConfigService` 只有 `get` / + `set` 两个方法,是 core config 真相投影 facade 的直接暴露。拆成 + `IConfigCommandService` / `IConfigQueryService` 不带来新契约,反而把同 + 一条 “coreAPI → toConfigResponse” 管道复制两遍。 +- **DR9:不需要改名。** `terminal` / `ITerminalService` / `TerminalService` + / `config` / `IConfigService` / `ConfigService` 的命名已精确反映其职责 + (terminal = PTY 终端生命周期;config = KimiConfig 投影 facade)。不存在 + “名字覆盖多个不相关关注点” 或 “名字误导” 的问题;现有命名与 service- + skill 的 `.ts` + `Service.ts` command-facade 布局一致。 +- **DR10:不需要拆分、合并或移动文件。** terminal 已物理隔离在 + `services/terminal/`(`terminal.ts` 契约 + `terminalService.ts` 实现 + + node-pty 后端),config 已物理隔离在 `services/config/`(`config.ts` 契 + 约 + `configService.ts` 实现 + 投影 helper)。两者零 cross-import、无 + god 残留(terminal 不碰 config,config 不碰 terminal / 进程)、各自独立 + transport。M4.8 结论:**保持现状**,仅在本概念定稿中固化边界。 diff --git a/.agents/skills/service-skill/explanation/domains/tool-mcp.md b/.agents/skills/service-skill/explanation/domains/tool-mcp.md new file mode 100644 index 000000000..114315e53 --- /dev/null +++ b/.agents/skills/service-skill/explanation/domains/tool-mcp.md @@ -0,0 +1,282 @@ +# Tool / MCP Service 目标架构定稿 + +本文是**概念定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、依赖方向和决策记录。 + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [Service 拆分概览](#service-拆分概览) +- [统一的 tool-mcp 激活流](#统一的-tool-mcp-激活流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里,**tool** 与 **mcp** 是两个相邻但职责不同的 domain: + +- `tool` = **command / registry & policy(工具注册 + 激活策略)**:管理 agent 可见的工具集合(builtin / user / mcp 三类来源)的注册表,以及“哪些工具对当前 loop 可见”的激活策略(`setActiveTools` / `loopTools`)。它是 agent 进程内的**工具目录与策略**,决定一次 loop 能拿到哪些工具。 +- `mcp` = **runtime(MCP 连接状态)**:管理每个 MCP server 的**连接生命周期与活状态投影**——`pending / connected / failed / disabled / needs-auth`、连接 / 断开 / 重连、以及状态变更订阅。它是 tool domain 的 mcp 来源背后的运行时真相。 + +**这两个 domain 不需要合并、也不需要进一步拆分。** 边界当前就是干净的: + +- tool 只做**工具注册 + 激活策略**(registry / `setActiveTools` / `loopTools`),不直接持有任何 MCP 连接状态——没有 socket、没有 client、没有 per-server 的 `pending/connected/failed` 状态机;当它需要把某个 MCP server 的工具纳入注册表时,只通过订阅 mcp 的状态变更、读取 mcp 暴露的 `resolved(server)` 来反应式地维护注册表。 +- mcp 只做**连接生命周期 + 活状态投影**,不表达任何工具注册 / 激活语义——它不知道某个 server 的工具最终被注册成了什么 qualified name、也不知道哪些工具当前对 loop 可见;那些都留在 tool 侧。 + +**关系一句话:mcp 拥有 MCP server 的连接状态;tool 订阅这份状态,把已连接 server 的工具纳入自己的注册表,并按激活策略决定可见性。** + +接口定义见 `agent/tool/index.ts` 的 `IAgentToolService`(command owner)与 `services/tool/tool.ts` 的 `IToolService`(daemon/SDK 只读 facade),以及 `services/mcp/mcp.ts` 的 `IMcpService`(daemon/SDK 边界 facade,背后是 `mcp/connection-manager.ts` 的 `IMcpConnectionService` 运行时);本文只承载跨 Service 的概念叙述。 + +## 第一性原理 + +### 1. “工具目录”与“连接状态”是两个不同的关注点 + +“agent 这次 loop 能调用哪些 MCP 工具”由两个步骤组成: + +- **维护目录(register)**:给定三类来源(builtin / user / mcp)的工具集合,维护一份统一的工具注册表,并按激活策略算出 `loopTools`。这是**注册表 + 策略**,可以同步、可重放、可单测,不需要知道某个 MCP server 当前是 connected 还是 failed。 +- **维持连接(connect)**:对每个 MCP server,建立 / 关闭 / 重连传输层,发现其工具,跟踪 `pending → connected / failed / needs-auth` 的状态迁移。这是**异步、跨进程、带超时与断连**的运行时态。 + +这两步的生命周期、依赖、失败语义都不同: + +- 工具目录可以在没有 MCP 连接时照常运行(builtin / user 工具不依赖任何 server;mcp 工具随连接状态增删)。 +- 连接状态必须绑定一个真实传输层,有超时 / 断连 / OAuth 等运行时态。 + +因此它们分属两个 domain:tool 拥有目录与激活策略,mcp 拥有连接状态。 + +### 2. 命令 / 查询 / 运行时状态分开(按需要引入) + +按 service-skill 的角色表,本组 domain 实际用到两类: + +| 类型 | 关注 | 归属 | +|---|---|---| +| Command | 工具注册 / 注销、激活策略(`setActiveTools`)、loop 工具解析 | `tool`(`ToolManager` / `IAgentToolService`) | +| Runtime | MCP server 的连接生命周期、per-server 活状态投影、状态变更订阅 | `mcp`(`IMcpConnectionService` 运行时 + `IMcpService` SDK facade) | +| Query | 多 scope 列表 / 搜索 / 计数 | **无**(tool 的 `data()` / `toolInfos()` 是单份注册表快照;mcp 的 `list()` 是 per-session 连接状态投影,不是查询模型) | + +按 [Domain decomposition](../../../../../packages/agent-core/src/services/AGENTS.md) 的规范:“不是每个 domain 都需要五件套,仅当某角色有明确 owner 且契约非空时才引入”。本组 domain 没有多 scope 查询模型,因此不引入 Query Service。 + +### 3. tool 不持有“连接状态”,mcp 不表达“注册 / 激活语义” + +边界保持干净: + +- tool 侧只持有**目录与策略所需的状态**:`builtinTools` / `userTools` / `mcpTools` 三份注册表、`enabledTools` 激活集合、`mcpAccessPatterns`(MCP glob 策略)、`loopTools` 解析结果。它不知道某个 MCP server 当前是 connected 还是 failed、有没有 client、是否超时。 +- mcp 侧只持有**连接状态**:按 server name 关联的 `InternalEntry`(status / client / tools / error)、状态变更 listener、initial-load 进度。它不知道某个工具最终被注册成了什么 qualified name、是否被激活。 + +这条边界是“是否需要拆分 / 合并”的唯一硬指标:只要 tool 不混入连接状态、mcp 不混入注册 / 激活语义,两个 domain 就是清晰的。 + +### 4. “mcp 工具进入注册表”是 tool 对 mcp 状态的反应,不是 mcp 的副作用 + +当某个 MCP server 从 `pending` 翻到 `connected` 时,它的工具需要出现在 agent 的注册表里;翻到 `failed / disabled` 时,需要被移除。这套反应: + +- 是 **tool 的注册表维护**(`registerMcpServer` / `unregisterMcpServer`),由 tool 在收到 mcp 状态变更后执行。 +- **不是** mcp 的副作用——mcp 只负责迁移连接状态并广播 `onStatusChange`,不关心 tool 侧如何消费这份状态。 + +这避免了“工具注册到底在谁手里”的二义性:所有工具(无论来自 builtin / user / mcp)都归 tool 的注册表。 + +### 5. “需要 OAuth” 的合成工具由 tool 侧构造,由 mcp 侧驱动 + +当一个远程 MCP server 翻到 `needs-auth` 时,tool 侧会构造一个合成的 `authenticate` 工具并纳入注册表,让模型可以触发 OAuth 流程。这条链路: + +- **构造 + 注册**合成工具是 tool 的注册表操作(`registerNeedsAuthMcpServer`)。 +- **驱动 OAuth**(提供 `oauthService` / `getRemoteServerUrl` / `reconnect`)是 mcp 的连接状态职责。 + +即:tool 决定“注册表里有没有这个 auth 工具”,mcp 决定“这个 server 为什么需要 auth、以及如何重连”。二者各管一段。 + +### 6. Service 层 facade 暴露运行时,transport 层只做形状适配 + +- `tool`:工具注册 / 激活策略的解析都在 agent 进程内的 manager 完成;command transport(如 `setActiveTools` RPC)只负责把激活集合写到 manager,不承载目录语义;SDK 只读 facade `IToolService.list` 只做 `ToolInfo` → `ToolDescriptor` 的形状翻译(`toProtocolTool`),不重新解释注册表语义。 +- `mcp`:in-process 运行时(`IMcpConnectionService`)与 daemon/SDK 边界(`IMcpService`)之间的形状翻译集中在 `toProtocolMcpServer`(`McpServerInfo` → 协议 `McpServer`);REST / WS 路由不重新解释 mcp 连接语义。 + +## Service 拆分概览 + +| Service | 一句话职责 | 角色 | +|---|---|---| +| `IAgentToolService` | 工具注册表(builtin / user / mcp)+ 激活策略(`setActiveTools` / `loopTools`) | command(registry & policy) | +| `IToolService` | daemon/SDK 只读 facade:列出工具描述符(`ToolInfo` → `ToolDescriptor` 形状翻译) | command-side read(SDK facade) | +| `IMcpService` | daemon/SDK 边界的 MCP server 列表与重连入口(运行时 facade) | runtime(facade) | +| `IMcpConnectionService` | in-process MCP 连接生命周期与 per-server 活状态投影 | runtime(connection state) | + +> 只有这些 Service。不引入 `IAgentToolQueryService` / `IAgentToolRuntimeService`,也不把 tool 与 mcp 合并成一个 Service。 +> `IToolService` 是 `services/tool/` 下的只读 SDK facade,是对 `getTools` 的薄投影 + 形状翻译,不构成独立的 Query Service(单一全局列表,非多 scope 查询模型)。 +> `IMcpService` 是 `services/mcp/` 下的 daemon/SDK 边界 facade,它依赖 in-process 运行时 `IMcpConnectionService`(services → runtime 是允许的方向,见 AGENTS.md)。 +> 共享类型(`ToolInfo` / `UserToolRegistration` / `McpServerEntry` / `McpServerStatus` / `McpServer` 等)见 `agent/tool/types.ts`、`mcp/connection-manager.ts` 与 `services/mcp/mcp.ts`。 + +模式参考: + +- tool 侧对齐 [`command-service.md`](../../reference/patterns/command-service.md):工具注册 / 激活是这份 aggregate 的写入入口;`loopTools` 是“命令驱动的策略解析”,不套用 create/archive/purge 生命周期骨架。 +- mcp 侧对齐 [`runtime-service.md`](../../reference/patterns/runtime-service.md):MCP 连接状态是事件驱动的活状态投影,`connect` / `reconnect` / `remove` 是其生命周期入口,`onStatusChange` / `tool.list.updated` 是其对外事件;它不写入工具注册表(tool 注册表由 tool 自己写)。 + +## 统一的 tool-mcp 激活流 + +一个 MCP server 从“配置中出现”到“其工具进入 loop”只有一条主路径: + +```text +mcp.connectAll(configs) // IMcpConnectionService:启动所有 server + ├─ 每个 server: pending → connected / failed / needs-auth + ├─ 状态迁移时 emit onStatusChange(entry) // mcp 运行时:广播活状态 + │ + └─ tool.attachMcpTools() 订阅 onStatusChange + ├─ connected → registerConnectedMcpServer + │ ├─ mcp.resolved(name) → { client, tools, enabledNames } + │ ├─ registerMcpServer(...) → 写入 mcpTools 注册表(tool 侧) + │ └─ emit tool.list.updated(reason='mcp.connected') + ├─ needs-auth → registerNeedsAuthMcpServer + │ ├─ 读取 mcp.oauthService / getRemoteServerUrl / reconnect + │ ├─ 构造合成 authenticate 工具 → 写入 mcpTools 注册表(tool 侧) + │ └─ emit tool.list.updated(reason='mcp.connected') + ├─ failed → unregisterMcpServer + emit tool.list.updated(reason='mcp.failed') + └─ disabled / pending → unregisterMcpServer + emit tool.list.updated(reason='mcp.disconnected') + +loopTools (getter) // tool:按激活策略解析本次 loop 的工具 + ├─ builtin / user 工具:按 enabledTools 过滤 + └─ mcp 工具:按 mcpAccessPatterns(glob)过滤已注册的 mcpTools +``` + +要点: + +- mcp 是**唯一的连接状态 owner**:所有 `pending/connected/failed/needs-auth` 迁移都由 `McpConnectionManager` 产出,tool 只在 `onStatusChange` 的回调里被动反应。 +- tool 是**唯一的注册表 owner**:所有工具(builtin / user / mcp / 合成 auth)的注册与注销都发生在 tool 侧;mcp 不直接写 tool 的注册表。 +- 激活策略的**副作用落在 tool**:`setActiveTools` / `mcpAccessPatterns` / `loopTools` 都在 tool 侧,mcp 不参与“哪些工具对 loop 可见”的判定。 + +> `mcp.onStatusChange` / `mcp.resolved` 是 tool 消费 mcp 运行时的**调用入口原语**,不是 `IAgentToolService` 暴露的方法。tool 把它们作为反应式维护注册表的实现细节,对外只暴露目录与激活语义。 + +## 关键场景 + +### 场景 A:纯 builtin / user 工具(不涉及 mcp) + +```ts +toolService.setActiveTools(['Read', 'Edit', 'Bash']); +toolService.loopTools; +``` + +内部解析:`setActiveTools` 写入 `enabledTools`;`loopTools` getter 按 `enabledTools` 从 `builtinTools` 解析。无 mcp 交互,无连接状态。 + +### 场景 B:MCP server 连接成功,工具进入注册表 + +```text +mcp.connect(name, config) + → status: pending → connected + → onStatusChange(entry{status:'connected'}) + → tool.registerConnectedMcpServer + → mcp.resolved(name) → { client, tools, enabledNames } + → tool.registerMcpServer(...) → mcpTools.set(qualified, { tool, serverName }) + → emit tool.list.updated(reason='mcp.connected') +``` + +### 场景 C:MCP server 连接失败 / 被禁用,工具移出注册表 + +```text +mcp.connect(name, config) + → status: pending → failed | disabled + → onStatusChange(entry) + → tool.unregisterMcpServer(name) → 从 mcpTools / mcpToolsByServer 删除 + → emit tool.list.updated(reason='mcp.failed' | 'mcp.disconnected') +``` + +### 场景 D:MCP server 需要 OAuth,注册合成 authenticate 工具 + +```text +mcp.connect(name, config) + → status: pending → needs-auth (401, 无静态 token) + → onStatusChange(entry{status:'needs-auth'}) + → tool.registerNeedsAuthMcpServer + → 读取 mcp.oauthService / getRemoteServerUrl / reconnect + → createMcpAuthTool(...) → mcpTools.set(authToolName, ...) + → emit tool.list.updated(reason='mcp.connected') +``` + +### 场景 E:切换激活策略(command) + +```ts +toolService.setActiveTools(['Read', 'mcp__github__*']); +``` + +内部解析:`enabledTools = { Read }`,`mcpAccessPatterns = ['mcp__github__*']`。这是 tool 的命令写入:记录 `tools.set_active_tools`、更新激活集合与 glob 策略。不经过 mcp。 + +### 场景 F:daemon 列出 / 重启 MCP server(runtime facade) + +```ts +mcpService.list(); // IMcpService:per-session 连接状态投影(适配为协议 McpServer) +mcpService.restart(serverId); // IMcpService:触发重连 +``` + +内部解析:`list` 读取 mcp 运行时的 per-server 状态并适配为协议形状;`restart` 经 CoreAPI 调用 `reconnectMcpServer`,由运行时重新进入 `pending → ...` 迁移。这是 mcp 的运行时投影 / 生命周期入口,不是 tool 的查询,也不是查询模型。 + +## 派生交互映射 + +| 用户交互 | 对应 Service 方法 / 入口 | 角色 | +|---|---|---| +| 注册 / 注销 user 工具 | `tool.registerUserTool` / `unregisterUserTool` | command(tool) | +| 设置激活工具(含 mcp glob) | `tool.setActiveTools(names)` | command(tool) | +| 解析本次 loop 可见工具 | `tool.loopTools` | command-side read(tool) | +| 读取工具注册表快照 | `tool.data()` / `tool.toolInfos()` | command-side read(tool) | +| daemon 列出工具描述符 | `toolService.list(sessionId?)`(SDK 只读 facade) | command-side read(tool) | +| 启动 / 连接所有 MCP server | `mcp.connectAll(configs)` / `mcp.connect(name, config)` | runtime(mcp) | +| 重连 / 移除 MCP server | `mcp.reconnect(name)` / `mcp.remove(name)` | runtime(mcp) | +| 列出 MCP server 连接状态 | `mcp.list()` / `mcpService.list()`(SDK facade) | runtime(mcp) | +| 读取已连接 server 的 client + tools | `mcp.resolved(name)` | runtime(mcp) | +| 订阅 MCP 状态变更 | `mcp.onStatusChange(listener)` | runtime(mcp) | +| mcp 工具随连接状态入 / 出注册表 | `tool.attachMcpTools` 订阅 `onStatusChange` → `registerMcpServer` / `unregisterMcpServer` | command(tool,反应式消费 runtime) | +| MCP 工具列表变更事件 | `tool.list.updated`(reason: `mcp.connected` / `mcp.failed` / `mcp.disconnected`) | runtime-derived event(tool 侧转发) | + +## 依赖方向与边界 + +概念分层(不引用任何具体实现层 Service): + +```text +Application Service + IAgentToolService (command / registry & policy — 注册表、激活策略、loopTools 解析) + IToolService (command-side read facade — daemon/SDK 只读 list,ToolInfo → ToolDescriptor) + IMcpService (runtime facade — daemon/SDK 边界的 list / restart) + +Runtime (in-process) + IMcpConnectionService (runtime — 连接生命周期、per-server 活状态投影、onStatusChange) + +Domain / Policy + Tool registry (builtinTools / userTools / mcpTools + enabledTools / mcpAccessPatterns) + MCP connection state (per-server InternalEntry: status / client / tools / error) + +Infrastructure + MCP transports (stdio / http / sse clients) + OAuth orchestrator (needs-auth 流程) + SDK adapters (toProtocolTool / toProtocolMcpServer:runtime → 协议形状翻译) + 事件通道 (onStatusChange / tool.list.updated) +``` + +依赖关系: + +```text +IAgentToolService → Tool registry / policy (目录与激活策略) +IAgentToolService → IMcpConnectionService (仅反应式消费:onStatusChange / resolved / reconnect) +IToolService → CoreAPI.getTools (只读投影 + 形状翻译) +IMcpService → IMcpConnectionService (经 CoreAPI:list / reconnect) +IMcpConnectionService → MCP transports / OAuth (连接生命周期) +``` + +禁止的边界: + +```text +IAgentToolService → MCP transports / client / status 状态机 (tool 不直接持有连接状态) +IMcpConnectionService → Tool registry / 激活策略 (mcp 不解释工具如何注册 / 是否激活) +IAgentToolService ⇄ IMcpConnectionService 的业务方法互相调用 (tool 只单向订阅 mcp;mcp 不回调 tool 的目录方法) +``` + +关键不变量: + +- tool 侧不持有 MCP 连接状态(无 client / 无 per-server status 状态机);mcp 侧不持有工具注册表 / 激活策略。 +- “mcp 工具进入 / 移出注册表”的反应发生在 tool,mcp 只交付状态变更。 +- 合成 `authenticate` 工具的构造在 tool,驱动 OAuth 的能力(`oauthService` / `getRemoteServerUrl` / `reconnect`)在 mcp。 +- runtime→协议形状翻译集中在 `toProtocolMcpServer`,REST / WS 路由不重新解释 mcp 连接语义。 + +## 决策记录 + +- **DR1:tool 与 mcp 是两个独立 domain。** tool 是 command / registry & policy(工具注册表 + 激活策略);mcp 是 runtime(MCP 连接生命周期 + 活状态投影)。二者关注点不同、生命周期不同、失败语义不同,不合并。 +- **DR2:不引入 Query Service。** tool 的 `data()` / `toolInfos()` 是单份注册表快照,mcp 的 `list()` 是 per-session 连接状态投影;两者都不是多 scope 查询模型,因此不开 `IAgentToolQueryService` / `IMcpQueryService`。 +- **DR3:tool 不持有 MCP 连接状态。** 目录与策略所需状态(`builtinTools` / `userTools` / `mcpTools` / `enabledTools` / `mcpAccessPatterns`)归 tool;client / status 状态机 / 超时 / 断连 / OAuth 等运行时态归 mcp。这是“是否需要拆分 / 合并”的唯一硬指标,当前为“边界干净,无需改动”。 +- **DR4:mcp 不表达工具注册 / 激活语义。** mcp 只负责连接生命周期与活状态投影(`connect` / `reconnect` / `remove` / `onStatusChange`),不解释工具如何注册为 qualified name、不决定是否激活。注册 / 激活一律发生在 tool。 +- **DR5:“mcp 工具入注册表”是 tool 对 mcp 状态的反应。** tool 通过 `attachMcpTools` 订阅 `mcp.onStatusChange`,按 `connected / needs-auth / failed / disabled` 分别调用 `registerMcpServer` / `registerNeedsAuthMcpServer` / `unregisterMcpServer`;mcp 只广播状态,不写 tool 的注册表。 +- **DR6:合成 authenticate 工具跨 domain 协作。** tool 负责构造并注册该合成工具(注册表操作);mcp 负责提供 OAuth 驱动能力(`oauthService` / `getRemoteServerUrl` / `reconnect`)。二者各管一段,不互相侵入状态。 +- **DR7:IMcpService 是 services/mcp 下的 runtime facade。** daemon/SDK 边界的 `list` / `restart` 经 CoreAPI 依赖 in-process 运行时 `IMcpConnectionService`(services → runtime 是 AGENTS.md 允许的方向);协议形状翻译集中在 `toProtocolMcpServer`。 +- **DR8:当前代码布局已满足边界,无需迁移。** tool 在 `agent/tool/`(`ToolManager` / `IAgentToolService`,command owner)+ `services/tool/`(`IToolService`,daemon/SDK 只读 facade);mcp 运行时在 `mcp/connection-manager.ts`(`McpConnectionManager` / `IMcpConnectionService`),daemon/SDK facade 在 `services/mcp/`(`IMcpService` / `McpService`)。两个角色已经分离,没有发现重叠或渗漏,因此本次只出概念定稿,不做代码拆分。 diff --git a/.agents/skills/service-skill/explanation/scope-mechanism.md b/.agents/skills/service-skill/explanation/scope-mechanism.md new file mode 100644 index 000000000..049a7fb53 --- /dev/null +++ b/.agents/skills/service-skill/explanation/scope-mechanism.md @@ -0,0 +1,667 @@ +# Scope 机制设计定稿 + +本文是 di-v3 的**横切 scope 机制定稿**:不引用当前代码结构、不预设迁移路径。只描述目标形态、注册 / 构建 / 析构的统一流程、跨 scope 通讯的方向和决策记录。 + +本文是 P1(scope 机制实施)的直接依据。P1 worker 应能按本文逐条落地 `LifecycleScope` / `ScopeRegistry` / `registerScopedService` / `I*Context` / `ScopeBuilder` / manager 模式。 + +权威来源(本文对它们做规范化整理,命名以本文为准): + +- `/Users/moonshot/Projects/kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` +- `/Users/moonshot/Projects/kimi-code-dev-2/plan/2026.06.21-Domain 和 Scope 的划分.md` + +## 目录 + +- [结论](#结论) +- [第一性原理](#第一性原理) +- [拆分概览](#拆分概览) +- [统一流](#统一流) +- [关键场景](#关键场景) +- [派生交互映射](#派生交互映射) +- [依赖方向与边界](#依赖方向与边界) +- [决策记录](#决策记录) + +## 结论 + +目标架构里: + +- **Scope 与 Domain 是两个正交维度**。Domain 回答“这个 Service 在讲什么事”(Kosong / Kaos / Loop / Permission / Agent / …);Scope 回答“这个 Service 实例什么时候创建、什么时候释放”(Core / Session / Agent / Turn / ToolCall)。一个域可以同时拥有多种 scope 的 Service 实例。 +- **每个 scope = 一个子 InstantiationService(child container)**。child 找不到的 service 沿 parent 向上找。Scope 的身份通过一个“context service”注入,service ctor 拿身份,方法签名不再带 id。 +- **只暴露一个注册 API:`registerScopedService(scope, id, ctor, type, options?)`**。注册是 lazy 的(写入 `ScopeRegistry`,不实例化)。`registerScopedService(Core, ...)` 等价于 `registerSingleton(...)`,保留以让 5 个 scope 用法一致。 +- **每个 child scope 在父 scope 里有一个 manager service**。manager 是它所管 scope 的唯一上行事件发布点,通过 `child.accessor.get(...)` 主动订阅 child 内的事件源并 re-emit 成集合视图事件(加 child id);child 永不反向调用 manager 的写方法。 +- **`scope.dispose()` 与 manager `onDid*` 事件同步配对**:`try { await dispose() } finally { fire onDid*; eventBus.publish(...) }`。`onWillDispose` 在数据还在时触发(抓 snapshot),`onDidDispose` 在数据已没时触发(只更新自己状态)。 + +接口定义见 P1 实施产物(`packages/agent-core/src/scope/`),本文只承载跨 scope 的概念叙述。 + +## 第一性原理 + +### 1. 域与 scope 必须分开 + +同一个域的不同 Service 实例可以散在不同 scope。例:`IUsageService` 属于 Kosong 域(讲模型用量),但实例分散在 Core scope(跨 session 聚合)和 Agent scope(每个 agent 一份累计 view)。硬把“域 = 同一 scope”会同时牺牲概念清晰和资源管理。 + +### 2. 一个 scope = 一个 child container + +不用“Core scope 单例 service 内部持 `Map`”。每个 scope 是一个子 InstantiationService,里面的 service ctor 通过 DI 拿到 `IAgentContext` / `ISessionContext` / `ITurnContext` / `IToolCallContext` 当身份。这样: + +- 方法签名不带 id(无 API 噪声); +- 拿错身份由编译器 / 容器解析顺序保证(隔离不失效); +- dispose 由 child container 统一调度(无 boilerplate)。 + +### 3. 身份走 context service,方法不带 id + +每个 scope 暴露一个 `IXxxContext` decorator service。service ctor 通过 `@IAgentContext` 等注入身份;业务方法签名**不**携带 `agentId` / `sessionId` / `turnId` / `toolCallId`(例外见 [依赖方向与边界](#依赖方向与边界) 的不变量 14)。 + +### 4. 注册与构建分离 + +- **注册**(`registerScopedService`):在模块 import 时同步执行 side-effect,只写 `ScopeRegistry`,不实例化。 +- **构建**(`ScopeBuilder.build`):从 registry 读出 descriptor,包成 `SyncDescriptor` 装进 child container,lazy 实例化。 + +所有 builtin / 上层包注册必须发生在**第一次 `ScopeBuilder.build()` 之前**。 + +### 5. 下行生命周期走 DI,上行通知走 manager + +- **下行**:父 scope 调 `childHandle.dispose()`,DI 容器自动调每个 service 的 `.dispose()`。 +- **上行**:child scope 的 manager service(住父 scope)是**唯一**上行事件发布点。child 内 service 发本地 typed event(不带 id),manager 在订阅 callback 里 re-emit 成集合视图事件(带 id)。 + +### 6. ctor 不做 IO + +scope build 链可能创建几十个 service;任何 ctor 阻塞都会拖慢 agent 创建延迟,且测试时 mock 一份 fake collection 不能跑真 IO。ctor 只允许:订阅同 scope / 父 scope 的 typed event(同步 wiring);`accessor.get(...)` 拿依赖但不调耗时方法。重活推到首次方法调用或专门的 `init()`。 + +### 7. 跨 scope 数据不共享 Service + +不允许一个 Service 同时存在于两个 scope。所有“既需要 Core 聚合又需要子 scope 视图”的场景必须拆两个独立 Service(Core 一份 + child scope 一份),由上层同时调两边。 + +## 拆分概览 + +### LifecycleScope 枚举 + +5 个核心 scope。User / Project 是 Core scope 的持久化子集(不是独立 DI scope);Background-task 是 Agent scope 的延迟释放变种;Subagent 是 Agent scope 的另一个实例 + 所有权关系。它们都不是新 scope。 + +| Scope | 含义 | 创建时机 | 释放时机 | 基数(cardinality) | 典型成员 | +|---|---|---|---|---|---| +| `Core` | 进程级 | 进程启动 | 进程退出 | 每进程 1 份 | `IChatProviderService` / `IModelCatalogService` / `IKaosRegistryService` / `IPermissionRegistry` / `ITurnService` 注册中心 / 底层 `IUsageService` / `ILogService` / `IEventService` | +| `Session` | 一次会话 | `session.open` | `Session.close()` | 每 Session 1 份 | `IMcpConnectionManagerService` / `ISessionSkillRegistry` / `ISessionLogService` / `IApprovalService` / `IWorkspaceService` / `IHookEngine`(Session 实例)/ Session-scoped `GrantStore` | +| `Agent` | 一个 agent 实例 | agent 创建 | `Agent.dispose()` | 每 Agent 1 份 | `IContextMemoryService` / `IToolManager` / `IPermissionManager` / `PlanMode` / `GoalMode` / `IBackgroundService` / `ICronService` / `ICompactionService` / `IRecordsService` / `UsageView` / `TurnFlow` | +| `Turn` | 一轮推进 | `turn.start` | turn 结束(success / abort / error) | 每 Turn 1 份 | `ActiveTurn` / `TurnHandle` / `AbortController` / LLM stream / `KosongLLM` / `ProviderRequestAuth` / `ExecutionScope` / `once` / `turn` grant / per-turn `LiveEventBus` | +| `ToolCall` | 单次工具调用 | tool 调用准备 | 单次 tool 调用结束 | 每次 tool call 1 份 | `once` permission grant / `prepareToolExecution` 临时 buffer / 单次 approval prompt 句柄 / 单次 tool 执行的 child `AbortController` | + +枚举定义(normative): + +```ts +export enum LifecycleScope { + Core = 'core', + Session = 'session', + Agent = 'agent', + Turn = 'turn', + ToolCall = 'toolCall', +} +``` + +嵌套关系: + +```text +Core Scope (root IInstantiationService) + ├─ Session Scope A (child of Core) + │ ├─ Agent Scope A1 (child of Session A) + │ │ └─ Turn Scope A1-t1 (child of Agent A1) + │ │ └─ ToolCall sub-scope A1-t1-c1 (child of Turn) + │ └─ Agent Scope A2 + └─ Session Scope B + └─ ... +``` + +DI 解析顺序:child 找不到的 service 沿 parent 向上找,最终到 Core。 + +### ScopeRegistry + +进程级、单例、两张表的嵌套结构: + +```ts +type ServiceId = ServiceIdentifier; // createDecorator 的产物 +type SyncDescriptor = SyncDescriptor0; // ctor + 静态参数 + supportsDelayed + +class ScopeRegistry { + // process-wide;每个 scope 一张 (id -> descriptor) 表 + private readonly tables = new Map, SyncDescriptor>>(); + + register(scope, id, descriptor): void; // 写入(lazy,不实例化) + descriptors(scope): Iterable<{ id; descriptor }>; // ScopeBuilder 读取 + has(scope, id): boolean; +} +``` + +性质: + +- `ScopeRegistry` 是 process-wide 单例(模块级常量)。测试可 reset,但生产进程里只有一份。 +- 写入是 lazy 的:只存 `SyncDescriptor`,**不** `new`。 +- 读取入口只对 `ScopeBuilder` 暴露;业务代码不直接读 registry。 + +### `registerScopedService` API(Pattern 1) + +唯一对外注册 API(仿 VSCode `registerSingleton`): + +```ts +export function registerScopedService( + scope: LifecycleScope, + id: ServiceIdentifier, + ctor: new (...args: never[]) => T, + type: InstantiationType, // 默认 Delayed + options?: { replace?: boolean }, +): void; +``` + +行为契约: + +1. **写入 registry(lazy)**:`registry.register(scope, id, new SyncDescriptor(ctor, [], /* supportsDelayed */ true))`。不实例化。 +2. **Core 别名**:`registerScopedService(LifecycleScope.Core, id, ctor, type)` 等价于 `registerSingleton(id, ctor, type)`,内部直接走现有 `registerSingleton`。保留以让 5 个 scope 用法一致。 +3. **重复注册 last-write-wins + warn**:同 `(scope, id)` 重复注册时,默认打 `warn`(“duplicate registration for in , last write wins”),但仍覆盖。 +4. **`{ replace: true }` 静默覆盖**:显式声明“我知道我在替换”,registry **不**打 warn。用于 plugin 覆盖 builtin。 +5. **注册时机**:必须在第一次 `ScopeBuilder.build()` 之前。之后注册 `warn` + 忽略(避免构建到一半的 scope 拿到不一致的 descriptor)。 + +典型用法: + +```ts +// agent-core/goal/goalService.ts(builtin) +registerScopedService( + LifecycleScope.Agent, + IGoalService, + GoalService, + InstantiationType.Delayed, +); + +// plugin-X 覆盖 builtin(load order 晚于 builtin) +registerScopedService( + LifecycleScope.Agent, + IGoalService, + EnhancedGoalService, + InstantiationType.Delayed, + { replace: true }, +); +``` + +未来扩展(本版不实现,builder pipeline 已预留):Pattern 2 `registerScopeBuildHook(scope, hook)` / Pattern 3 pre-build interceptor。见 [统一流](#统一流) step 3 / 4。 + +### Scope identity contexts + +每个 scope 一个 `IXxxContext` decorator service。normative 字段统一为 `id` / `parentId` / `abortSignal` / `executionScope`(来源文档用 `sessionId` / `agentId` / `signal` 等按 scope 命名的字段,本文归一化;见 DR10)。 + +```ts +// createDecorator 产物;service ctor 通过 @ISessionContext 等注入 +interface ISessionContext { + readonly id: string; // sessionId + readonly parentId: undefined; // Session 的父是 Core,无业务父 id + readonly abortSignal: AbortSignal; // 等价于 session scope 的 onWillDispose + readonly executionScope: IExecutionScope; +} + +interface IAgentContext { + readonly id: string; // agentId + readonly parentId: string; // sessionId + readonly abortSignal: AbortSignal; + readonly executionScope: IExecutionScope; +} + +interface ITurnContext { + readonly id: string; // turnId + readonly parentId: string; // agentId + readonly abortSignal: AbortSignal; // ESC / abort 触发的 cancel + readonly executionScope: IExecutionScope; +} + +interface IToolCallContext { + readonly id: string; // toolCallId + readonly parentId: string; // turnId + readonly abortSignal: AbortSignal; + readonly executionScope: IExecutionScope; +} +``` + +消费侧: + +```ts +class GoalService implements IGoalService, IDisposable { + constructor( + @IAgentContext private readonly ctx: IAgentContext, + @IRecordsService private readonly records: IRecordsService, + ) {} + + async create(spec: GoalCreateSpec, actor: GoalActor) { + // 不需要 agentId 参数;this.ctx.id 是隐式的 + await this.records.append({ kind: 'goal-created', agentId: this.ctx.id, /* ... */ }); + } + + dispose() { /* 由 child container 在析构时自动调用 */ } +} +``` + +### Scope handle + +每个 builder 返回的 handle: + +```ts +interface IScopeHandle extends IDisposable { + readonly id: string; // 本 scope 的 identity id(== context.id) + readonly scope: LifecycleScope; + readonly accessor: IServiceAccessor; // child container 的 accessor + readonly onWillDispose: Event<{ reason?: string }>; // 数据还在 + readonly onDidDispose: Event<{ reason?: string }>; // 数据已没 + dispose(reason?: string): Promise; +} +``` + +## 统一流 + +### ScopeBuilder 4 步 pipeline + +每个 scope 一个 builder(`SessionScopeBuilder` / `AgentScopeBuilder` / `TurnScopeBuilder` / `ToolCallScopeBuilder`),同模式: + +```ts +class AgentScopeBuilder { + build(parent: IInstantiationService, identity: AgentScopeIdentity): IAgentScopeHandle { + const collection = new ServiceCollection(); + + // ① inject scope identity context + collection.set(IAgentContext, identity.context); + + // ② install Pattern-1 statically registered services as SyncDescriptors + for (const { id, descriptor } of registry.descriptors(LifecycleScope.Agent)) { + collection.set(id, descriptor); + } + + // ③ reserved build hook(Pattern 2,本版未启用) + // for (const hook of buildHooks.get(LifecycleScope.Agent)) hook(collection, identity); + + // ④ reserved post-build interceptor(本版未启用) + // for (const interceptor of postBuildInterceptors.get(LifecycleScope.Agent)) interceptor(collection, identity); + + const child = parent.createChild(collection); + return new AgentScopeHandle(child, identity); + } +} +``` + +性质: + +- step ② 的 descriptor 全部走 `SyncDescriptor` + lazy:不用的 service 不占内存;首次 `accessor.get(IXxx)` 时才 `new`。 +- step ③ / ④ 是预留位,本版不实现。未来加 Pattern 2 / 3 时只需在这两步迭代 hooks / interceptors,已注册的 Pattern 1 调用方零修改。 +- `parent.createChild(collection)` 创建 child container;返回的 handle 包 `accessor` + 两个 dispose 事件。 + +### SyncDescriptor + lazy 实例化 + +```ts +collection.set(IGoalService, new SyncDescriptor(GoalService, [], /* supportsDelayed */ true)); +``` + +### ctor 约束(强约束) + +- **禁止**:ctor 做 IO(文件读取、网络、shell exec)。 +- **允许**:ctor 订阅同 scope / 父 scope 的 typed event(同步 wiring)。 +- **允许**:ctor `accessor.get(...)` 拿依赖,但**不**调对方的耗时方法。 +- 重活推到首次方法调用或专门的 `init()`。 + +理由:scope build 链可能创建几十个 service;任何 ctor 阻塞都会拖慢 agent 创建延迟,且测试时 mock 一份 fake collection 不能跑真 IO。 + +### `dispose()` 流 + manager `onDid*` 配对 + +`IScopeHandle.dispose()` 内部: + +```ts +async dispose(reason?: string): Promise { + if (this.disposed) return; + this.disposed = true; + + // [1] fire onWillDispose,await 全部 listener(数据还在) + await fireAsync(this.onWillDisposeEmitter, { reason }); + + // [2] 析构 child container(DI 自动调每个 service 的 .dispose()) + this.child.dispose(); + + // [3] fire onDidDispose(同步;数据已没) + this.onDidDisposeEmitter.fire({ reason }); +} +``` + +manager 调 dispose 时必须配对 onDid\* + wire publish(强约束): + +```ts +class TurnService { + async abort(turnId: string, reason: string): Promise { + const handle = this.active.get(turnId); + if (!handle) return; + try { + await handle.scope.dispose(reason); + } finally { + this.active.delete(turnId); + this.onDidCancelTurn.fire({ turnId, reason }); + this.eventBus.publish({ kind: 'turn.cancelled', turnId, agentId: this.ctx.parentId, reason }); + } + } +} +``` + +两个 dispose 事件的语义分: + +| 事件 | 时机 | 允许做什么 | +|---|---|---| +| `onWillDispose` | scope 即将析构,**数据还在** | 抓 snapshot(final usage / transcript flush / final goal state)。manager `await` 全部 listener 后才继续。 | +| `onDidDispose` | scope 已析构,**数据已没** | subscriber 只更新自己状态;**不允许**访问 child 内部 service。 | + +### Manager 上行流(主动 attach 订阅) + +manager service 住父 scope,是它所管 child scope 的唯一上行发布点。它通过 `child.accessor.get(...)` 主动订阅 child 内的事件源,再 re-emit 成集合视图事件(加 child id): + +```ts +class AgentLifecycleService { + private readonly active = new Map(); + + async create(spec: AgentCreateSpec): Promise { + const agentCtx = buildAgentContext(spec, this.sessionCtx); + const handle = AgentScopeBuilder.build(this.sessionScope, agentCtx); + + // 关键:拿 child scope 的 per-agent event source,一次性挂订阅 + const childSubs = new DisposableStore(); + const agentStatus = handle.accessor.get(IAgentStatus); + childSubs.add(agentStatus.onDidChange(({ previous, current }) => { + // re-emit 成集合视图事件(manager 是唯一加 agentId 的“组合”点) + this.onDidChangeAgentStatus.fire({ agentId: agentCtx.id, previous, current }); + this.eventBus.publish({ + kind: 'agent.status-changed', + agentId: agentCtx.id, + sessionId: this.sessionCtx.id, + status: current, + }); + })); + + this.active.set(agentCtx.id, { handle, childSubs }); + this.onDidCreateAgent.fire({ agentId: agentCtx.id, type: spec.type }); + return handle; + } + + async dispose(agentId: string, reason?: string): Promise { + const entry = this.active.get(agentId); + if (!entry) return; + try { + this.onWillDisposeAgent.fire({ agentId, reason }); + await entry.handle.dispose(reason); + } finally { + entry.childSubs.dispose(); // 一并解订阅,无 dangling + this.active.delete(agentId); + this.onDidDisposeAgent.fire({ agentId, reason }); + } + } +} +``` + +per-scope event source 命名(child 内专门给 manager 订阅): + +| Scope | per-scope event source(住该 scope) | manager(住父 scope) | +|---|---|---| +| Session | (session 状态多维,无单一 self-view;manager 直接订阅 agents map / approval / question 等) | `ISessionLifecycleService` | +| Agent | `IAgentStatus`(derived from `ITurnService`) | `IAgentLifecycleService` | +| Turn | `ITurnHandle.signal`(abort 即 dispose)+ `ITurnService` 自身的 onDid\* | `ITurnService` | +| ToolCall | `IToolCallContext.signal` | `IToolCallScheduler` | + +Turn / ToolCall 生命周期短,直接用 manager 自己的 onDid\* + scope handle 的 signal 就够,不需要再起 `ITurnStatus`。Agent scope 引入 `IAgentStatus` 是因为 status 由多源(turn 活跃度)派生,且 manager 在父 scope,必须有个 child 侧的发源给 manager 订阅。 + +per-scope event source **优先 derived**:能从已有事件派生的状态(如 `IAgentStatus` 从 `ITurnService.activeCount` 派生)不允许暴露 `setStatus` 写 API。 + +```ts +class AgentStatusService implements IAgentStatus { + private _status: AgentStatus = 'idle'; + private readonly _onDidChange = new Emitter<{ previous: AgentStatus; current: AgentStatus }>(); + readonly onDidChange = this._onDidChange.event; + + constructor( + @IAgentContext private readonly ctx: IAgentContext, + @ITurnService private readonly turns: ITurnService, + ) { + const recompute = () => { + const next: AgentStatus = this.turns.activeCount() > 0 ? 'running' : 'idle'; + if (this._status === next) return; + const previous = this._status; + this._status = next; + this._onDidChange.fire({ previous, current: next }); + }; + this._disposables.add(this.turns.onDidStartTurn(recompute)); + this._disposables.add(this.turns.onDidFinishTurn(recompute)); + this._disposables.add(this.turns.onDidCancelTurn(recompute)); + } + + get status() { return this._status; } + dispose() { this._disposables.dispose(); } +} +``` + +## 关键场景 + +### 场景 A:创建一个 Agent scope + +```ts +const handle = agentLifecycleService.create(spec); +``` + +内部解析: + +```text +AgentLifecycleService.create (Session scope) + ├─ buildAgentContext(spec, sessionCtx) // id / parentId / abortSignal / executionScope + ├─ AgentScopeBuilder.build(sessionScope, ctx) + │ ├─ collection.set(IAgentContext, ctx) // ① identity + │ ├─ for desc in registry.descriptors(Agent) // ② Pattern-1 static + │ ├─ (③ build hook 预留) + │ ├─ (④ post-build interceptor 预留) + │ └─ parent.createChild(collection) // child container + ├─ handle.accessor.get(IAgentStatus) // manager 主动 attach + ├─ active.set(agentId, { handle, childSubs }) + └─ onDidCreateAgent.fire({ agentId, type }) +``` + +### 场景 B:dispose 一个 Agent scope + +```ts +await agentLifecycleService.dispose(agentId, reason); +``` + +内部解析: + +```text +AgentLifecycleService.dispose + ├─ onWillDisposeAgent.fire({ agentId, reason }) // manager 旁路 + ├─ handle.dispose(reason) + │ ├─ onWillDispose.fireAsync() // 数据还在:抓 snapshot / final flush + │ ├─ child.dispose() // DI 析构每个 service .dispose() + │ └─ onDidDispose.fire() // 数据已没:只更新自己 state + └─ finally + ├─ childSubs.dispose() // 解订阅,无 dangling + ├─ active.delete(agentId) + └─ onDidDisposeAgent.fire({ agentId, reason }) +``` + +### 场景 C:注册 builtin service + +```ts +registerScopedService(LifecycleScope.Agent, IGoalService, GoalService, InstantiationType.Delayed); +``` + +行为:模块 import 时同步执行 side-effect,写 `ScopeRegistry.tables[Agent][IGoalService]` = `SyncDescriptor(GoalService)`。不实例化。第一次 `AgentScopeBuilder.build` 之前必须完成。 + +### 场景 D:plugin 覆盖 builtin + +```ts +// builtin load order 1 +registerScopedService(LifecycleScope.Agent, IGoalService, GoalService, Delayed); +// plugin-X load order 2 +registerScopedService(LifecycleScope.Agent, IGoalService, EnhancedGoalService, Delayed, { replace: true }); +``` + +行为:第二次同 `(Agent, IGoalService)` 注册覆盖前者;因带 `{ replace: true }`,registry 静默覆盖、不打 warn。多 plugin 互相覆盖同一 service 时最后 import 的胜出,结果可能因 bundler 不同而变,不推荐。 + +### 场景 E:Core service 订阅子 scope 事件 + +Core scope 容器看不见 Session / Agent / Turn scope 的 service,所以 Core service 不能直接 inject child-scope manager。三条合法路径: + +| 需求 | 推荐 | +|---|---| +| 上 wire 推到 TUI / daemon / 跨进程 | Pattern A:订阅 `IEventService`(Core scope),按 `event.kind` 过滤;丢强类型 | +| Core service 聚合 / 镜像 child scope 事件 | Pattern B:写 Core scope 的 typed aggregator + `InstantiationType.Eager`;保强类型,可在 payload 补 sessionId | +| 等某个特定 turn / agent 结束 | Pattern C:从已知 scope handle 拿 manager + `filter` by id | + +aggregator 示例(Pattern B): + +```ts +class AgentLifecycleAggregator implements IAgentLifecycleAggregator { + private readonly _onDidCreateAgent = new Emitter(); + readonly onDidCreateAgent = this._onDidCreateAgent.event; + + constructor(@ISessionLifecycleService private readonly sessions: ISessionLifecycleService) { + for (const h of this.sessions.list()) this._attach(h); // 启动时已存在的 session + this.sessions.onDidCreate(h => this._attach(h)); // 后续新创建的 session + } + + private _attach(handle: ISessionScopeHandle) { + const agentSvc = handle.accessor.get(IAgentLifecycleService); + const sessionId = handle.accessor.get(ISessionContext).id; + const subs = new DisposableStore(); + subs.add(agentSvc.onDidCreateAgent(e => this._onDidCreateAgent.fire({ ...e, sessionId }))); + handle.onDidDispose(() => subs.dispose()); // session 关闭自动解订阅 + } +} + +registerSingleton(IAgentLifecycleAggregator, AgentLifecycleAggregator, InstantiationType.Eager); +``` + +aggregator 默认 `Eager`:核心价值是“全程不漏”,ctor 必须真的轻量(只做 wire 订阅,零 IO)。`Delayed` 会错过首次 `.get()` 之前的所有事件。 + +Pattern A 与 Pattern B 不冲突:它们订阅同一份事件源(manager typed event)。manager 内部 fire 顺序保证 typed event 先于 `eventBus.publish`,所以同进程 Pattern B 订阅者永远先于 Pattern A 的 wire 订阅者看到事件,状态不滞后。 + +反模式(禁止): + +```ts +class MyCoreService { + constructor(@IAgentLifecycleService private readonly agents: IAgentLifecycleService) {} + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 编译/解析失败:Core 看不见 Session scope service +} +``` + +## 派生交互映射 + +| 用户/上层交互 | 对应机制 | +|---|---| +| 进程启动 | Core scope build;`registerSingleton` / `registerScopedService(Core, ...)` 生效 | +| 打开 session | `ISessionLifecycleService.create` → `SessionScopeBuilder.build` → `ISessionContext` 注入 | +| 创建 agent | `IAgentLifecycleService.create` → `AgentScopeBuilder.build` → `IAgentContext` 注入 + manager attach `IAgentStatus` | +| 开始一轮 turn | `ITurnService.start` → `TurnScopeBuilder.build` → `ITurnContext` 注入 | +| 执行一次工具调用 | `IToolCallScheduler` → `ToolCallScopeBuilder.build` → `IToolCallContext` 注入 | +| 工具调用结束 | `toolCallScope.dispose()` → `onDidExecuteTool` fire | +| turn 结束 / 取消 | `turnScope.dispose()` → `ITurnService.onDidFinishTurn` / `onDidCancelTurn` fire + `eventBus.publish` | +| agent dispose | `IAgentLifecycleService.dispose` → `handle.dispose` → `onDidDisposeAgent` fire | +| session close | `ISessionLifecycleService.close` → 串行 dispose agents → `sessionScope.dispose` → `onDidClose` fire | +| 注册 builtin service | `registerScopedService(scope, id, ctor, type)` 写 `ScopeRegistry` | +| plugin 覆盖 service | `registerScopedService(scope, id, ctor, type, { replace: true })` 静默覆盖 | +| Core 聚合 child 事件 | Core scope typed aggregator(Eager)通过 `handle.accessor.get(manager)` attach | +| 列所有 active agent | `IAgentLifecycleService.list()`(manager 持 `Map`) | + +## 依赖方向与边界 + +### 方向总览 + +```text +Core Scope + ILogService / IEventService / IChatProviderService / ... (process-wide) + ISessionLifecycleService ── manager of Session ──► Session Scope + IAgentLifecycleService ── manager of Agent ──► Agent Scope + ITurnService ── manager of Turn ──► Turn Scope + IToolCallScheduler ── manager of ToolCall ──► ToolCall Scope +``` + +下行:DI 沿 parent 链解析;父 scope 调 `childHandle.dispose()` 触发生命周期。 + +上行:child scope 发本地 typed event(不带 id);父 scope 的 manager 通过 `child.accessor.get(...)` 订阅并 re-emit 成集合视图事件(带 id);`IEventService.publish(...)` 是 wire 的尾节点。 + +### 禁止的写法 + +```text +// 禁止 1:Core scope service 内部持 Map +class GoalService { private readonly state = new Map(); } // 应该用 child container + +// 禁止 2:业务方法带 scope id(应通过 ctor 注入 IXxxContext) +goalService.create(agentId, spec); // 应该 goalService.create(spec) + +// 禁止 3:child 反向调用 manager 写方法 +class TurnService { + constructor(@IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService) {} + async start() { this.lifecycle.reportStatusChange(this.ctx.parentId, 'running'); } // 反向写穿透 +} + +// 禁止 4:Core 直接 inject child-scope service +class MyCoreService { + constructor(@IAgentLifecycleService private readonly agents: IAgentLifecycleService) {} // 解析失败 +} + +// 禁止 5:ctor 做 IO +constructor() { this.config = fs.readFileSync(...); } // 重活推到 init()/首次调用 + +// 禁止 6:onDidDispose listener 访问 child 内部 service(数据已没) +handle.onDidDispose(() => handle.accessor.get(IGoalService)...); // 句柄已 dispose +``` + +### `Map` 是唯一允许的 map + +Core scope service **禁止** 持 `Map`。但 parent scope 的 manager **允许** 持 `Map`——它的 key 是 child scope identity,不是业务状态切片: + +```ts +class AgentLifecycleService { + private readonly active = new Map(); +} +``` + +### 跨 scope 查询 + +| 查询类型 | 解 | +|---|---| +| 当前 scope 内 service 调用 | DI 注入 | +| 父 scope service 调用 | DI 注入(child 沿父链找) | +| 子 scope 查询(“列所有 active agent”) | Parent scope 的 manager 持 `Map` | +| 真·跨 scope 聚合(“全局 usage”) | 拆两个 Service:Core scope 一份 `IUsageHistoryService` + Session/Agent scope 一份 `ISessionUsageView` / `UsageView`;上层同时调两边 | + +### 不变量总表(hard rules) + +1. **DI scope = child InstantiationService**——沿 VSCode platform-services 模式。 +2. **scope identity 走 context service**——`IAgentContext` / `ISessionContext` / `ITurnContext` / `IToolCallContext`;方法不带 id 参数。 +3. **service 全部 SyncDescriptor + lazy**——ctor 不做 IO。 +4. **scope handle `dispose()` 与 manager `onDid*` event 同步配对**——`try { await dispose() } finally { fire onDid*; eventBus.publish(...) }`。 +5. **manager service 住父 scope**——上行事件单一发布点。 +6. **typed event 不直接到 wire**——`IEventService.publish` 是 wire 尾节点;中间 typed event 只跨同进程 service。 +7. **subscriber lifetime-scoped**——订阅 disposable 挂在订阅方 scope 上,自动随之析构,无 dangling。 +8. **跨 scope 数据不共享 Service**——拆两个 Service(Core + child scope)。 +9. **`registerScopedService` 是唯一注册 API**——Pattern 1 only;重复注册 last-write-wins + warn;显式 replace 用 `{ replace: true }` 静默覆盖。 +10. **Builder pipeline 预留 step 3 / 4**——未来 Pattern 2 / 3 增量加,不破坏 Pattern 1。 +11. **Core service 订阅子 scope 事件走 aggregator**——禁止 Core inject child-scope service;推荐 typed aggregator + `InstantiationType.Eager`。 +12. **manager 通过 `child.accessor.get(...)` 主动 attach 订阅**——child scope 内 service **永远不反向调** manager 的写方法;child 发本地事件(不带 id),manager 在订阅 callback 里 re-emit 成集合视图事件(带 id)。 +13. **per-scope event source 优先 derived**——能从已有事件派生的状态(如 `IAgentStatus` 从 `ITurnService.activeCount` 派生)不允许暴露 `setStatus` 写 API。 +14. **方法接收 id 参数当且仅当**:(a) 它是 manager service,参数指向它管的下层 scope(`lifecycle.create` / `dispose`);或 (b) 它住父 scope,参数指向本 scope 内的 children 之一(如 session 内多 agent 路由);或 (c) 它是 wire RPC 契约(跨进程必须显式携带);或 (d) 它是无状态的 Core scope facade(无 context 注入可拿)。其它情况下方法**不应**带 scope id——通过 ctor 注入 `IXxxContext` 拿身份。 + +## 决策记录 + +- **DR1:用 child container 而不是 `Map`。** 后者在三处败:(a) API 噪声(处处带 id);(b) 隔离失效(拿错 id 编译器不查,靠 runtime assertion 兜底);(c) dispose boilerplate(每个 service 自己 `dispose(agentId)` + listen `onWillDisposeAgent`)。child container 让 ctor 注入 context、方法无 id、dispose 统一调度。 + +- **DR2:manager service 住父 scope。** 下行生命周期已由 DI 解决;上行通知需要一个单一发布点。manager 在父 scope 能 (a) 创建 / 持 child scope handle(`Map`);(b) 通过 `child.accessor.get(...)` 主动 attach child 事件源;(c) 在 child dispose 完成的 `finally` 路径里 emit onDid\*。让 manager 住父 scope 而不是 child scope,避免 child dispose 后事件无人发。 + +- **DR3:child 永不反向调 manager 写方法。** 在 manager 上暴露写 API 会让任何能拿到 manager 的代码改任意 child 状态,破坏 scope 隔离;每次调用还要穿 id,回退到 DR1 想根除的 id 透传模式;状态写得分散,多处忘 set 就漏。改为 child 发本地 typed event + manager 主动 attach re-emit。 + +- **DR4:方法签名不带 scope id。** 身份通过 ctor 注入 `IXxxContext` 拿。例外见不变量 14:manager 指向下层 scope 的 create/dispose、父 scope 内多 child 路由、wire RPC、无状态 Core facade。 + +- **DR5:`registerScopedService` 是唯一注册 API(Pattern 1 only)。** 仿 VSCode `registerSingleton`。`registerScopedService(Core, ...)` 是其别名,保留以让 5 个 scope 用法一致。Pattern 2(build hook)/ Pattern 3(interceptor)本版不实现,但 builder pipeline 的 step 3 / 4 已预留,未来增量加不破坏 Pattern 1 调用方。 + +- **DR6:重复注册 last-write-wins + warn;`{ replace: true }` 静默。** 默认重复注册打 warn(让人知道发生了覆盖)但仍覆盖(仿 VSCode)。plugin 覆盖 builtin 用 `{ replace: true }` 显式声明意图,registry 静默。多 plugin 互相覆盖同一 service 不推荐(结果可能因 bundler 不同而变)。 + +- **DR7:注册必须在第一次 `ScopeBuilder.build()` 之前。** 注册是 import 期 side-effect。build 之后注册 warn + 忽略,避免构建到一半的 scope 拿到不一致的 descriptor。上层包的 entry module 在程序启动期被 import 即可保证。 + +- **DR8:ctor 不做 IO。** scope build 链可能创建几十个 service;任何 ctor 阻塞都会拖慢 agent 创建延迟,且测试时 mock fake collection 不能跑真 IO。ctor 只允许同步 wiring(订阅 typed event、`accessor.get` 拿依赖不调耗时方法);重活推到首次调用或 `init()`。 + +- **DR9:`onWillDispose` 与 `onDidDispose` 语义分开。** `onWillDispose` 在数据还在时触发(manager `await` 全部 listener,抓 snapshot / final flush);`onDidDispose` 在数据已没时触发(subscriber 只更新自己状态,不允许访问 child 内部 service)。IDisposable 释放本 service 自己的资源(同步 / 简单 await,DI 自动调);manager event 承载跨 service 协同动作(多 listener 并发 await,manager 显式 fire)。两者协同,不互替。 + +- **DR10:context 字段归一化为 `id` / `parentId` / `abortSignal` / `executionScope`。** 来源文档(`2026.06.22-Scope-Mechanism.md` §3)用 `sessionId` / `agentId` / `turnId` / `toolCallId` + `signal` 按 scope 命名。本文归一化为 `id` / `parentId`,因为:(a) 跨 scope 处理代码(manager、aggregator、builder)可以泛型化;(b) `parentId` 显式表达嵌套关系;(c) 减少 per-scope 命名发散。`signal` 改名 `abortSignal` 以与 Web `AbortSignal` 术语一致。`executionScope` 是 Kaos 域的执行环境快照(cwd / env),Turn / ToolCall 必需,Session / Agent 携带以向下派生。 + +- **DR11:5 个 scope(含 ToolCall),不再新增。** 按“独立创建/释放时机 + 多实例并存 + 释放时需级联清理”三条公式,Core / Session / Agent / Turn / ToolCall 是当前需要的全部 scope。User / Project 是 Core 的持久化子集;Background-task 是 Agent 的延迟释放变种;Subagent 是 Agent 的另一实例 + 所有权关系。新增 scope 需三条公式都满足。 + +- **DR12:跨 scope 数据不共享 Service。** 一个 Service 不能同时存在于两个 scope。“既需要 Core 聚合又需要子 scope 视图”拆两个独立 Service(如 `IUsageHistoryService` Core + `UsageView` Agent),由上层同时调两边。这让 dispose 语义清晰(dispose view 不释放底层)且避免 scope 间状态泄漏。 diff --git a/.agents/skills/service-skill/explanation/service-design-principles.md b/.agents/skills/service-skill/explanation/service-design-principles.md new file mode 100644 index 000000000..bffac4f7a --- /dev/null +++ b/.agents/skills/service-skill/explanation/service-design-principles.md @@ -0,0 +1,101 @@ +# Service 设计原则 + +本 skill 的所有讨论从**业务和数据模型本质**出发,不读现有代码、不引用现有 Service 名字、不依赖现有目录结构。当前实现只是众多可能落点之一;设计的对象是概念,不是文件。 + +## 为什么从第一性原理开始 + +Service 不是函数集合,而是业务边界的显式表达。设计 Service 前先回答: + +- 业务里的实体 / aggregate 是什么? +- 谁拥有这个 aggregate 的生命周期? +- 哪些操作改变状态,哪些只是读取? +- 哪些数据是持久化真相,哪些是派生索引,哪些是运行时投影? +- 哪些标识必须在 Service 层解析? + +只有这些问题在概念层得到回答,接口才能稳定。 + +## 思考风格 + +- **结论先行。** 先说目标形态,再展开细节。 +- **一个 aggregate 一个 owner。** 不把同一个 aggregate 的生命周期切给多个 Service。 +- **命令 / 查询 / 运行时三类分开。** 一个 Service 不应同时承担三类职责。 +- **统一查询。** 多个 scope 下的 list 是同一个查询的不同入参,不是多份实现。 +- **标识解析在 Service 层。** 业务标识(如 workspace_id → workDir)由 Service 解析,transport 层只做参数映射。 +- **真相、索引、运行时分开。** 持久化真相(repository)、查询读模型(index)、活状态(runtime projection)不能混合存储。 +- **删除语义必须显式。** 区分 archive、restore、purge。 +- **依赖向下。** Application Service → Repository / Index → Infrastructure。下层不依赖上层,应用层之间不循环依赖。 +- **避免隐式级联。** 跨 aggregate 的删除或状态变更必须显式命名,不能藏在普通 delete 里。 + +## Command / Query / Runtime 分离 + +| 类型 | 关注 | 不应承担 | +|---|---|---| +| Command Service | create / update / archive / restore / purge / fork 等生命周期 | 复杂 list、search、count | +| Query Service | list / search / filter / count | 修改 aggregate 状态 | +| Runtime Service | status / active turn / approval 等活状态 | 持久化元数据查询 | +| Repository / Index | 持久化与读模型 | 业务编排 | + +关键含义: + +- 普通 list 不触发 resume agent、扫描全部 record、或读取每个 runtime 状态。 +- 状态增强只对“当前页”或“用户明确打开的对象”生效。 +- list 返回的是 Summary(轻量字段子集),不是 aggregate 全貌。 + +## 统一查询,而不是重复入口 + +一个业务既需要“全局 list”,也需要“某 scope 下 list”时,**只有一个 query**: + +```ts +list({ scope: { kind: "global" } }); +list({ scope: { kind: "parent"; id } }); +list({ scope: { kind: "children"; parentId } }); +``` + +便捷方法(listByX、listGlobal、listChildren)只能是 `list()` 的一行薄封装。过滤、排序、分页、归档可见性逻辑只能有一份实现。 + +## 标识解析放在 Service 层 + +业务标识由 Service 解析和校验,不让 transport(REST、WebSocket、CLI、TUI)承载业务规则: + +```text +workspace_id → workDir +repo_id → workspace roots +parent_id → children scope +``` + +这样所有调用方共享同一套业务规则。 + +## 删除语义 + +不要用同一个 `delete()` 隐藏多种语义。默认拆分为: + +- `archive`:默认删除,可恢复,默认列表不可见。 +- `restore`:取消归档。 +- `purge`:硬删除,需要显式策略处理运行中对象、关联数据、回收资源。 + +跨 aggregate 的删除必须命名(如 `purgeWorkspace(id, { deleteSessions: true })`),不能藏在 `delete(id)` 里。 + +## 依赖方向 + +```text +Application Service + ↓ +Domain / Repository / Index + ↓ +Infrastructure +``` + +Application Service 之间不互相依赖业务编排能力。如果两个 Service 都需要同一份计数或索引,把它下沉到 Repository / Index,而不是让一个 Application Service 调用另一个。 + +## Red Flags + +遇到以下情况停下来重做设计: + +- 业务规则落在 route / transport 层。 +- 两个 Service 实现了相同的 list 逻辑。 +- 一个超大 Service 同时承担 CRUD、list / search / count、runtime status。 +- list API 触发昂贵的运行时加载(resume agent、扫描全部 record)。 +- delete 操作隐藏跨 aggregate 级联。 +- 同一份过滤 / 排序 / 归档可见性逻辑在 API、Service、Store 层各写一遍。 +- 两个 Application Service 互相调用对方的业务方法。 +- aggregate 的真相字段(如 workDir)和派生字段(如 workspace_id)边界不清。 diff --git a/.agents/skills/service-skill/how-to/archive-service-design.md b/.agents/skills/service-skill/how-to/archive-service-design.md new file mode 100644 index 000000000..8f2610bdf --- /dev/null +++ b/.agents/skills/service-skill/how-to/archive-service-design.md @@ -0,0 +1,102 @@ +# 如何归档定稿的 Service 设计 + +只有用户确认设计定稿或明确要求保存时,才归档到本 skill。草稿、候选、未决问题一律不进入 `reference/`。 + +## 1. 选择 Diátaxis 位置 + +| 内容 | 位置 | +|---|---| +| 单个 Service 的职责、接口、依赖、约束(属于某个具体 domain) | `reference/domains//.md` | +| 该 domain 共享的类型契约 | `reference/domains//types.md` | +| 通用、可被多个 domain 复用的 Service 模式(Command / Query / Runtime / Repository / Index 等) | `reference/patterns/.md` | +| 该 domain 的跨 Service 架构叙述、决策记录 | `explanation/domains/.md` | +| 通用设计原则、思考风格、Red Flags | `explanation/service-design-principles.md` | +| 如何完成某类设计 / 审视 / 归档任务 | `how-to/.md` | +| 端到端、从业务需求推导到 Service 拆分的演练 | `tutorial/.md` | + +判定要点: + +- 这是“一类模式”还是“一个领域的 Service”?模式进 `patterns/`,领域进 `domains/`。 +- 不要在 `patterns/` 里出现某个具体 domain 的实体名。 +- 不要在 `domains//` 里复述通用原则——链回 `explanation/service-design-principles.md`。 + +## 2. 写每个 domain Service reference + +每个 `reference/domains//.md` 至少包含: + +```text +# + +## 职责 +## 拥有 +## 不拥有 +## 接口 +## 依赖 +## 关键约束 +## 决策记录 +``` + +要求: + +- 接口用 TypeScript-like pseudocode。 +- 该 Service 独有的输入 / 输出类型(如 `XxxCreate`、`XxxUpdate`)就近内联在接口下方;跨 Service 共享的类型(如 `XxxStatus`、`PageResponse`、`XxxListQuery`)放在 `types.md` 并链接。 +- 决策记录是非显然选择的依据;如果没有任何非显然选择,写一句话说明“无非显然选择”。 + +## 3. 写 domain 叙述 + +`explanation/domains/.md` 顺序建议: + +```text +## 结论 +## 第一性原理 +## Service 拆分概览(一句话职责 + 链到 reference) +## 跨 Service 模型(如统一查询模型) +## 关键场景 +## 派生交互映射 +## 依赖方向与边界 +## 决策记录 +``` + +不写: + +- “当前事实与问题”——这是讨论时的临时上下文,不是定稿内容。 +- “最小演进路径 / 迁移步骤”——迁移属于 `plan-lifecycle__*`,不是设计归档。 +- 任何具体代码层 Service 的名字、文件路径、目录布局。 + +## 4. 写 pattern + +`reference/patterns/.md` 顺序建议: + +```text +# + +## 适用场景 +## 拥有 +## 不拥有 +## 通用接口骨架(generic pseudocode) +## 决策点 +``` + +pattern 不绑定 domain:不出现 Session、Workspace、Order 等具体业务实体名字,用 `Aggregate` / `Summary` / `Query` 等占位。 + +## 5. 更新 SKILL.md 索引 + +每次新增 / 删除 / 替代 reference 或 explanation 文件,同步更新 `SKILL.md` 的 `## Skill Map`。索引就是入口,链接断了等于该文件不存在。 + +## 6. 不归档草稿 + +不归档: + +- 用户未确认的方案; +- 仍在比较的多个候选; +- 只有待办没有结论的内容; +- 与已有定稿冲突但没有 decision record 的内容; +- 代码视角的“当前状态”说明。 + +## 7. 替代旧定稿 + +如果新定稿替代旧定稿: + +- 更新原文件而不是新建并行版本; +- 在被替代文件的 `## 决策记录` 末尾补一条 DR,说明何时被替代、被谁替代、为什么; +- 不保留两份互相冲突的“定稿”。 diff --git a/.agents/skills/service-skill/how-to/design-a-service.md b/.agents/skills/service-skill/how-to/design-a-service.md new file mode 100644 index 000000000..503d130bb --- /dev/null +++ b/.agents/skills/service-skill/how-to/design-a-service.md @@ -0,0 +1,98 @@ +# 如何设计一个业务 Service + +本流程**不读现有代码**,输入是业务语义。 + +## 1. 建立业务事实 + +写下与代码无关的事实: + +- **实体**:业务里出现的名词(Workspace、Session、User、Order 等)。 +- **用户交互**:用户在产品中实际做什么(创建、列出、归档、查看状态、搜索、关联)。 +- **一致性边界**:哪些字段必须同时修改才能保持业务有效?这些字段构成一个 aggregate。 +- **可见性与归档语义**:删除是“看不到”还是“真的没有”?是否要恢复? +- **持久化 vs 派生 vs 运行时**:哪些信息必须保存?哪些可以由保存内容推导?哪些只在进程内活着? +- **外部约束**:并发要求、数据量级、查询模式(按谁过滤、按谁排序、是否跨 scope)。 + +不要: + +- 不要参考“现在有什么 Service”。 +- 不要参考“现在 route 里写了什么”。 +- 不要参考“现在 DI 怎么布的”。 + +## 2. 找 aggregate + +把实体拆成 aggregate。每个 aggregate 只回答一个问题:**它自己的一致性边界是什么?** + +提示: + +- 如果两个实体的字段从不同时修改,它们多半是不同 aggregate。 +- 如果一个字段是另一字段的派生(如 `workspace_id` 由 `workDir` 派生),它是派生字段,不是独立 aggregate。 +- 运行时投影(如 status、active turn)通常不是 aggregate 的持久化部分。 + +## 3. 拆 Command / Query / Runtime + +为每类能力找归属: + +| 能力 | 归属 | 模板 | +|---|---|---| +| create / update / archive / fork | Command Service | [`reference/patterns/command-service.md`](../reference/patterns/command-service.md) | +| list / search / count / children | Query Service | [`reference/patterns/query-service.md`](../reference/patterns/query-service.md) | +| status / active turn / approval | Runtime Service | [`reference/patterns/runtime-service.md`](../reference/patterns/runtime-service.md) | +| 单条持久化读写 / 列表读模型 | Repository / Index | [`reference/patterns/repository-and-index.md`](../reference/patterns/repository-and-index.md) | + +判定规则: + +- 改不改 aggregate 状态?改 → Command。 +- 读 aggregate 之外的派生 / 列表 / 计数?→ Query。 +- 是进程内 / 事件流推导的活状态?→ Runtime。 + +## 4. 定义接口 + +每个 Service 至少写清: + +```ts +interface IXxxService { + // 一句话职责通过方法体现 + command(input: XxxCommand): Promise; + query(query: XxxQuery): Promise>; +} +``` + +接口旁说明: + +- **拥有**:哪些字段 / 操作 / 不变量归它。 +- **不拥有**:哪些容易误归它的能力**不**归它。 +- **依赖**:依赖哪些更下层的 Service / Repository / Index。 +- **关键约束**:业务规则、命名规约、不允许的行为。 + +## 5. 用交互验证 + +把第 1 步列出的“用户交互”逐条映射到方法: + +```text +从 scope A 创建 → CommandService.create({ scopeA }) +全局列表 → QueryService.list({ scope: global }) +scope 下列表 → QueryService.list({ scope: ... }) +查看运行状态 → RuntimeService.getStatus(id) +``` + +诊断: + +- 某个交互找不到对应方法 → 接口缺口,回到第 3 步补。 +- 某个方法对应不上任何交互 → 过度设计,回到第 4 步删。 + +## 6. 写决策记录 + +对非显然的选择各写一条 DR: + +- 删除语义(archive vs purge)。 +- 派生字段 vs 真相字段。 +- 跨 aggregate 是否级联,如何级联。 +- 查询 scope 的设计。 +- runtime 与列表的分离边界。 + +这些 DR 是日后审视设计是否仍然适用的依据。 + +## 7. 决定是否归档 + +只有用户确认设计定稿、或明确要求保存时,才进入 [`how-to/archive-service-design.md`](archive-service-design.md)。草稿、未完成的比较方案、未决问题不进入 reference。 diff --git a/.agents/skills/service-skill/how-to/review-a-service-design.md b/.agents/skills/service-skill/how-to/review-a-service-design.md new file mode 100644 index 000000000..df33f5e65 --- /dev/null +++ b/.agents/skills/service-skill/how-to/review-a-service-design.md @@ -0,0 +1,72 @@ +# 如何审视一份 Service 设计 + +本流程的对象是**设计稿**或**已归档定稿**,不是代码。如果你想 review 的是“当前代码里 Service 边界对不对”,请用 `module-review` skill;本 skill 不读代码。 + +## 1. 明确输入 + +确认手里有: + +- 一份候选设计(pseudocode 接口 + 职责说明)或一份归档在 `reference/` 的旧定稿; +- 推动 review 的诱因:新增交互、出现冲突、归档已过时、与定稿冲突。 + +如果只有“感觉哪里不对”而没有可对照的设计文本,先回到 [`how-to/design-a-service.md`](design-a-service.md) 把设计写下来再 review。 + +## 2. 跑一遍 Red Flags 清单 + +对照 [`explanation/service-design-principles.md`](../explanation/service-design-principles.md) 的 Red Flags 逐条问: + +- [ ] 一个 aggregate 是否只有一个 owner? +- [ ] Command / Query / Runtime 是否被混在同一个 Service 里? +- [ ] 多 scope 下的 list 是否被实现了多份? +- [ ] 业务规则(标识解析、scope 推导)是否漂到 transport 层? +- [ ] 删除语义是否显式拆为 archive / restore / purge? +- [ ] 是否存在隐式的跨 aggregate 级联? +- [ ] 列表是否依赖运行时(resume、scan、状态加载)? +- [ ] 依赖方向是否向下?应用层之间是否有循环? + +每命中一条就记为一个 finding。 + +## 3. 跑一遍 Thinking Style 清单 + +对照同一篇 `service-design-principles.md` 的“思考风格”逐条问: + +- [ ] 文档是否结论先行? +- [ ] 真相 / 索引 / 运行时三类数据是否清晰分开? +- [ ] 派生字段和真相字段是否明确? +- [ ] 是否存在“看起来一样、实际不同语义”的方法名(delete vs purge、list 与 search 等)? + +## 4. 用交互验证 + +按 [`how-to/design-a-service.md`](design-a-service.md) 第 5 步: + +- 把所有用户交互重新映射到方法; +- 找“没有方法承接”的交互 → 接口缺口; +- 找“没有交互对应”的方法 → 过度设计; +- 找“一个交互需要多个 Service 协作”的情形 → 检查是否本该收归同一 Service。 + +## 5. 给出结论 + +每份 review 必须给出明确结论: + +- **保留**:设计仍然成立,记录“本次 review 未发现需要修改”。 +- **修订**:需要小幅修改,列出 finding 与对应的修订点。 +- **替代**:设计已经不适用,提出替代设计并补 DR 说明替换原因。 + +不允许的结论: + +- “看起来还行” / “大致没问题”——没有对照清单跑过。 +- “部分不对但先不改” / “以后再说”——不是 review 结论,是延后。 + +## 6. 处理与现有归档的冲突 + +如果本次 review 的结论和 `reference/` 里已归档的内容冲突: + +- 不要保留两份互相矛盾的“定稿”; +- 要么更新原文件并补一条 DR 说明变更原因,要么显式标记旧文件为已被替代并在 SKILL.md 索引里指向新文件; +- 决策记录是定稿可信度的来源,不能省。 + +## 7. 不要做的事 + +- 不要在 review 过程中读现有代码以判断设计是否“能落地”——落地能力由实现阶段评估。 +- 不要把“当前实现的便利性”作为反对一个概念上更正确拆分的理由——那是迁移问题,不是设计问题。 +- 不要在 review 报告里塞迁移路径——迁移属于 `plan-lifecycle__*`。 diff --git a/.agents/skills/service-skill/reference/domains/session-workspace/session-query-service.md b/.agents/skills/service-skill/reference/domains/session-workspace/session-query-service.md new file mode 100644 index 000000000..08faaff5a --- /dev/null +++ b/.agents/skills/service-skill/reference/domains/session-workspace/session-query-service.md @@ -0,0 +1,64 @@ +# SessionQueryService + +## 职责 + +`ISessionQueryService` 拥有 Session 的读模型:list / search / filter / count / children。global list 和 workspace list 是同一个 query 的不同 scope。 + +## 拥有 + +- global list。 +- workspace scoped list。 +- children list。 +- search、filter、sort、pagination。 +- archived 可见性控制。 +- `workspaceId → workDir` 在查询路径上的解析。 + +## 不拥有 + +- create / update / archive(→ `ISessionService`)。 +- runtime status(→ `ISessionRuntimeService`)。 +- workspace 注册项的读写(→ `IWorkspaceService`)。 +- Session aggregate 的真相写入。 + +## 接口 + +```ts +interface ISessionQueryService { + list(query: SessionListQuery): Promise>; + count(query: SessionListQuery): Promise; + + listByWorkspace( + workspaceId: string, + query?: Omit, + ): Promise>; + + listGlobal( + query?: Omit, + ): Promise>; + + listChildren( + parentSessionId: string, + query?: Omit, + ): Promise>; +} +``` + +共享类型(`SessionSummary`、`SessionListQuery`、`SessionQueryScope`、`PageResponse`、`CursorQuery`)见 [`types.md`](types.md)。 + +## 依赖 + +- `ISessionIndex`:读取列表读模型和计数。 +- `IWorkspaceService`:把 `workspaceId` 解析成 `workDir`。 + +## 关键约束 + +- `listByWorkspace` / `listGlobal` / `listChildren` 都是 `list()` 的薄封装,不复制实现。 +- 列表查询读 `ISessionIndex`,不触发 agent resume、不读取 runtime 投影。 +- 过滤、排序、分页、归档可见性逻辑只有一份实现。 +- `archived` 默认 `"exclude"`;`orderBy` 默认 `"updatedAt"`,`orderDir` 默认 `"desc"`。 + +## 决策记录 + +- **DR-Q1:global / workspace / children 共用同一查询模型。** 区别只是 `scope`。 +- **DR-Q2:list 不依赖 runtime。** 状态增强由 `ISessionRuntimeService` 按 id 按需提供。 +- **DR-Q3:业务标识解析在 Service 层。** transport 不承载 `workspace_id → workDir`。 diff --git a/.agents/skills/service-skill/reference/domains/session-workspace/session-runtime-service.md b/.agents/skills/service-skill/reference/domains/session-workspace/session-runtime-service.md new file mode 100644 index 000000000..7871b2b16 --- /dev/null +++ b/.agents/skills/service-skill/reference/domains/session-workspace/session-runtime-service.md @@ -0,0 +1,52 @@ +# SessionRuntimeService + +## 职责 + +`ISessionRuntimeService` 拥有 Session 的运行时活状态:status、active turn、approval、question、prompt 状态。 + +## 拥有 + +- `getStatus(id)`。 +- `getLiveState(id)`。 +- 状态变化事件。 +- runtime projection 的读取与维护。 + +## 不拥有 + +- Session 元数据持久化(→ `ISessionService`)。 +- Session list(→ `ISessionQueryService`)。 +- workspace 解析(→ `IWorkspaceService`)。 +- archive / restore / purge。 + +## 接口 + +```ts +interface ISessionRuntimeService { + getStatus(id: string): Promise; + getLiveState(id: string): Promise; + + readonly onDidChangeStatus: Event<{ + sessionId: string; + status: SessionStatus; + }>; +} +``` + +共享类型(`SessionStatus`、`SessionStatusResponse`、`SessionLiveState`)见 [`types.md`](types.md)。 + +## 依赖 + +- Runtime projection 源(如事件流、外部进程通信)。 + +## 关键约束 + +- 普通 list 不依赖 runtime。 +- 若列表需要展示状态,只对当前页或用户明确打开的 Session 做增强查询(per-id `getStatus`)。 +- Runtime 状态是投影,不写回 Session aggregate 真相。 +- 冷态读取(aggregate 已归档 / 运行时不存在)必须返回明确枚举值,不静默返回默认。 + +## 决策记录 + +- **DR-R1:投影 ≠ 真相。** 进程重启后由事件流重建,不写回真相。 +- **DR-R2:list 与 runtime 分离。** Query Service 不依赖 Runtime Service。 +- **DR-R3:状态增强按 id 按需。** 不作为 list 默认字段。 diff --git a/.agents/skills/service-skill/reference/domains/session-workspace/session-service.md b/.agents/skills/service-skill/reference/domains/session-workspace/session-service.md new file mode 100644 index 000000000..57fba77c0 --- /dev/null +++ b/.agents/skills/service-skill/reference/domains/session-workspace/session-service.md @@ -0,0 +1,102 @@ +# SessionService + +## 职责 + +`ISessionService` 拥有 Session aggregate 的生命周期命令:创建、读取、更新、归档、恢复、硬删除、fork、child、touch。 + +## 拥有 + +- Session create / get / update。 +- archive / restore / purge。 +- fork / createChild。 +- title、metadata、parent / child 关系的写入。 +- create 时解析 `workspaceId` 或 `workDir`。 +- `lastOpenedAt` 维护(`touch`)。 +- 生命周期事件的发布。 + +## 不拥有 + +- list / search / count(→ `ISessionQueryService`)。 +- runtime status(→ `ISessionRuntimeService`)。 +- workspace 注册表(→ `IWorkspaceService`)。 +- 列表排序、分页、过滤。 + +## 接口 + +```ts +interface ISessionService { + create(input: SessionCreate): Promise; + get(id: string): Promise; + update(id: string, input: SessionUpdate): Promise; + + archive(id: string): Promise<{ archived: true }>; + restore(id: string): Promise; + purge(id: string): Promise<{ deleted: true }>; + + fork(id: string, input: SessionFork): Promise; + createChild(id: string, input: SessionChildCreate): Promise; + + touch(id: string): Promise; +} + +type SessionCreate = + | { + workspaceId: string; + cwd?: string; + title?: string; + metadata?: SessionMetadata; + } + | { + workDir: string; + cwd?: string; + title?: string; + metadata?: SessionMetadata; + } + | { + workspaceId: string; + workDir: string; + cwd?: string; + title?: string; + metadata?: SessionMetadata; + }; + +interface SessionUpdate { + title?: string; + metadata?: SessionMetadata; +} + +interface SessionFork { + title?: string; + metadata?: SessionMetadata; + // 由 domain 进一步定义 fork 起点等参数 +} + +interface SessionChildCreate { + title?: string; + metadata?: SessionMetadata; + // 由 domain 进一步定义 child 类型等参数 +} +``` + +共享类型(`Session`、`SessionMetadata`)见 [`types.md`](types.md)。 + +## 依赖 + +- `ISessionRepository`:单条 Session 持久化读写。 +- `ISessionIndex`:创建 / 更新 / 删除后维护读模型。 +- `IWorkspaceService`:解析或创建 workspace。 +- 事件总线:发布 Session 生命周期事件。 + +## 关键约束 + +- `workspaceId` 和 `workDir` 同时传入时,必须校验 `encodeWorkDirKey(workDir) === workspaceId`。 +- Session 的持久化真相是 `workDir`;`workspace_id` 是对外暴露的派生字段。 +- 删除默认是 `archive`;硬删除必须叫 `purge`。 +- 任意状态变更后必须触发 `ISessionIndex.upsert`,保证读模型一致。 + +## 决策记录 + +- **DR-S1:删除默认 archive。** 硬删除走显式 `purge`。 +- **DR-S2:create 接受多形态标识。** `workspaceId` 与 `workDir` 共存的 union;同时传入校验一致。 +- **DR-S3:`workspace_id` 是派生字段。** Session 真相是 `workDir`。 +- **DR-S4:派生构造命名业务化。** `fork` 与 `createChild` 命名反映业务语义,不统一叫 `create`。 diff --git a/.agents/skills/service-skill/reference/domains/session-workspace/types.md b/.agents/skills/service-skill/reference/domains/session-workspace/types.md new file mode 100644 index 000000000..11e60111b --- /dev/null +++ b/.agents/skills/service-skill/reference/domains/session-workspace/types.md @@ -0,0 +1,214 @@ +# Session / Workspace 共享类型契约 + +集中定义本 domain 中跨多个 Service 共享的类型。Service 独有的输入 / 输出类型(如 `SessionCreate`、`SessionUpdate`)就近内联在各自 reference 文件中。 + +## 通用查询与分页 + +```ts +interface CursorQuery { + cursor?: string; + limit?: number; +} + +interface PageResponse { + items: T[]; + nextCursor?: string; + total?: number; +} +``` + +## Workspace + +```ts +interface Workspace { + id: string; // 派生:encodeWorkDirKey(root) + root: string; // 真相:绝对路径 + name?: string; + pinned?: boolean; + + createdAt: number; + updatedAt: number; + lastOpenedAt?: number; + + sessionCount?: number; // 由 Index 派生 + git?: WorkspaceGitInfo; +} + +interface WorkspaceGitInfo { + isRepo: boolean; + branch?: string; + remoteUrl?: string; +} + +interface WorkspaceBrowseRequest { + path?: string; + showHidden?: boolean; +} + +interface WorkspaceBrowseResponse { + cwd: string; + parent?: string; + entries: Array<{ + name: string; + path: string; + kind: "file" | "dir"; + isWorkspace?: boolean; + }>; +} + +interface WorkspaceHomeResponse { + home: string; + recent: Workspace[]; +} +``` + +## Session + +```ts +interface Session { + id: string; + workDir: string; // 真相 + workspaceId: string; // 派生:encodeWorkDirKey(workDir) + cwd: string; + + title?: string; + metadata?: SessionMetadata; + + parentSessionId?: string; + childKind?: "fork" | "child"; + + archived: boolean; + + createdAt: number; + updatedAt: number; + lastOpenedAt?: number; +} + +interface SessionSummary { + id: string; + workDir: string; + workspaceId: string; + parentSessionId?: string; + childKind?: "fork" | "child"; + + title?: string; + archived: boolean; + + createdAt: number; + updatedAt: number; + lastOpenedAt?: number; + + metadata?: SessionMetadata; +} + +type SessionMetadata = Record; +``` + +## Session 查询 + +```ts +type SessionQueryScope = + | { kind: "global" } + | { kind: "workspace"; workspaceId: string } + | { kind: "workDir"; workDir: string } + | { kind: "children"; parentSessionId: string }; + +interface SessionListQuery extends CursorQuery { + scope?: SessionQueryScope; + + status?: SessionStatus | SessionStatus[]; + archived?: "exclude" | "include" | "only"; + + parentSessionId?: string | null; + childKind?: "fork" | "child"; + + search?: string; + tags?: string[]; + + createdAfter?: string; + createdBefore?: string; + updatedAfter?: string; + updatedBefore?: string; + + orderBy?: "updatedAt" | "createdAt" | "title" | "lastOpenedAt"; + orderDir?: "asc" | "desc"; +} +``` + +默认行为: + +- `scope` 省略时等价于 `{ kind: "global" }`。 +- `archived` 默认 `"exclude"`。 +- `orderBy` 默认 `"updatedAt"`,`orderDir` 默认 `"desc"`。 + +## Runtime 状态 + +```ts +type SessionStatus = + | "idle" + | "running" + | "waiting-approval" + | "waiting-question" + | "compacting" + | "terminated" + | "unknown" + | "archived"; + +interface SessionStatusResponse { + sessionId: string; + status: SessionStatus; + updatedAt: number; +} + +interface SessionLiveState { + sessionId: string; + status: SessionStatus; + + activeTurnId?: string; + pendingApprovalId?: string; + pendingQuestionId?: string; + promptDraft?: string; +} +``` + +## Repository / Index 存储型 + +Repository 的存储型可与对外 `Session` 不同;Index 的 Summary 存储字段是 `SessionSummary` 的子集与派生字段。 + +```ts +interface StoredSession { + id: string; + workDir: string; + cwd: string; + title?: string; + metadata?: SessionMetadata; + parentSessionId?: string; + childKind?: "fork" | "child"; + archived: boolean; + createdAt: number; + updatedAt: number; + lastOpenedAt?: number; +} + +interface StoredSessionCreate { + workDir: string; + cwd: string; + title?: string; + metadata?: SessionMetadata; + parentSessionId?: string; + childKind?: "fork" | "child"; +} + +interface StoredSessionUpdate { + title?: string; + metadata?: SessionMetadata; + archived?: boolean; + lastOpenedAt?: number; +} +``` + +## 派生关系 + +- `workspaceId = encodeWorkDirKey(workDir)`。 +- `SessionSummary` 是 `Session` 的轻量字段子集,加上 `archived` 等查询需要的派生标记。 +- `SessionStatus` 是投影,由 Runtime 维护,不进入 `Session` / `StoredSession`。 diff --git a/.agents/skills/service-skill/reference/domains/session-workspace/workspace-service.md b/.agents/skills/service-skill/reference/domains/session-workspace/workspace-service.md new file mode 100644 index 000000000..1dac87c07 --- /dev/null +++ b/.agents/skills/service-skill/reference/domains/session-workspace/workspace-service.md @@ -0,0 +1,64 @@ +# WorkspaceService + +## 职责 + +`IWorkspaceService` 拥有 Workspace 这个 aggregate:根目录注册项、display name、recent 状态、root 解析和目录浏览。 + +## 拥有 + +- workspace 注册表 CRUD。 +- `workspace_id → root` 解析。 +- recent workspaces 维护。 +- workspace 根目录浏览。 +- git 信息探测或缓存(可选)。 + +## 不拥有 + +- Session 生命周期。 +- Session list / search / count 的具体实现。 +- 磁盘目录的真实删除(仅删注册项)。 +- 跨 aggregate 的级联删除。 + +## 接口 + +```ts +interface IWorkspaceService { + list(): Promise; + recent(limit?: number): Promise; + get(workspaceId: string): Promise; + + createOrTouch(root: string, name?: string): Promise; + update(workspaceId: string, input: WorkspaceUpdate): Promise; + delete(workspaceId: string): Promise; + + resolveRoot(workspaceId: string): Promise; + + browse(input?: WorkspaceBrowseRequest): Promise; + home(): Promise; +} + +interface WorkspaceUpdate { + name?: string; + pinned?: boolean; +} +``` + +共享类型(`Workspace`、`WorkspaceBrowseRequest`、`WorkspaceBrowseResponse`、`WorkspaceHomeResponse`)见 [`types.md`](types.md)。 + +## 依赖 + +- `IWorkspaceStore`:读写 workspace 注册表。 +- `ISessionIndex`:获取 `session_count` 或 recent session 摘要(避免依赖 `ISessionQueryService` 形成循环)。 +- 文件系统 / git 基础设施:目录浏览和 git 信息探测。 + +## 关键约束 + +- `delete(workspaceId)` 只删除注册项,不动磁盘目录,不删 Session。 +- 如果需要删除 workspace 下所有 Session,必须提供独立的高阶命令,例如 `purgeWorkspace(workspaceId, { deleteSessions: true })`,不能藏在 `delete` 里。 +- `workspace_id` 可由 `root` 推导,但 `root` 是真相字段。 + +## 决策记录 + +- **DR-W1:Workspace 不拥有 Session 生命周期。** Workspace 只是 Session 查询 scope 和 root 解析来源。 +- **DR-W2:`delete` 不级联。** 跨 aggregate 删除必须显式命名。 +- **DR-W3:`session_count` 依赖 Index,不依赖 Query Service。** 避免 `IWorkspaceService ⇄ ISessionQueryService` 循环。 diff --git a/.agents/skills/service-skill/reference/patterns/command-service.md b/.agents/skills/service-skill/reference/patterns/command-service.md new file mode 100644 index 000000000..aa9259aff --- /dev/null +++ b/.agents/skills/service-skill/reference/patterns/command-service.md @@ -0,0 +1,49 @@ +# Command Service Pattern + +## 适用场景 + +某个业务 aggregate 的**生命周期**和**状态变更**需要一个稳定的 owner。Command Service 是该 aggregate 的唯一写入入口。 + +## 拥有 + +- aggregate 的 create / get / update。 +- 显式的删除语义:archive / restore / purge。 +- 派生构造操作:fork / clone / createChild 等。 +- 执行命令所需的业务标识解析(例如把外部 id 解析为内部真相字段)。 +- 命令发出的领域事件。 + +## 不拥有 + +- list / search / count / filter(→ Query Service)。 +- 运行时活状态(→ Runtime Service)。 +- 单条持久化读写细节(→ Repository)。 +- transport 层的参数映射、序列化、错误码翻译。 + +## 通用接口骨架 + +```ts +interface ICommandService { + create(input: Create): Promise; + get(id: string): Promise; + update(id: string, input: Update): Promise; + + archive(id: string): Promise<{ archived: true }>; + restore(id: string): Promise; + purge(id: string): Promise<{ deleted: true }>; + + // 派生构造(按 domain 选择性提供) + fork?(id: string, input: Fork): Promise; + createChild?(id: string, input: Child): Promise; + + // 最近打开 / lastOpenedAt 等浅状态 + touch?(id: string): Promise; +} +``` + +## 决策点 + +- **create 输入是否多形态?** 例如同时接受“通过 scope id 创建”和“通过原始路径创建”——用 union 类型表达,校验同时传入时的一致性。 +- **删除是单操作还是拆分?** 默认拆 archive / restore / purge。如果业务上确实无可恢复语义,至少把 purge 命名清楚,不要叫 delete。 +- **是否有派生构造?** fork、clone、createChild 等命名应反映业务语义,而不是统一叫 create。 +- **跨 aggregate 的级联存在吗?** 存在则必须命名为高阶命令(如 `purgeWorkspace(id, { deleteSessions: true })`),不能藏在 archive / purge 里。 +- **命令产生事件吗?** 列出生命周期事件,作为 Query Service / Runtime Service / 外部订阅者更新读模型和投影的触发点。 diff --git a/.agents/skills/service-skill/reference/patterns/query-service.md b/.agents/skills/service-skill/reference/patterns/query-service.md new file mode 100644 index 000000000..e69561013 --- /dev/null +++ b/.agents/skills/service-skill/reference/patterns/query-service.md @@ -0,0 +1,56 @@ +# Query Service Pattern + +## 适用场景 + +同一个 aggregate 需要在多个 scope 下被列出 / 搜索 / 计数(全局、按父对象、按子集、按搜索词),且希望保持**一份查询实现**。 + +## 拥有 + +- list / search / count。 +- 过滤、排序、分页、归档可见性。 +- scope 维度的便捷方法(listByX / listGlobal / listChildren),作为 `list()` 的薄封装。 +- 业务标识解析(如把外部 scope id 解析为内部查询参数)。 + +## 不拥有 + +- aggregate 的写入与状态变更(→ Command Service)。 +- 运行时活状态(→ Runtime Service)。 +- 持久化真相(→ Repository)。 +- 直接驱动 transport 层的展示逻辑。 + +## 通用接口骨架 + +```ts +type QueryScope = + | { kind: "global" } + | { kind: "parent"; parentId: string } + | { kind: "children"; parentId: string } + /* 按 domain 扩展更多 scope kind */; + +interface BaseQuery extends CursorQuery { + scope?: QueryScope; + search?: string; + archived?: "exclude" | "include" | "only"; + orderBy?: string; + orderDir?: "asc" | "desc"; +} + +interface IQueryService { + list(query: Query): Promise>; + count(query: Query): Promise; + + // 便捷方法:一行薄封装 + listGlobal(query?: Omit): Promise>; + listByParent(parentId: string, query?: Omit): Promise>; + listChildren(parentId: string, query?: Omit): Promise>; +} +``` + +## 决策点 + +- **统一 query 模型。** 多 scope list 必须共享同一个 `Query` 类型,scope 只是其中一个字段。 +- **archived 默认行为。** 默认 `"exclude"`;明确写出来。 +- **orderBy 默认。** 默认 `"updatedAt" desc`;其他选项明确列举。 +- **Summary 字段集。** list 返回 Summary,不是完整 aggregate;显式定义 Summary 包含哪些字段,剩下的留给 Command Service 的 `get(id)`。 +- **不依赖运行时。** 不在 list 路径上 resume 任何长生命周期对象、不扫描运行时状态。状态增强(如显示当前 status)由 Runtime Service 在用户明确请求时按 id 提供,不进入默认 list。 +- **search 的语义。** 是前缀匹配、全文匹配,还是 tag 过滤?写清楚,避免与 filter 字段重叠定义。 diff --git a/.agents/skills/service-skill/reference/patterns/repository-and-index.md b/.agents/skills/service-skill/reference/patterns/repository-and-index.md new file mode 100644 index 000000000..a7d21ea83 --- /dev/null +++ b/.agents/skills/service-skill/reference/patterns/repository-and-index.md @@ -0,0 +1,63 @@ +# Repository and Index Pattern + +## 适用场景 + +某个 aggregate 同时需要: + +- 单条按 id 的真相读写; +- 多 scope 下的列表 / 搜索 / 计数。 + +Repository 是真相;Index 是读模型。两者分开,避免每次 list 扫描真相、避免 list 路径承担排序 / 过滤 / 分页的复杂度。 + +## Repository 拥有 + +- 单条 aggregate 的 create / get / update。 +- archive / restore / hard delete 的持久化原子操作。 +- aggregate 的真相字段约束(例如哪些字段一旦写入不可变)。 + +## Repository 不拥有 + +- 列表查询、计数、搜索。 +- 业务编排(→ Command Service)。 +- 跨 aggregate 的事务。 + +## Index 拥有 + +- 列表读模型(Summary)的 upsert / remove。 +- list / count / scoped list / search。 +- 排序、分页、过滤、归档可见性。 + +## Index 不拥有 + +- aggregate 真相写入。 +- 运行时活状态。 +- 业务标识解析(→ Command Service / Query Service)。 + +## 通用接口骨架 + +```ts +interface IRepository { + create(input: Create): Promise; + get(id: string): Promise; + update(id: string, input: Update): Promise; + archive(id: string): Promise; + restore(id: string): Promise; + delete(id: string): Promise; +} + +interface IIndex { + upsert(stored: Stored): Promise; + remove(id: string): Promise; + + list(query: Query): Promise>; + count(query: Query): Promise; +} +``` + +## 决策点 + +- **Summary 字段集。** Index 存 Summary 子集,不存完整 aggregate;显式列出字段,包括为 scope 查询冗余存储的派生字段(如 `workspaceId`、`parentSessionId`)。 +- **写入唯一来源。** Repository 写入后由谁触发 `Index.upsert`?通常是 Command Service。不允许 transport 层直接写 Index。 +- **一致性模型。** Repository 和 Index 是强一致还是最终一致?写清楚,避免 Command 完成后 list 看不到的歧义。 +- **归档语义在哪一层。** archive 改 Repository 的字段,由 Command Service 触发 `Index.upsert` 把 `archived: true` 同步过去;Index 通过 query 字段控制可见性,不删除条目。 +- **purge 的处理。** purge 同时清 Repository 和 Index;如果存在运行时关联资源,purge 之前必须由 Command Service 显式处理。 diff --git a/.agents/skills/service-skill/reference/patterns/runtime-service.md b/.agents/skills/service-skill/reference/patterns/runtime-service.md new file mode 100644 index 000000000..77c13093b --- /dev/null +++ b/.agents/skills/service-skill/reference/patterns/runtime-service.md @@ -0,0 +1,41 @@ +# Runtime Service Pattern + +## 适用场景 + +某个 aggregate 存在**不属于持久化真相的活状态**:status、active turn、当前 approval、prompt 状态、连接情况等。这些状态: + +- 由进程内对象 / 事件流推导; +- 在重启后可由真相 + 事件回放重建; +- 不应该写回 aggregate 真相,也不该让普通列表为了显示它而 resume 全部对象。 + +## 拥有 + +- 按 id 读取活状态:`getStatus(id)` / `getLiveState(id)`。 +- 活状态变化事件订阅。 +- 从事件流到投影的维护逻辑。 + +## 不拥有 + +- aggregate 的持久化(→ Repository)。 +- list / search / count(→ Query Service)。 +- 写入 aggregate 真相字段(→ Command Service)。 +- 跨 aggregate 的编排。 + +## 通用接口骨架 + +```ts +interface IRuntimeService { + getStatus(id: string): Promise; + getLiveState(id: string): Promise; + + readonly onDidChangeStatus: Event<{ id: string; status: Status }>; +} +``` + +## 决策点 + +- **投影 ≠ 真相。** 明确说明该 Service 的输出是投影;重启后由事件流重建,不是 aggregate 字段。 +- **列表不依赖 runtime。** Query Service 的 list 不能在内部调用 Runtime Service。 +- **状态增强是 per-id、按需触发。** 例如展示当前页时,对当前页里 N 条 id 分别调 `getStatus`,而不是把 status 作为 list 默认字段。 +- **事件订阅的传播范围。** 决定订阅是进程内还是跨进程;是 push 还是 long-poll;订阅断开后如何重连和补偿。 +- **冷态读取语义。** 如果 aggregate 已归档或对应运行时不存在,`getStatus` 返回什么?显式定义“冷态” / “未知” / “已终止”等枚举值,不要静默返回默认值。 diff --git a/.agents/skills/service-skill/tutorial/design-session-workspace-services.md b/.agents/skills/service-skill/tutorial/design-session-workspace-services.md new file mode 100644 index 000000000..3e5ea38f7 --- /dev/null +++ b/.agents/skills/service-skill/tutorial/design-session-workspace-services.md @@ -0,0 +1,220 @@ +# Tutorial:从业务需求推导 Session / Workspace Service 拆分 + +本教程**完全不引用任何现有代码**。我们只从一段产品需求出发,按 skill 的方法走到目标设计。 + +## 0. 起点:一段业务需求 + +产品方提出: + +> 我们做一个本地 agent 开发工具。用户在一个或多个**项目目录**下工作,每个项目目录可以反复打开(叫 “workspace”)。在每个 workspace 下,用户可以发起多次 agent 对话,每次对话叫 “session”,会保留 title、metadata 和对话历史。 +> +> 用户要做这些事: +> +> - 注册 / 看到 / 切换最近打开的 workspace; +> - 在 workspace 下创建 session、列出 session、归档 session、恢复、彻底删除; +> - 在“所有 workspace”视角下列出全部 session(用于全局搜索、整理); +> - 从某个已有 session 复制出一份 fork,或在其下再开一个 child; +> - 实时看到某个 session 是 idle 还是 running、是否在等用户审批; +> - 浏览磁盘目录,把任意目录注册为新的 workspace。 +> +> 删除 workspace 不应该删掉用户的目录,也不应该不知不觉删掉里面的 session。 + +只看这段话,不看任何代码。 + +## 1. 建立业务事实 + +把名词、动词、不变量列出来。 + +**实体**: + +- Workspace:一个根目录上下文,带 name、最近打开时间。 +- Session:一次对话,带 title、metadata、归档状态、父子关系。 + +**用户交互**: + +| 交互 | 频次 | 是否改状态 | +|---|---|---| +| 列出最近 workspace | 频繁 | 否 | +| 列出某 workspace 下 session | 频繁 | 否 | +| 全局列出所有 session | 偶尔 | 否 | +| 列出某 session 的 children | 偶尔 | 否 | +| 创建 session | 频繁 | 是 | +| 改 title / metadata | 偶尔 | 是 | +| 归档 / 恢复 / 彻底删除 session | 偶尔 | 是 | +| fork / createChild | 偶尔 | 是 | +| 看 session 当前 status | 频繁 | 否(读投影) | +| 浏览目录 | 偶尔 | 否 | +| 把目录注册为 workspace | 偶尔 | 是 | +| 删除 workspace 注册项 | 偶尔 | 是(仅注册项) | + +**不变量与约束**: + +- Workspace 真相 = 根目录绝对路径;其它字段都是辅助。 +- Session 真相 = 它属于哪个目录 + 自己的 metadata;status 不是真相。 +- 删除 workspace 注册项 ≠ 删除 session。 +- 删除 session 默认应可恢复。 +- 列表常被打开,必须便宜。 + +## 2. 找 aggregate + +按“一致性边界”切: + +- Workspace aggregate:`root`、`name`、`pinned`、`lastOpenedAt`。 +- Session aggregate:`workDir`、`title`、`metadata`、`archived`、`parentSessionId`、`childKind`。 + +派生字段(不是 aggregate 的真相): + +- `workspaceId = encodeWorkDirKey(workDir)`:Workspace 派生 id,也是 Session 引用 Workspace 的方式。 +- `sessionCount`:Workspace 视角下的派生统计。 +- `status` / `liveState`:Session 的运行时投影,不持久化。 + +## 3. 反面尝试(先犯一次错) + +**尝试 A:单个 `SessionService` 干所有事。** + +```ts +interface ISessionService { + create(input): Promise; + update(id, input): Promise; + delete(id): Promise; + + list(): Promise; + listByWorkspace(workspaceId): Promise; + listChildren(id): Promise; + search(text): Promise; + + getStatus(id): Promise; +} +``` + +问题 Red Flag 命中: + +- 一个 Service 同时承担 CRUD、多 scope list、运行时 status → **Command / Query / Runtime 混杂**。 +- `list` 和 `listByWorkspace` 和 `listChildren` 字段不同、过滤不同 → 实现会**复制三份过滤排序**。 +- `getStatus` 在同一个 Service 里 → 列表渲染时为了显示 status 容易触发 **list 触发 resume**。 +- `delete(id)` 语义不清:是看不到还是真删?→ **删除语义不显式**。 + +## 4. 第二次尝试(按本 skill 拆) + +按 Command / Query / Runtime 三分 + Workspace 独立: + +| Service | 一句话职责 | +|---|---| +| `IWorkspaceService` | Workspace 注册表、root 解析、目录浏览 | +| `ISessionService` | Session 生命周期命令(含 fork / child / touch) | +| `ISessionQueryService` | Session 多 scope 列表 / 搜索 / 计数 | +| `ISessionRuntimeService` | Session 活状态投影 | + +## 5. 统一 list + +global、workspace、children 三个列表是同一个查询的不同 scope: + +```ts +type SessionQueryScope = + | { kind: "global" } + | { kind: "workspace"; workspaceId: string } + | { kind: "children"; parentSessionId: string }; + +sessionQueryService.list({ scope, ...filters }); + +// 便捷封装 +sessionQueryService.listByWorkspace(workspaceId, query); +sessionQueryService.listGlobal(query); +sessionQueryService.listChildren(parentId, query); +``` + +底层只有一份过滤 / 排序 / 分页 / 归档可见性实现。 + +## 6. 放对标识解析 + +`workspace_id → workDir` 由 Service 层完成: + +```text +SessionQueryService.listByWorkspace(workspaceId) + └─ WorkspaceService.resolveRoot(workspaceId) → workDir + └─ SessionIndex.list({ scope: { kind: "workDir", workDir } }) +``` + +REST / WebSocket / CLI / TUI 共享同一规则。 + +## 7. 把 runtime 拆出去 + +普通列表读 Index,不依赖 runtime: + +```ts +sessionQueryService.listGlobal(query); // 不 resume 任何 Session +``` + +运行状态单独取: + +```ts +sessionRuntimeService.getStatus(id); // 按 id 按需 +sessionRuntimeService.onDidChangeStatus.subscribe(handler); +``` + +避免 list 时 resume 所有 session 的 anti-pattern。 + +## 8. 把删除语义拆开 + +```ts +sessionService.archive(id); // 默认;列表不可见;可恢复 +sessionService.restore(id); // 取消归档 +sessionService.purge(id); // 硬删除;必须显式 +``` + +Workspace 不级联: + +```ts +workspaceService.delete(workspaceId); // 仅删注册项 +// 真要清掉所有 session: +purgeWorkspace(workspaceId, { deleteSessions: true }); +``` + +## 9. 校验:把交互再映射一遍 + +回到第 1 步的交互表,每一条都能落到一个方法: + +| 交互 | 方法 | +|---|---| +| 列出最近 workspace | `WorkspaceService.recent()` | +| workspace 下列 session | `SessionQueryService.listByWorkspace(id, q)` | +| 全局列 session | `SessionQueryService.listGlobal(q)` | +| 列 children | `SessionQueryService.listChildren(parentId, q)` | +| 创建 session | `SessionService.create({ workspaceId })` 或 `{ workDir }` | +| 改 title / metadata | `SessionService.update(id, ...)` | +| 归档 / 恢复 / 删除 | `SessionService.archive` / `restore` / `purge` | +| fork / createChild | `SessionService.fork` / `createChild` | +| 看 status | `SessionRuntimeService.getStatus(id)` | +| 浏览目录 | `WorkspaceService.browse()` | +| 注册新 workspace | `WorkspaceService.createOrTouch(root)` | +| 删除 workspace 注册项 | `WorkspaceService.delete(id)` | + +每个方法都有对应交互;没有“多出来”的方法;没有“没承接”的交互。 + +## 10. 写决策记录 + +- DR1:Workspace 不拥有 Session 生命周期。 +- DR2:Session 删除默认 archive,硬删走 purge。 +- DR3:global / workspace / children list 共用一个 query。 +- DR4:普通 list 不依赖 runtime。 +- DR5:业务标识在 Service 层解析。 +- DR6:`workspace_id` 是 `workDir` 的派生字段。 +- DR7:跨 aggregate 删除必须显式命名。 + +## 11. 归档 + +设计定稿后归档到: + +- 跨 Service 叙述:[`explanation/domains/session-workspace.md`](../explanation/domains/session-workspace.md) +- 单 Service 契约: + - [`reference/domains/session-workspace/workspace-service.md`](../reference/domains/session-workspace/workspace-service.md) + - [`reference/domains/session-workspace/session-service.md`](../reference/domains/session-workspace/session-service.md) + - [`reference/domains/session-workspace/session-query-service.md`](../reference/domains/session-workspace/session-query-service.md) + - [`reference/domains/session-workspace/session-runtime-service.md`](../reference/domains/session-workspace/session-runtime-service.md) +- 共享类型:[`reference/domains/session-workspace/types.md`](../reference/domains/session-workspace/types.md) + +不归档: + +- 反面尝试 A(属于教学过程,不是定稿)。 +- 任何迁移路径(属于 `plan-lifecycle__*`)。 +- 任何现有代码引用(本 skill 不承载代码现状)。 diff --git a/.changeset/delete-deprecated-runtime-aliases.md b/.changeset/delete-deprecated-runtime-aliases.md new file mode 100644 index 000000000..c2d561b98 --- /dev/null +++ b/.changeset/delete-deprecated-runtime-aliases.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Remove deprecated DI aliases left over from the runtime-services refactor. The `IAgentEventBus` / `AgentEventBus` re-export is gone (use `IDomainEventBus` / `DomainEventBus`); the `ICoreProcessService` alias is gone (use `ICoreRuntime`); and the deprecated `ISessionService.list` / `listChildren` / `getStatus` thin wrappers are gone (use `ISessionQueryService.list` / `listChildren` and `ISessionRuntimeService.getStatus`). The `'coreProcessService'` decorator string is unchanged for now. diff --git a/.changeset/di-domain-runtime-services.md b/.changeset/di-domain-runtime-services.md new file mode 100644 index 000000000..24272807b --- /dev/null +++ b/.changeset/di-domain-runtime-services.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Refactor the in-process DI service layer into a domain-runtime-services architecture. Each aggregate decomposes into command / query / runtime / repository / index roles with explicit owners (e.g. `session/` → `ISessionService` + `ISessionQueryService` + `ISessionRuntimeService`, with `SessionRepository` / `SessionIndex` owned by the runtime layer). Facades no longer depend on the `CoreRPC` mega-proxy: they route to the in-process core through `ICoreRuntime.getCoreApi()` or through peer domain services. Cross-cutting effects move onto domain lifecycle hooks (`onSessionWillStart`, `onSessionWillClose`, `onAgentWillResume`, …) over the `IDomainEventBus`, with core-to-protocol event projection at the boundary. `ICoreRuntime` replaces `ICoreProcessService` and the deprecated aliases are gone, and a dependency-direction fence enforces runtime ↛ services, repository/index ↛ services, and no cross-service business imports. diff --git a/.changeset/introduce-icore-runtime.md b/.changeset/introduce-icore-runtime.md new file mode 100644 index 000000000..7cc066e59 --- /dev/null +++ b/.changeset/introduce-icore-runtime.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Introduce the `ICoreRuntime` runtime facade (in-process core access via `ready()`, `dispose()`, and `getCoreApi()`) and deprecate `ICoreProcessService` as a back-compat alias. Existing consumers keep working unchanged; core teardown now short-circuits RPC dispatch after dispose. diff --git a/.changeset/restore-archived-sessions.md b/.changeset/restore-archived-sessions.md new file mode 100644 index 000000000..2fd35cbf9 --- /dev/null +++ b/.changeset/restore-archived-sessions.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add server APIs to restore archived sessions and list only archived sessions. diff --git a/AGENTS.md b/AGENTS.md index a9f77b457..5017e06f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. +- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/` — domain-runtime-services decomposed into command/query/runtime facades over runtime-owned repositories/indexes, fenced by dependency direction), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 1a6494163..bed4c7ca6 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -27,6 +27,25 @@ ], "type": "module", "imports": { + "#/_base/di": "./src/_base/di/index.ts", + "#/_base/event": "./src/_base/event/index.ts", + "#/_base/logging": "./src/_base/logging/index.ts", + "#/_base/errors": "./src/_base/errors/index.ts", + "#/_utils/abort": "./src/_utils/abort/index.ts", + "#/_utils/fs": "./src/_utils/fs/index.ts", + "#/_utils/slug": "./src/_utils/slug/index.ts", + "#/_utils/xml": "./src/_utils/xml/index.ts", + "#/_utils/template": "./src/_utils/template/index.ts", + "#/_utils/types": "./src/_utils/types/index.ts", + "#/_utils/persistence": "./src/_utils/persistence/index.ts", + "#/_utils/net": "./src/_utils/net/index.ts", + "#/approval": "./src/approval/index.ts", + "#/event": "./src/event/index.ts", + "#/question": "./src/question/index.ts", + "#/coreProcess": "./src/coreProcess/index.ts", + "#/message": "./src/message/index.ts", + "#/prompt": "./src/prompt/index.ts", + "#/session": "./src/session/index.ts", "#/*": [ "./src/*.ts", "./src/*/index.ts" diff --git a/packages/agent-core/src/di/README.md b/packages/agent-core/src/_base/di/README.md similarity index 98% rename from packages/agent-core/src/di/README.md rename to packages/agent-core/src/_base/di/README.md index 1d0c5e974..1e3ea46a8 100644 --- a/packages/agent-core/src/di/README.md +++ b/packages/agent-core/src/_base/di/README.md @@ -13,7 +13,7 @@ A VSCode-style DI container for the agent-core / server stack. Provides: - **Delayed instantiation** — services flagged `supportsDelayedInstantiation: true` materialise lazily behind a `Proxy`. - **Testing** — `TestInstantiationService` (subpath - `@moonshot-ai/agent-core/di/test`) exposes direct `.get` / `.stub` so + `@moonshot-ai/agent-core/_base/di/test`) exposes direct `.get` / `.stub` so test bodies don't have to thread an `invokeFunction` accessor. The design intentionally mirrors VSCode's `vs/platform/instantiation` API so @@ -245,11 +245,11 @@ operate at the root container. ## Testing -Test files import from the subpath `@moonshot-ai/agent-core/di/test` +Test files import from the subpath `@moonshot-ai/agent-core/_base/di/test` (NOT the main package entry — keeps production bundles clean): ```ts -import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test'; +import { TestInstantiationService } from '@moonshot-ai/agent-core/_base/di/test'; const ix = new TestInstantiationService(); ix.stub(ILogger, { log: vi.fn() } as ILogger); @@ -270,10 +270,10 @@ expect((ix.get(ILogger) as { log: vi.Mock }).log).toHaveBeenCalled(); ## File layout ``` -packages/agent-core/src/di/ +packages/agent-core/src/_base/di/ ├── README.md ← you are here ├── index.ts ← public barrel (main package entry) -├── test.ts ← subpath barrel (`@moonshot-ai/agent-core/di/test`) +├── test.ts ← subpath barrel (`@moonshot-ai/agent-core/_base/di/test`) ├── instantiation.ts ← createDecorator + IInstantiationService interface + _util ├── descriptors.ts ← SyncDescriptor + SyncDescriptor0 + InstantiationType enum ├── serviceCollection.ts ← ServiceCollection @@ -315,5 +315,5 @@ following surface changes need attention: flips the Proxy path on. Existing call sites with `false` (or omitted) are unchanged. 7. **`TestInstantiationService` moved to a subpath** — - `import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test'` + `import { TestInstantiationService } from '@moonshot-ai/agent-core/_base/di/test'` (NOT from the main entry). diff --git a/packages/agent-core/src/di/descriptors.ts b/packages/agent-core/src/_base/di/descriptors.ts similarity index 100% rename from packages/agent-core/src/di/descriptors.ts rename to packages/agent-core/src/_base/di/descriptors.ts diff --git a/packages/agent-core/src/di/errors.ts b/packages/agent-core/src/_base/di/errors.ts similarity index 100% rename from packages/agent-core/src/di/errors.ts rename to packages/agent-core/src/_base/di/errors.ts diff --git a/packages/agent-core/src/di/extensions.ts b/packages/agent-core/src/_base/di/extensions.ts similarity index 100% rename from packages/agent-core/src/di/extensions.ts rename to packages/agent-core/src/_base/di/extensions.ts diff --git a/packages/agent-core/src/di/graph.ts b/packages/agent-core/src/_base/di/graph.ts similarity index 100% rename from packages/agent-core/src/di/graph.ts rename to packages/agent-core/src/_base/di/graph.ts diff --git a/packages/agent-core/src/di/index.ts b/packages/agent-core/src/_base/di/index.ts similarity index 92% rename from packages/agent-core/src/di/index.ts rename to packages/agent-core/src/_base/di/index.ts index 7796836f8..0b1139d69 100644 --- a/packages/agent-core/src/di/index.ts +++ b/packages/agent-core/src/_base/di/index.ts @@ -14,7 +14,8 @@ export { export { SyncDescriptor } from './descriptors'; export type { SyncDescriptor0 } from './descriptors'; export { ServiceCollection } from './serviceCollection'; -export { InstantiationService } from './instantiationService'; +export { InstantiationService, Trace } from './instantiationService'; +export { Graph } from './graph'; export { Disposable, DisposableStore, diff --git a/packages/agent-core/src/di/instantiation.ts b/packages/agent-core/src/_base/di/instantiation.ts similarity index 100% rename from packages/agent-core/src/di/instantiation.ts rename to packages/agent-core/src/_base/di/instantiation.ts diff --git a/packages/agent-core/src/di/instantiationService.ts b/packages/agent-core/src/_base/di/instantiationService.ts similarity index 100% rename from packages/agent-core/src/di/instantiationService.ts rename to packages/agent-core/src/_base/di/instantiationService.ts diff --git a/packages/agent-core/src/di/lifecycle.ts b/packages/agent-core/src/_base/di/lifecycle.ts similarity index 99% rename from packages/agent-core/src/di/lifecycle.ts rename to packages/agent-core/src/_base/di/lifecycle.ts index 8d43234b9..91248e397 100644 --- a/packages/agent-core/src/di/lifecycle.ts +++ b/packages/agent-core/src/_base/di/lifecycle.ts @@ -1,4 +1,4 @@ -import { onUnexpectedError } from '../errors/unexpectedError'; +import { onUnexpectedError } from '#/_base/errors'; export interface IDisposableTracker { trackDisposable(disposable: IDisposable): void; diff --git a/packages/agent-core/src/di/serviceCollection.ts b/packages/agent-core/src/_base/di/serviceCollection.ts similarity index 100% rename from packages/agent-core/src/di/serviceCollection.ts rename to packages/agent-core/src/_base/di/serviceCollection.ts diff --git a/packages/agent-core/src/_base/di/test.ts b/packages/agent-core/src/_base/di/test.ts new file mode 100644 index 000000000..05cf3cf2d --- /dev/null +++ b/packages/agent-core/src/_base/di/test.ts @@ -0,0 +1,9 @@ +export { + createServices, + TestInstantiationService, +} from './testInstantiationService'; +export type { ServiceIdCtorPair } from './testInstantiationService'; +// Test-only/internal helper from the instantiation submodule. Re-exported here +// (not from the production barrel) so tests can inspect decorator metadata +// without polluting the public DI surface. +export { _util } from './instantiation'; diff --git a/packages/agent-core/src/di/testInstantiationService.ts b/packages/agent-core/src/_base/di/testInstantiationService.ts similarity index 100% rename from packages/agent-core/src/di/testInstantiationService.ts rename to packages/agent-core/src/_base/di/testInstantiationService.ts diff --git a/packages/agent-core/src/di/util/idleValue.ts b/packages/agent-core/src/_base/di/util/idleValue.ts similarity index 100% rename from packages/agent-core/src/di/util/idleValue.ts rename to packages/agent-core/src/_base/di/util/idleValue.ts diff --git a/packages/agent-core/src/di/util/linkedList.ts b/packages/agent-core/src/_base/di/util/linkedList.ts similarity index 100% rename from packages/agent-core/src/di/util/linkedList.ts rename to packages/agent-core/src/_base/di/util/linkedList.ts diff --git a/packages/agent-core/src/_base/errors/index.ts b/packages/agent-core/src/_base/errors/index.ts new file mode 100644 index 000000000..86d77df00 --- /dev/null +++ b/packages/agent-core/src/_base/errors/index.ts @@ -0,0 +1,7 @@ +export { + onUnexpectedError, + resetUnexpectedErrorHandler, + safelyCallListener, + setUnexpectedErrorHandler, + type UnexpectedErrorHandler, +} from './unexpectedError'; diff --git a/packages/agent-core/src/errors/unexpectedError.ts b/packages/agent-core/src/_base/errors/unexpectedError.ts similarity index 100% rename from packages/agent-core/src/errors/unexpectedError.ts rename to packages/agent-core/src/_base/errors/unexpectedError.ts diff --git a/packages/agent-core/src/base/common/event.ts b/packages/agent-core/src/_base/event/event.ts similarity index 96% rename from packages/agent-core/src/base/common/event.ts rename to packages/agent-core/src/_base/event/event.ts index 89fde7300..1741ff477 100644 --- a/packages/agent-core/src/base/common/event.ts +++ b/packages/agent-core/src/_base/event/event.ts @@ -1,10 +1,10 @@ -import { onUnexpectedError, safelyCallListener } from '../../errors/unexpectedError'; +import { onUnexpectedError, safelyCallListener } from '#/_base/errors'; import { Disposable, DisposableStore, combinedDisposable, type IDisposable, -} from '../../di/lifecycle'; +} from '../../_base/di'; export interface Event { ( diff --git a/packages/agent-core/src/_base/event/index.ts b/packages/agent-core/src/_base/event/index.ts new file mode 100644 index 000000000..e68dcafc6 --- /dev/null +++ b/packages/agent-core/src/_base/event/index.ts @@ -0,0 +1 @@ +export { Event, Emitter } from './event'; diff --git a/packages/agent-core/src/logging/formatter.ts b/packages/agent-core/src/_base/logging/formatter.ts similarity index 100% rename from packages/agent-core/src/logging/formatter.ts rename to packages/agent-core/src/_base/logging/formatter.ts diff --git a/packages/agent-core/src/logging/index.ts b/packages/agent-core/src/_base/logging/index.ts similarity index 75% rename from packages/agent-core/src/logging/index.ts rename to packages/agent-core/src/_base/logging/index.ts index 51be3b8a6..92c8f99e9 100644 --- a/packages/agent-core/src/logging/index.ts +++ b/packages/agent-core/src/_base/logging/index.ts @@ -30,3 +30,8 @@ export { formatEntry, redactCtx, } from './formatter'; + +export { resolveLoggingConfig } from './resolve-config'; +export type { ResolveLoggingInput } from './resolve-config'; + +export { PENDING_MAX, RotatingFileSink } from './sinks'; diff --git a/packages/agent-core/src/logging/logger.ts b/packages/agent-core/src/_base/logging/logger.ts similarity index 100% rename from packages/agent-core/src/logging/logger.ts rename to packages/agent-core/src/_base/logging/logger.ts diff --git a/packages/agent-core/src/logging/resolve-config.ts b/packages/agent-core/src/_base/logging/resolve-config.ts similarity index 100% rename from packages/agent-core/src/logging/resolve-config.ts rename to packages/agent-core/src/_base/logging/resolve-config.ts diff --git a/packages/agent-core/src/logging/sinks.ts b/packages/agent-core/src/_base/logging/sinks.ts similarity index 99% rename from packages/agent-core/src/logging/sinks.ts rename to packages/agent-core/src/_base/logging/sinks.ts index ef23eb101..ab2e2a75b 100644 --- a/packages/agent-core/src/logging/sinks.ts +++ b/packages/agent-core/src/_base/logging/sinks.ts @@ -2,7 +2,7 @@ import { mkdir, open, rename, stat, unlink } from 'node:fs/promises'; import { appendFileSync, mkdirSync } from 'node:fs'; import { dirname } from 'pathe'; -import { syncDir } from '#/utils/fs'; +import { syncDir } from '#/_utils/fs'; export const PENDING_MAX = 1000; const STDERR_NOTICE_INTERVAL_MS = 30_000; diff --git a/packages/agent-core/src/logging/types.ts b/packages/agent-core/src/_base/logging/types.ts similarity index 100% rename from packages/agent-core/src/logging/types.ts rename to packages/agent-core/src/_base/logging/types.ts diff --git a/packages/agent-core/src/utils/abort.ts b/packages/agent-core/src/_utils/abort/abort.ts similarity index 100% rename from packages/agent-core/src/utils/abort.ts rename to packages/agent-core/src/_utils/abort/abort.ts diff --git a/packages/agent-core/src/_utils/abort/index.ts b/packages/agent-core/src/_utils/abort/index.ts new file mode 100644 index 000000000..5a4ed7432 --- /dev/null +++ b/packages/agent-core/src/_utils/abort/index.ts @@ -0,0 +1 @@ +export * from './abort'; diff --git a/packages/agent-core/src/utils/fs.ts b/packages/agent-core/src/_utils/fs/fs.ts similarity index 100% rename from packages/agent-core/src/utils/fs.ts rename to packages/agent-core/src/_utils/fs/fs.ts diff --git a/packages/agent-core/src/_utils/fs/index.ts b/packages/agent-core/src/_utils/fs/index.ts new file mode 100644 index 000000000..c6a897d25 --- /dev/null +++ b/packages/agent-core/src/_utils/fs/index.ts @@ -0,0 +1 @@ +export * from './fs'; diff --git a/packages/agent-core/src/_utils/net/index.ts b/packages/agent-core/src/_utils/net/index.ts new file mode 100644 index 000000000..9bcd86890 --- /dev/null +++ b/packages/agent-core/src/_utils/net/index.ts @@ -0,0 +1 @@ +export * from './proxy'; diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/_utils/net/proxy.ts similarity index 100% rename from packages/agent-core/src/utils/proxy.ts rename to packages/agent-core/src/_utils/net/proxy.ts diff --git a/packages/agent-core/src/_utils/persistence/index.ts b/packages/agent-core/src/_utils/persistence/index.ts new file mode 100644 index 000000000..f07dd9307 --- /dev/null +++ b/packages/agent-core/src/_utils/persistence/index.ts @@ -0,0 +1 @@ +export * from './per-id-json-store'; diff --git a/packages/agent-core/src/utils/per-id-json-store.ts b/packages/agent-core/src/_utils/persistence/per-id-json-store.ts similarity index 99% rename from packages/agent-core/src/utils/per-id-json-store.ts rename to packages/agent-core/src/_utils/persistence/per-id-json-store.ts index 7dfff7444..a25644dce 100644 --- a/packages/agent-core/src/utils/per-id-json-store.ts +++ b/packages/agent-core/src/_utils/persistence/per-id-json-store.ts @@ -24,7 +24,7 @@ import { mkdir, readdir, readFile, unlink } from 'node:fs/promises'; import { join } from 'pathe'; -import { atomicWrite } from './fs'; +import { atomicWrite } from '../fs'; export interface PerIdJsonStore { /** diff --git a/packages/agent-core/src/utils/hero-slug.ts b/packages/agent-core/src/_utils/slug/hero-slug.ts similarity index 100% rename from packages/agent-core/src/utils/hero-slug.ts rename to packages/agent-core/src/_utils/slug/hero-slug.ts diff --git a/packages/agent-core/src/_utils/slug/index.ts b/packages/agent-core/src/_utils/slug/index.ts new file mode 100644 index 000000000..62b767af0 --- /dev/null +++ b/packages/agent-core/src/_utils/slug/index.ts @@ -0,0 +1,2 @@ +export * from './hero-slug'; +export * from './workdir-slug'; diff --git a/packages/agent-core/src/utils/workdir-slug.ts b/packages/agent-core/src/_utils/slug/workdir-slug.ts similarity index 100% rename from packages/agent-core/src/utils/workdir-slug.ts rename to packages/agent-core/src/_utils/slug/workdir-slug.ts diff --git a/packages/agent-core/src/_utils/template/index.ts b/packages/agent-core/src/_utils/template/index.ts new file mode 100644 index 000000000..794c51170 --- /dev/null +++ b/packages/agent-core/src/_utils/template/index.ts @@ -0,0 +1 @@ +export * from './render-prompt'; diff --git a/packages/agent-core/src/utils/render-prompt.ts b/packages/agent-core/src/_utils/template/render-prompt.ts similarity index 100% rename from packages/agent-core/src/utils/render-prompt.ts rename to packages/agent-core/src/_utils/template/render-prompt.ts diff --git a/packages/agent-core/src/_utils/types/index.ts b/packages/agent-core/src/_utils/types/index.ts new file mode 100644 index 000000000..fcb073fef --- /dev/null +++ b/packages/agent-core/src/_utils/types/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/packages/agent-core/src/utils/types.ts b/packages/agent-core/src/_utils/types/types.ts similarity index 100% rename from packages/agent-core/src/utils/types.ts rename to packages/agent-core/src/_utils/types/types.ts diff --git a/packages/agent-core/src/_utils/xml/index.ts b/packages/agent-core/src/_utils/xml/index.ts new file mode 100644 index 000000000..9e5edc0cb --- /dev/null +++ b/packages/agent-core/src/_utils/xml/index.ts @@ -0,0 +1 @@ +export * from './xml-escape'; diff --git a/packages/agent-core/src/utils/xml-escape.ts b/packages/agent-core/src/_utils/xml/xml-escape.ts similarity index 100% rename from packages/agent-core/src/utils/xml-escape.ts rename to packages/agent-core/src/_utils/xml/xml-escape.ts diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 5c9963c57..f8664cac0 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -15,6 +15,7 @@ import { randomBytes } from 'node:crypto'; import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; +import { createDecorator } from '../../_base/di'; import { errorMessage } from '../../loop/errors'; import type { BackgroundTaskOrigin } from '../context'; import { renderNotificationXml } from '../context/notification-xml'; @@ -671,6 +672,21 @@ export class BackgroundManager { } } +export interface IBackgroundService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): BackgroundManager; +} + +export const IBackgroundService = createDecorator('backgroundService'); + +export class BackgroundService extends BackgroundManager implements IBackgroundService { + readonly _serviceBrand: undefined; + unwrap(): BackgroundManager { + return this; + } +} + function notificationKey(origin: BackgroundTaskOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } diff --git a/packages/agent-core/src/agent/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts index 290521df7..a3d008f14 100644 --- a/packages/agent-core/src/agent/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -15,7 +15,7 @@ import { appendFile, mkdir, open, stat } from 'node:fs/promises'; import { dirname, join } from 'pathe'; -import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store'; +import { createPerIdJsonStore, type PerIdJsonStore } from '#/_utils/persistence'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from './task'; /** diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index e444aee52..e28d10488 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -14,12 +14,13 @@ import { } from '@moonshot-ai/kosong'; import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import { isAbortError } from '../../loop/errors'; import { retryBackoffDelays, sleepForRetry, } from '../../loop/retry'; -import { renderPrompt } from '../../utils/render-prompt'; +import { renderPrompt } from '#/_utils/template'; import { estimateTokens, estimateTokensForMessages, @@ -405,6 +406,21 @@ export class FullCompaction { } } +export interface ICompactionService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): FullCompaction; +} + +export const ICompactionService = createDecorator('compactionService'); + +export class CompactionService extends FullCompaction implements ICompactionService { + readonly _serviceBrand: undefined; + unwrap(): FullCompaction { + return this; + } +} + function extractCompactionSummary(response: GenerateResult): string { const summary = typeof response.message.content === 'string' diff --git a/packages/agent-core/src/agent/compaction/micro.ts b/packages/agent-core/src/agent/compaction/micro.ts index 65a045a3d..c3e393f70 100644 --- a/packages/agent-core/src/agent/compaction/micro.ts +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -1,6 +1,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import type { ContextMessage } from '../context'; import { estimateTokensForContentParts, @@ -148,3 +149,18 @@ export class MicroCompaction { }; } } + +export interface IMicroCompactionService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): MicroCompaction; +} + +export const IMicroCompactionService = createDecorator('microCompactionService'); + +export class MicroCompactionService extends MicroCompaction implements IMicroCompactionService { + readonly _serviceBrand: undefined; + unwrap(): MicroCompaction { + return this; + } +} diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 8fd96838c..196543235 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -10,6 +10,7 @@ import { applyKimiEnvSamplingParams, applyKimiEnvThinkingKeep } from '#/config/k import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; +import { createDecorator } from '../../_base/di'; import type { AgentConfigData, AgentConfigUpdateData } from './types'; import { resolveThinkingEffort, type ThinkingEffort } from './thinking'; import type { ResolvedRuntimeProvider } from '../../session/provider-manager'; @@ -163,3 +164,18 @@ export class ConfigState { } } } + +export interface IAgentConfigService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): ConfigState; +} + +export const IAgentConfigService = createDecorator('agentConfigService'); + +export class AgentConfigService extends ConfigState implements IAgentConfigService { + readonly _serviceBrand: undefined; + unwrap(): ConfigState { + return this; + } +} diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 9bd25f209..bf929e4b4 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -2,6 +2,7 @@ import { createToolMessage, type ContentPart, type Message } from '@moonshot-ai/ import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; +import { createDecorator } from '../../_base/di'; import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop'; import { estimateTokensForMessages } from '../../utils/tokens'; import type { CompactionResult } from '../compaction'; @@ -113,6 +114,7 @@ export class ContextMemory { removedMessages.add(message); this._history.splice(i, 1); this.agent.injection.onContextMessageRemoved(i); + this.agent.lifecycle.fireContextMessageRemoved(i); if (i < this.tokenCountCoveredMessageCount) { this.tokenCountCoveredMessageCount--; @@ -347,6 +349,21 @@ export class ContextMemory { } } +export interface IContextService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): ContextMemory; +} + +export const IContextService = createDecorator('contextService'); + +export class ContextService extends ContextMemory implements IContextService { + readonly _serviceBrand: undefined; + unwrap(): ContextMemory { + return this; + } +} + function toolResultOutputForModel(result: ExecutableToolResult): string | ContentPart[] { const output = result.output; if (typeof output === 'string') { diff --git a/packages/agent-core/src/agent/context/notification-xml.ts b/packages/agent-core/src/agent/context/notification-xml.ts index c1da4f12b..5ac4aee03 100644 --- a/packages/agent-core/src/agent/context/notification-xml.ts +++ b/packages/agent-core/src/agent/context/notification-xml.ts @@ -24,7 +24,7 @@ * look alike (`agent-...`) but live in different namespaces. */ -import { escapeXmlAttr } from '#/utils/xml-escape'; +import { escapeXmlAttr } from '#/_utils/xml'; export function renderNotificationXml(data: Record): string { const id = stringAttr(data['id'], 'unknown'); diff --git a/packages/agent-core/src/agent/cron/index.ts b/packages/agent-core/src/agent/cron/index.ts index fca7804e8..108fb9a04 100644 --- a/packages/agent-core/src/agent/cron/index.ts +++ b/packages/agent-core/src/agent/cron/index.ts @@ -1 +1 @@ -export { CronManager, type CronManagerOptions } from './manager'; +export { CronManager, CronService, ICronService, type CronManagerOptions } from './manager'; diff --git a/packages/agent-core/src/agent/cron/manager.ts b/packages/agent-core/src/agent/cron/manager.ts index 3ba937bd8..3a146e41a 100644 --- a/packages/agent-core/src/agent/cron/manager.ts +++ b/packages/agent-core/src/agent/cron/manager.ts @@ -39,6 +39,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../index'; +import { createDecorator } from '../../_base/di'; import type { CronJobOrigin, CronMissedOrigin } from '../context/types'; import { resolveClockSources, @@ -59,7 +60,7 @@ import { CRON_SCHEDULED, } from '../../tools/cron/telemetry-events'; import type { CronTask } from '../../tools/cron/types'; -import type { PerIdJsonStore } from '../../utils/per-id-json-store'; +import type { PerIdJsonStore } from '#/_utils/persistence'; import type { SessionCronTaskInit } from '../../tools/cron/session-store'; @@ -563,3 +564,18 @@ export class CronManager { this.sigusr1Handler = null; } } + +export interface ICronService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): CronManager; +} + +export const ICronService = createDecorator('cronService'); + +export class CronService extends CronManager implements ICronService { + readonly _serviceBrand: undefined; + unwrap(): CronManager { + return this; + } +} diff --git a/packages/agent-core/src/agent/factory.ts b/packages/agent-core/src/agent/factory.ts new file mode 100644 index 000000000..a2fa8cb42 --- /dev/null +++ b/packages/agent-core/src/agent/factory.ts @@ -0,0 +1,116 @@ +import type { AgentEvent } from '#/rpc'; + +import { ServiceCollection, SyncDescriptor } from '../_base/di'; +import { DomainEventBus, IDomainEventBus } from '#/event'; +import { BackgroundService, BackgroundTaskPersistence, IBackgroundService } from './background'; +import { + CompactionService, + ICompactionService, + IMicroCompactionService, + MicroCompactionService, +} from './compaction'; +import { AgentConfigService, IAgentConfigService } from './config'; +import { ContextService, IContextService } from './context'; +import { CronService, ICronService } from './cron'; +import { GoalService, IGoalService } from './goal'; +import { InjectionService, IInjectionService } from './injection/manager'; +import { ILifecycleService, LifecycleService } from './lifecycle'; +import { IPermissionService, PermissionService } from './permission'; +import { IPlanService, PlanService } from './plan'; +import { + type AgentRecordPersistence, + IRecordsService, + RecordsService, +} from './records'; +import { IReplayService, ReplayService } from './replay'; +import { AgentSkillService, IAgentSkillService } from './skill'; +import { AgentStatusService, IAgentStatusService, type AgentStatusHost } from './status'; +import { ISwarmService, SwarmService } from './swarm'; +import { AgentToolService, IAgentToolService } from './tool/index'; +import { ITurnService, TurnService } from './turn'; +import { IUsageService, UsageService } from './usage'; +import type { Agent, AgentOptions } from './index'; + +/** + * Builds the per-agent `ServiceCollection` (the ~18 `SyncDescriptor` registrations + * plus the `IAgentSkillService` / `ICronService` conditionals) that the `Agent` + * constructor turns into a child scope. + * + * This is the safe first half of M2.6: it only relocates the registration block. + * The lazy-`this` injection is preserved — several descriptors still capture the + * `agent` handle and deref fields (e.g. `agent.records`, `agent.eventBus`) that are + * assigned *after* the scope is created. That is safe because the descriptors and + * the closures are lazy: they only materialize / run after construction completes. + * The two-phase-construction rewrite is deferred to M2.6b. + */ +export class AgentFactory { + static buildServiceCollection( + agent: Agent, + options: AgentOptions, + recordsPersistence: AgentRecordPersistence | undefined, + backgroundPersistence: BackgroundTaskPersistence | undefined, + ): ServiceCollection { + const perAgentServices = new ServiceCollection(); + perAgentServices.set(IRecordsService, new SyncDescriptor(RecordsService, [agent, recordsPersistence])); + perAgentServices.set( + ICompactionService, + new SyncDescriptor(CompactionService, [agent, options.compactionStrategy]), + ); + perAgentServices.set( + IMicroCompactionService, + new SyncDescriptor(MicroCompactionService, [agent, options.microCompaction]), + ); + perAgentServices.set(IContextService, new SyncDescriptor(ContextService, [agent])); + perAgentServices.set(IAgentConfigService, new SyncDescriptor(AgentConfigService, [agent])); + perAgentServices.set(ITurnService, new SyncDescriptor(TurnService, [agent])); + perAgentServices.set(IInjectionService, new SyncDescriptor(InjectionService, [agent])); + perAgentServices.set( + IPermissionService, + new SyncDescriptor(PermissionService, [agent, options.permission]), + ); + perAgentServices.set( + IAgentStatusService, + new SyncDescriptor(AgentStatusService, [agent satisfies AgentStatusHost]), + ); + perAgentServices.set( + IPlanService, + new SyncDescriptor(PlanService, [agent.kaos, agent.homedir]), + ); + perAgentServices.set(ISwarmService, new SyncDescriptor(SwarmService)); + perAgentServices.set(IUsageService, new SyncDescriptor(UsageService)); + perAgentServices.set(IAgentToolService, new SyncDescriptor(AgentToolService, [agent])); + perAgentServices.set( + IBackgroundService, + new SyncDescriptor(BackgroundService, [agent, backgroundPersistence]), + ); + perAgentServices.set(IReplayService, new SyncDescriptor(ReplayService, [options.replay])); + perAgentServices.set( + IDomainEventBus, + new SyncDescriptor(DomainEventBus, [ + (event: AgentEvent) => { + if (!agent.records.restoring) void agent.rpc?.emitEvent?.(event); + }, + ]), + ); + perAgentServices.set(ILifecycleService, new SyncDescriptor(LifecycleService, [])); + perAgentServices.set( + IGoalService, + new SyncDescriptor(GoalService, [ + agent.telemetry, + (event: AgentEvent) => { + agent.eventBus.publish(event); + }, + ]), + ); + if (options.skills !== undefined) { + perAgentServices.set( + IAgentSkillService, + new SyncDescriptor(AgentSkillService, [agent, options.skills]), + ); + } + if (agent.type !== 'sub') { + perAgentServices.set(ICronService, new SyncDescriptor(CronService, [agent])); + } + return perAgentServices; + } +} diff --git a/packages/agent-core/src/agent/goal/index.ts b/packages/agent-core/src/agent/goal/index.ts index d8f8bafd3..588964b09 100644 --- a/packages/agent-core/src/agent/goal/index.ts +++ b/packages/agent-core/src/agent/goal/index.ts @@ -1,11 +1,16 @@ import { randomUUID } from 'node:crypto'; import { ErrorCodes, KimiError } from '#/errors'; -import type { Agent } from '..'; +import type { AgentEvent } from '#/rpc'; import type { AgentRecordOf } from '../records/types'; +import { IContextService } from '../context'; +import { IRecordsService } from '../records'; +import { IReplayService } from '../replay'; import { + type TelemetryClient, type TelemetryProperties, } from '../../telemetry'; +import { createDecorator } from '../../_base/di'; /** * Durable goal-mode state owned by {@link GoalMode}. @@ -218,8 +223,13 @@ interface GoalReasonInput { export class GoalMode { private state: GoalState | undefined; - constructor(private readonly agent: Agent) { - } + constructor( + private readonly telemetry?: TelemetryClient, + private readonly emitEvent?: (event: AgentEvent) => void, + @IRecordsService private readonly records?: IRecordsService, + @IReplayService private readonly replayBuilder?: IReplayService, + @IContextService private readonly context?: IContextService, + ) {} /** * Reconciles replayed goal state with runtime reality on agent resume. @@ -265,7 +275,7 @@ export class GoalMode { budgetLimits: {}, }; this.state = state; - this.agent.replayBuilder.push({ + this.replayBuilder?.push({ type: 'goal_updated', snapshot: this.toSnapshot(state), change: { kind: 'created' }, @@ -291,7 +301,7 @@ export class GoalMode { if (record.budgetLimits !== undefined) state.budgetLimits = record.budgetLimits; if (status === undefined) return; - this.agent.replayBuilder.push({ + this.replayBuilder?.push({ type: 'goal_updated', snapshot: this.toSnapshot(state), change: status === 'complete' @@ -319,7 +329,7 @@ export class GoalMode { const hadGoal = this.state !== undefined; this.state = undefined; if (!hadGoal) return; - this.agent.context.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { + this.context?.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { kind: 'system_trigger', name: 'goal_fork_cleared', }); @@ -383,7 +393,7 @@ export class GoalMode { }; this.persistState(state); - this.agent.records.logRecord({ + this.records?.logRecord({ type: 'goal.create', goalId: state.goalId, objective: state.objective, @@ -481,7 +491,7 @@ export class GoalMode { const snapshot = this.toSnapshot(state); this.clearInternal(actor); if (actor === 'user') { - this.agent.context.appendSystemReminder(GOAL_CANCELLED_REMINDER, { + this.context?.appendSystemReminder(GOAL_CANCELLED_REMINDER, { kind: 'system_trigger', name: 'goal_cancelled', }); @@ -592,7 +602,7 @@ export class GoalMode { const state = this.state; if (state === undefined) return; // idempotent this.persistState(undefined, { silent: opts.emit === false }); - this.agent.records.logRecord({ type: 'goal.clear' }); + this.records?.logRecord({ type: 'goal.clear' }); if (opts.track !== false) { this.track('goal_cleared', { actor }); } @@ -618,7 +628,7 @@ export class GoalMode { private appendGoalUpdate( update: Omit, 'type' | 'time'>, ): void { - this.agent.records.logRecord({ + this.records?.logRecord({ type: 'goal.update', ...update, }); @@ -635,7 +645,7 @@ export class GoalMode { } private track(event: string, properties: TelemetryProperties): void { - this.agent.telemetry.track(event, properties); + this.telemetry?.track(event, properties); } private applyStatus( @@ -681,7 +691,7 @@ export class GoalMode { } private emitGoalUpdated(snapshot: GoalSnapshot | null, change?: GoalChange): void { - this.agent.emitEvent({ type: 'goal.updated', snapshot, change }); + this.emitEvent?.({ type: 'goal.updated', snapshot, change }); } /** Counter snapshot for a {@link GoalChange}. */ @@ -708,6 +718,22 @@ export class GoalMode { } } +export interface IGoalService extends Pick { + readonly _serviceBrand: undefined; + + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): GoalMode; +} + +export const IGoalService = createDecorator('goalService'); + +export class GoalService extends GoalMode implements IGoalService { + readonly _serviceBrand: undefined; + unwrap(): GoalMode { + return this; + } +} + /** * Live active-pursuit time: the accumulated total plus the in-flight `active` * interval. Correct even when read mid-turn (the interval isn't folded into diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index ea1cd806f..46dae487d 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -1,53 +1,67 @@ import { join } from 'pathe'; -import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; -import { log } from '#/logging/logger'; -import type { Logger } from '#/logging/types'; -import type { AgentAPI, AgentEvent, KimiConfig, SDKAgentRPC, UsageStatus } from '#/rpc'; +import { log } from '#/_base/logging'; +import type { Logger } from '#/_base/logging'; +import type { AgentAPI, AgentEvent, KimiConfig, SDKAgentRPC } from '#/rpc'; import { generate } from '@moonshot-ai/kosong'; import type { EnabledPluginSessionStart } from '#/plugin'; -import type { McpConnectionManager } from '../mcp'; +import type { IMcpConnectionService } from '../mcp'; import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile'; import type { ModelProvider } from '../session/provider-manager'; -import type { SessionSubagentHost } from '../session/subagent-host'; +import type { ISubagentHostService } from '../session/subagent-host'; import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; -import type { PromisableMethods } from '../utils/types'; -import { BackgroundManager, BackgroundTaskPersistence } from './background'; +import type { PromisableMethods } from '#/_utils/types'; +import { + InstantiationService, + type IInstantiationService, +} from '../_base/di'; +import { BackgroundTaskPersistence, IBackgroundService } from './background'; +import { IDomainEventBus } from '#/event'; +import { ILifecycleService } from './lifecycle'; import { - FullCompaction, - MicroCompaction, type CompactionStrategy, + ICompactionService, + IMicroCompactionService, type MicroCompactionConfig, } from './compaction'; -import { CronManager } from './cron'; -import { ConfigState } from './config'; -import { ContextMemory } from './context'; -import { GoalMode } from './goal'; -import { HookEngine } from '../session/hooks'; -import { InjectionManager } from './injection/manager'; -import { PermissionManager, type PermissionManagerOptions } from './permission'; -import { PlanMode } from './plan'; +import { ICronService } from './cron'; +import { IAgentConfigService } from './config'; +import { IContextService } from './context'; +import { IGoalService } from './goal'; +import { type IHookService } from '../session/hooks'; +import { IInjectionService } from './injection/manager'; +import { IPermissionService, type PermissionManagerOptions } from './permission'; +import { IPlanService } from './plan'; import { - AgentRecords, BlobStore, FileSystemAgentRecordPersistence, type AgentRecord, type AgentRecordPersistence, type AgentRecordsReplayOptions, + IRecordsService, } from './records'; -import { ReplayBuilder, type ReplayBuilderOptions } from './replay'; -import { SkillManager } from './skill'; +import { IReplayService, type ReplayBuilderOptions } from './replay'; +import { IAgentSkillService } from './skill'; import type { SkillRegistry } from './skill/types'; -import { SwarmMode } from './swarm'; -import { ToolManager } from './tool/index'; -import { TurnFlow } from './turn'; +import { ISwarmService } from './swarm'; +import { IAgentToolService } from './tool/index'; +import { ITurnService } from './turn'; import { KosongLLM } from './turn/kosong-llm'; -import { UsageRecorder } from './usage'; -import { LlmRequestLogger, splitGenerateOptions } from './llm-request-logger'; -import { resolveCompletionBudget } from '../utils/completion-budget'; +import { IUsageService } from './usage'; +import { AgentStatusService, IAgentStatusService } from './status'; +import { AgentRpcController } from './rpc-controller'; +import type { AgentRpcHost, IAgentRpcController } from './rpc-controller'; +import { AgentResumeService } from './resume'; +import type { AgentResumeHost, IAgentResumeService } from './resume'; +import { AgentProfileService } from './profile'; +import type { AgentProfileHost, IAgentProfileService } from './profile'; +import { AgentFactory } from './factory'; +import { LlmService } from './llm'; +import type { ILlmService } from './llm'; +import { LlmRequestLogger } from './llm-request-logger'; import type { Kaos } from '@moonshot-ai/kaos'; import type { ToolServices } from '../tools/support/services'; @@ -65,25 +79,28 @@ export interface AgentOptions { readonly rpc?: Partial; readonly persistence?: AgentRecordPersistence; readonly type?: AgentType; + readonly id?: string; readonly generate?: typeof generate; readonly toolServices?: ToolServices; readonly compactionStrategy?: CompactionStrategy; readonly microCompaction?: Partial; readonly modelProvider?: ModelProvider | undefined; - readonly subagentHost?: SessionSubagentHost | undefined; + readonly subagentHost?: ISubagentHostService | undefined; readonly skills?: SkillRegistry; - readonly mcp?: McpConnectionManager; - readonly hookEngine?: HookEngine; + readonly mcp?: IMcpConnectionService; + readonly hookEngine?: IHookService; readonly permission?: PermissionManagerOptions | undefined; readonly log?: Logger; readonly telemetry?: TelemetryClient | undefined; readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly experimentalFlags?: ExperimentalFlagResolver; readonly replay?: ReplayBuilderOptions; + readonly instantiationService?: IInstantiationService | undefined; } export class Agent { readonly type: AgentType; + readonly id: string | undefined; private _kaos: Kaos; get kaos(): Kaos { @@ -97,35 +114,44 @@ export class Agent { readonly pluginSessionStarts: readonly EnabledPluginSessionStart[]; readonly rawGenerate: typeof generate; readonly modelProvider?: ModelProvider; - readonly subagentHost?: SessionSubagentHost; - readonly mcp?: McpConnectionManager; - readonly hooks?: HookEngine; + readonly subagentHost?: ISubagentHostService; + readonly mcp?: IMcpConnectionService; + readonly hooks?: IHookService; readonly log: Logger; readonly telemetry: TelemetryClient; readonly experimentalFlags: ExperimentalFlagResolver; readonly llmRequestLogger: LlmRequestLogger; + readonly llmService: ILlmService; readonly blobStore: BlobStore | undefined; - readonly records: AgentRecords; - readonly fullCompaction: FullCompaction; - readonly microCompaction: MicroCompaction; - readonly context: ContextMemory; - readonly config: ConfigState; - readonly turn: TurnFlow; - readonly injection: InjectionManager; - readonly permission: PermissionManager; - readonly planMode: PlanMode; - readonly swarmMode: SwarmMode; - readonly usage: UsageRecorder; - readonly skills: SkillManager | null; - readonly tools: ToolManager; - readonly background: BackgroundManager; - readonly cron: CronManager | null; - readonly goal: GoalMode; - readonly replayBuilder: ReplayBuilder; + readonly records: IRecordsService; + readonly fullCompaction: ICompactionService; + readonly microCompaction: IMicroCompactionService; + readonly context: IContextService; + readonly config: IAgentConfigService; + readonly turn: ITurnService; + readonly injection: IInjectionService; + readonly permission: IPermissionService; + readonly planMode: IPlanService; + readonly swarmMode: ISwarmService; + readonly usage: IUsageService; + readonly statusService: IAgentStatusService; + readonly rpcController: IAgentRpcController; + readonly resumeService: IAgentResumeService; + readonly profileService: IAgentProfileService; + readonly eventBus: IDomainEventBus; + readonly lifecycle: ILifecycleService; + private readonly scope: IInstantiationService; + readonly skills: IAgentSkillService | null; + readonly tools: IAgentToolService; + readonly background: IBackgroundService; + readonly cron: ICronService | null; + readonly goal: IGoalService; + readonly replayBuilder: IReplayService; constructor(options: AgentOptions) { this.type = options.type ?? 'main'; + this.id = options.id; this._kaos = options.kaos; this.kimiConfig = options.config; this.homedir = options.homedir; @@ -145,37 +171,79 @@ export class Agent { this.blobStore = options.homedir ? new BlobStore({ blobsDir: join(options.homedir, 'blobs') }) : undefined; - this.records = new AgentRecords( - this, + const recordsPersistence = options.persistence ?? - (options.homedir - ? new FileSystemAgentRecordPersistence(join(options.homedir, 'wire.jsonl'), { - onError: (error) => { - this.emitRecordsWriteError(error); - }, - blobStore: this.blobStore, - }) - : undefined), - ); - this.fullCompaction = new FullCompaction(this, options.compactionStrategy); - this.microCompaction = new MicroCompaction(this, options.microCompaction); - this.context = new ContextMemory(this); - this.config = new ConfigState(this); - this.turn = new TurnFlow(this); - this.injection = new InjectionManager(this); - this.permission = new PermissionManager(this, options.permission); - this.planMode = new PlanMode(this); - this.swarmMode = new SwarmMode(this); - this.usage = new UsageRecorder(this); - this.skills = options.skills ? new SkillManager(this, options.skills) : null; - this.tools = new ToolManager(this); - this.background = new BackgroundManager( + (options.homedir + ? new FileSystemAgentRecordPersistence(join(options.homedir, 'wire.jsonl'), { + onError: (error) => { + // Lazy deref: `this.records` is assigned after `createChild` + // below, but this callback only runs on a later write failure, + // so the records service is always resolved by then. + this.records.emitWriteError(error); + }, + blobStore: this.blobStore, + }) + : undefined); + const backgroundPersistence = + this.homedir === undefined ? undefined : new BackgroundTaskPersistence(this.homedir); + + const perAgentServices = AgentFactory.buildServiceCollection( this, - this.homedir === undefined ? undefined : new BackgroundTaskPersistence(this.homedir), + options, + recordsPersistence, + backgroundPersistence, + ); + this.scope = (options.instantiationService ?? new InstantiationService(undefined, true)).createChild( + perAgentServices, ); - this.cron = this.type === 'sub' ? null : new CronManager(this); - this.goal = new GoalMode(this); - this.replayBuilder = new ReplayBuilder(this, options.replay); + + this.eventBus = this.scope.invokeFunction((accessor) => accessor.get(IDomainEventBus)); + this.lifecycle = this.scope.invokeFunction((accessor) => accessor.get(ILifecycleService)); + // Constructor is synchronous, so lifecycle hooks here are fire-and-forget; + // WillCreate fires as soon as the lifecycle service is resolved (the + // earliest point an `agentId` + lifecycle are both available). + if (this.id !== undefined) { + void this.lifecycle.fireAgentWillCreate({ agentId: this.id }); + } + this.records = this.scope.invokeFunction((accessor) => accessor.get(IRecordsService)); + this.fullCompaction = this.scope.invokeFunction((accessor) => accessor.get(ICompactionService)); + this.microCompaction = this.scope.invokeFunction((accessor) => + accessor.get(IMicroCompactionService), + ); + this.context = this.scope.invokeFunction((accessor) => accessor.get(IContextService)); + this.config = this.scope.invokeFunction((accessor) => accessor.get(IAgentConfigService)); + this.llmService = new LlmService({ + config: this.config, + llmRequestLogger: this.llmRequestLogger, + rawGenerate: this.rawGenerate, + modelProvider: this.modelProvider, + log: this.log, + kimiConfig: this.kimiConfig, + }); + this.turn = this.scope.invokeFunction((accessor) => accessor.get(ITurnService)); + this.injection = this.scope.invokeFunction((accessor) => accessor.get(IInjectionService)); + this.permission = this.scope.invokeFunction((accessor) => accessor.get(IPermissionService)); + this.planMode = this.scope.invokeFunction((accessor) => accessor.get(IPlanService)); + this.swarmMode = this.scope.invokeFunction((accessor) => accessor.get(ISwarmService)); + this.usage = this.scope.invokeFunction((accessor) => accessor.get(IUsageService)); + this.statusService = this.scope.invokeFunction((accessor) => accessor.get(IAgentStatusService)); + this.skills = options.skills + ? this.scope.invokeFunction((accessor) => accessor.get(IAgentSkillService)) + : null; + this.tools = this.scope.invokeFunction((accessor) => accessor.get(IAgentToolService)); + this.background = this.scope.invokeFunction((accessor) => accessor.get(IBackgroundService)); + this.cron = + this.type === 'sub' + ? null + : this.scope.invokeFunction((accessor) => accessor.get(ICronService)); + this.goal = this.scope.invokeFunction((accessor) => accessor.get(IGoalService)); + this.replayBuilder = this.scope.invokeFunction((accessor) => accessor.get(IReplayService)); + this.rpcController = new AgentRpcController(this satisfies AgentRpcHost); + this.resumeService = new AgentResumeService(this satisfies AgentResumeHost); + this.profileService = new AgentProfileService(this satisfies AgentProfileHost); + if (this.id !== undefined) { + void this.lifecycle.fireAgentDidCreate({ agentId: this.id }); + } } setKaos(kaos: Kaos) { @@ -183,248 +251,47 @@ export class Agent { } get generate(): typeof generate { - return async (provider, systemPrompt, tools, history, callbacks, options) => { - const { requestLogFields, generateOptions } = splitGenerateOptions(options); - const modelAlias = this.config.modelAlias; - const run = (requestOptions: Parameters[5]) => { - this.llmRequestLogger.logRequest({ - provider, - modelAlias, - systemPrompt, - tools, - messages: history, - fields: requestLogFields, - }); - return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, requestOptions); - }; - if (generateOptions?.auth !== undefined) { - return run(generateOptions); - } - const withAuth = - modelAlias === undefined - ? undefined - : this.modelProvider?.resolveAuth?.(modelAlias, { log: this.log }); - if (withAuth === undefined) { - return run(generateOptions); - } - return withAuth((auth) => { - return run({ ...generateOptions, auth }); - }); - }; + return this.llmService.generate; } get llm(): KosongLLM { - // All provider-level request config (thinking, sampling params, thinking.keep) - // is applied in ConfigState.provider so compaction shares it. See get provider(). - const provider = this.config.provider; - const loopControl = this.kimiConfig?.loopControl; - const completionBudgetConfig = resolveCompletionBudget({ - maxOutputSize: this.config.maxOutputSize, - reservedContextSize: loopControl?.reservedContextSize, - }); - return new KosongLLM({ - provider, - systemPrompt: this.config.systemPrompt, - capability: this.config.modelCapabilities, - generate: this.generate, - completionBudgetConfig, - }); + return this.llmService.llm; } useProfile(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void { - const systemPrompt = profile.systemPrompt({ - osEnv: this.kaos.osEnv, - cwd: this.config.cwd, - skills: this.skills?.registry, - cwdListing: context?.cwdListing, - agentsMd: context?.agentsMd, - }); - this.config.update({ profileName: profile.name, systemPrompt }); - this.tools.setActiveTools(profile.tools); + this.profileService.useProfile(profile, context); } async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { - const result = await this.records.replay(options); - try { - this.replayBuilder.postRestoring = true; - this.goal.normalizeAfterReplay(); - await this.background.loadFromDisk(); - await this.background.reconcile(); - await this.cron?.loadFromDisk(); - this.context.finishResume(); - this.turn.finishResume(); - } finally { - this.replayBuilder.postRestoring = false; + return this.resumeService.resume(options); + } + + /** + * Marks the agent teardown boundary by firing `fireAgentWillDispose`. The + * actual teardown (cron stop, background tasks, turn cancellation) is + * orchestrated by the owning `Session`; this method exists so the lifecycle + * hook fires from the agent boundary before that teardown runs. + */ + async dispose(): Promise { + if (this.id !== undefined) { + await this.lifecycle.fireAgentWillDispose({ agentId: this.id }); } - return result; } get rpcMethods(): PromisableMethods { - return { - prompt: (payload) => { - this.turn.prompt(payload.input); - }, - steer: (payload) => { - this.telemetry.track('input_steer', { parts: payload.input.length }); - this.turn.steer(payload.input); - }, - cancel: (payload) => { - if (this.turn.hasActiveTurn) { - this.telemetry.track('cancel', { from: 'streaming' }); - } - this.turn.cancel(payload.turnId); - }, - undoHistory: (payload) => { - this.context.undo(payload.count); - }, - setThinking: (payload) => { - const wasEnabled = this.config.thinkingLevel !== 'off'; - this.config.update({ thinkingLevel: payload.level }); - const enabled = this.config.thinkingLevel !== 'off'; - if (enabled !== wasEnabled) { - this.telemetry.track('thinking_toggle', { enabled }); - } - }, - setPermission: (payload) => { - const wasYolo = this.permission.mode === 'yolo'; - const wasAuto = this.permission.mode === 'auto'; - this.permission.setMode(payload.mode); - const enabled = this.permission.mode === 'yolo'; - if (enabled !== wasYolo) { - this.telemetry.track('yolo_toggle', { enabled }); - } - const afkEnabled = this.permission.mode === 'auto'; - if (afkEnabled !== wasAuto) { - this.telemetry.track('afk_toggle', { enabled: afkEnabled }); - } - }, - setModel: (payload) => { - // Validate the alias resolves before recording it so resume / runtime - // callers fail fast on missing aliases instead of deferring to the - // next prompt. - const resolved = this.modelProvider?.resolveProviderConfig(payload.model); - if (this.config.modelAlias !== payload.model) { - this.config.update({ modelAlias: payload.model }); - this.telemetry.track('model_switch', { model: payload.model }); - } - return { - model: payload.model, - providerName: resolved?.providerName, - }; - }, - getModel: () => { - return this.config.modelAlias ?? ''; - }, - enterPlan: async () => { - await this.planMode.enter(); - }, - cancelPlan: (payload) => { - this.planMode.cancel(payload.id); - }, - clearPlan: () => this.planMode.clear(), - enterSwarm: (payload) => { - this.swarmMode.enter(payload.trigger); - }, - exitSwarm: () => { - this.swarmMode.exit(); - }, - getSwarmMode: () => { - return this.swarmMode.isActive; - }, - beginCompaction: (payload) => { - this.fullCompaction.begin({ source: 'manual', instruction: payload.instruction }); - }, - cancelCompaction: () => { - if (this.fullCompaction.isCompacting) { - this.telemetry.track('cancel', { from: 'compacting' }); - } - this.fullCompaction.cancel(); - }, - registerTool: (payload) => { - this.tools.registerUserTool(payload); - }, - unregisterTool: (payload) => { - this.tools.unregisterUserTool(payload.name); - }, - setActiveTools: (payload) => { - this.tools.setActiveTools(payload.names); - }, - stopBackground: (payload) => { - void this.background.stop(payload.taskId, payload.reason); - }, - clearContext: () => { - this.context.clear(); - }, - activateSkill: (payload) => { - if (this.skills === null) { - throw new KimiError(ErrorCodes.SKILL_NOT_FOUND, `Skill "${payload.name}" was not found`); - } - this.skills.activate(payload); - }, - startBtw: () => this.subagentHost!.startBtw(), - createGoal: (payload) => this.goal.createGoal(payload), - getGoal: () => this.goal.getGoal(), - pauseGoal: () => this.goal.pauseGoal(), - resumeGoal: () => this.goal.resumeGoal(), - cancelGoal: () => this.goal.cancelGoal(), - getBackgroundOutput: (payload) => this.background.readOutput(payload.taskId, payload.tail), - getContext: () => this.context.data(), - getConfig: () => this.config.data(), - getPermission: () => this.permission.data(), - getPlan: () => this.planMode.data(), - getUsage: () => this.usage.data(), - getTools: () => this.tools.data(), - getBackground: (payload) => this.background.list(payload.activeOnly ?? false, payload.limit), - }; + return this.rpcController.rpcMethods; } emitEvent(event: AgentEvent): void { - if (this.records.restoring) return; - void this.rpc?.emitEvent?.(event); + this.eventBus.publish(event); } + /** + * Thin delegate preserved for callers (context / config / permission) that + * historically triggered a status refresh through the agent. The + * `agent.status.updated` emission now lives in {@link AgentStatusService}. + */ emitStatusUpdated(): void { - if (this.records.restoring) return; - if (!this.config.hasModel) return; - - const contextTokens = this.context.tokenCount; - const maxContextTokens = this.config.modelCapabilities.max_context_tokens; - const contextUsage = - maxContextTokens !== undefined && maxContextTokens > 0 - ? contextTokens / maxContextTokens - : undefined; - const usage: UsageStatus | undefined = this.usage.status(); - const model = this.config.model; - - this.emitEvent({ - type: 'agent.status.updated', - model, - contextTokens, - maxContextTokens, - contextUsage, - planMode: this.planMode.isActive, - swarmMode: this.swarmMode.isActive, - permission: this.permission.mode, - usage, - }); - } - - private emitRecordsWriteError(error: unknown, record?: AgentRecord | undefined): void { - const message = error instanceof Error ? error.message : String(error); - this.log.error('wire record persist failed', { - agentHomedir: this.homedir, - recordType: record?.type, - error, - }); - this.emitEvent({ - type: 'error', - ...makeErrorPayload( - ErrorCodes.RECORDS_WRITE_FAILED, - `Failed to write agent records: ${message}`, - { - details: { recordType: record?.type }, - }, - ), - }); + this.statusService.notifyStatusChanged(); } } diff --git a/packages/agent-core/src/agent/injection/manager.ts b/packages/agent-core/src/agent/injection/manager.ts index 99c9cd07e..250ab0f03 100644 --- a/packages/agent-core/src/agent/injection/manager.ts +++ b/packages/agent-core/src/agent/injection/manager.ts @@ -1,9 +1,9 @@ import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import { GoalInjector } from './goal'; import type { DynamicInjector } from './injector'; import { PermissionModeInjector } from './permission-mode'; import { PluginSessionStartInjector } from './plugin-session-start'; -import { PlanModeInjector } from './plan-mode'; import { TodoListReminderInjector } from './todo-list'; export class InjectionManager { @@ -19,7 +19,6 @@ export class InjectionManager { this.injectors = [ new PluginSessionStartInjector(agent), new TodoListReminderInjector(agent), - new PlanModeInjector(agent), new PermissionModeInjector(agent), ]; this.goalInjector = agent.type === 'main' ? new GoalInjector(agent) : null; @@ -29,6 +28,11 @@ export class InjectionManager { for (const injector of this.injectors) { await injector.inject(); } + await this.agent.lifecycle.fireBeforePrompt({ + injectSystemReminder: (content, origin) => { + this.agent.context.appendSystemReminder(content, origin); + }, + }); } /** @@ -72,3 +76,18 @@ export class InjectionManager { return this.goalInjector; } } + +export interface IInjectionService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): InjectionManager; +} + +export const IInjectionService = createDecorator('injectionService'); + +export class InjectionService extends InjectionManager implements IInjectionService { + readonly _serviceBrand: undefined; + unwrap(): InjectionManager { + return this; + } +} diff --git a/packages/agent-core/src/agent/injection/plan-mode.ts b/packages/agent-core/src/agent/injection/plan-mode.ts index 846d7b582..40a989c1c 100644 --- a/packages/agent-core/src/agent/injection/plan-mode.ts +++ b/packages/agent-core/src/agent/injection/plan-mode.ts @@ -83,7 +83,7 @@ function withPlanFileFooter(body: string, planFilePath: PlanFilePath): string { return `${body}\n\nPlan file: ${planFilePath}`; } -function fullReminder(planFilePath: PlanFilePath): string { +export function fullReminder(planFilePath: PlanFilePath): string { if (planFilePath === null || planFilePath.length === 0) { return inlineFullReminder(); } @@ -110,7 +110,7 @@ Do NOT use AskUserQuestion to ask about plan approval or reference "the plan" return withPlanFileFooter(body, planFilePath); } -function sparseReminder(planFilePath: PlanFilePath): string { +export function sparseReminder(planFilePath: PlanFilePath): string { if (planFilePath === null || planFilePath.length === 0) { return inlineSparseReminder(); } @@ -119,7 +119,7 @@ function sparseReminder(planFilePath: PlanFilePath): string { return withPlanFileFooter(body, planFilePath); } -function reentryReminder(planFilePath: PlanFilePath): string { +export function reentryReminder(planFilePath: PlanFilePath): string { if (planFilePath === null || planFilePath.length === 0) { return inlineReentryReminder(); } @@ -176,6 +176,6 @@ Before proceeding: Your turn must end with either AskUserQuestion (to clarify requirements) or ExitPlanMode (to request plan approval).`; } -function exitReminder(): string { +export function exitReminder(): string { return `Plan mode is no longer active. The read-only and plan-file-only restrictions from plan mode no longer apply. Continue with the approved plan using the normal tool and permission rules.`; } diff --git a/packages/agent-core/src/agent/injection/plugin-session-start.ts b/packages/agent-core/src/agent/injection/plugin-session-start.ts index 16d41489b..d9e28dd33 100644 --- a/packages/agent-core/src/agent/injection/plugin-session-start.ts +++ b/packages/agent-core/src/agent/injection/plugin-session-start.ts @@ -1,6 +1,6 @@ import type { EnabledPluginSessionStart } from '../../plugin/types'; import type { SkillDefinition } from '../../skill'; -import { escapeXmlAttr } from '../../utils/xml-escape'; +import { escapeXmlAttr } from '#/_utils/xml'; import { DynamicInjector } from './injector'; export class PluginSessionStartInjector extends DynamicInjector { diff --git a/packages/agent-core/src/agent/lifecycle.ts b/packages/agent-core/src/agent/lifecycle.ts new file mode 100644 index 000000000..8ab1edaac --- /dev/null +++ b/packages/agent-core/src/agent/lifecycle.ts @@ -0,0 +1,262 @@ +import { createDecorator } from '../_base/di'; +import type { IDisposable } from '../_base/di'; +import type { PromptOrigin } from './context'; + +/** + * Narrowed context passed to `onBeforePrompt` handlers. Handlers may inject a + * system reminder into the prompt being built; they reach every other service + * through their own injected dependencies (closed over at registration time), + * never through this context. + */ +export interface PromptCtx { + readonly agentId?: string; + readonly turnId?: number; + injectSystemReminder(content: string, origin: PromptOrigin): void; +} + +/** Context carried by session-scoped lifecycle hooks. */ +export interface SessionHookCtx { + readonly sessionId: string; +} + +/** Context carried by agent-scoped lifecycle hooks. */ +export interface AgentHookCtx { + readonly agentId: string; +} + +/** Context carried by turn-scoped lifecycle hooks. */ +export interface TurnHookCtx { + readonly turnId?: number; +} + +export interface ILifecycleService { + readonly _serviceBrand: undefined; + + onBeforePrompt(handler: (ctx: PromptCtx) => void | Promise): IDisposable; + + /** + * Fired when a message is removed from the context (e.g. undo), carrying the + * index of the removed message so handlers can keep any history-indexed state + * aligned. + */ + onContextMessageRemoved(handler: (index: number) => void): IDisposable; + + /** Fired by the framework (turn loop) before a step's prompt is built. */ + fireBeforePrompt(ctx: PromptCtx): Promise; + + /** Fired by the context when a message at `index` is removed. */ + fireContextMessageRemoved(index: number): void; + + // ---- Session lifecycle hooks ---- + onSessionWillStart(handler: SessionHookHandler): IDisposable; + onSessionDidStart(handler: SessionHookHandler): IDisposable; + onSessionWillClose(handler: SessionHookHandler): IDisposable; + onSessionDidClose(handler: SessionHookHandler): IDisposable; + fireSessionWillStart(ctx: SessionHookCtx): Promise; + fireSessionDidStart(ctx: SessionHookCtx): Promise; + fireSessionWillClose(ctx: SessionHookCtx): Promise; + fireSessionDidClose(ctx: SessionHookCtx): Promise; + + // ---- Agent lifecycle hooks ---- + onAgentWillCreate(handler: AgentHookHandler): IDisposable; + onAgentDidCreate(handler: AgentHookHandler): IDisposable; + onAgentWillResume(handler: AgentHookHandler): IDisposable; + onAgentDidResume(handler: AgentHookHandler): IDisposable; + onAgentWillDispose(handler: AgentHookHandler): IDisposable; + fireAgentWillCreate(ctx: AgentHookCtx): Promise; + fireAgentDidCreate(ctx: AgentHookCtx): Promise; + fireAgentWillResume(ctx: AgentHookCtx): Promise; + fireAgentDidResume(ctx: AgentHookCtx): Promise; + fireAgentWillDispose(ctx: AgentHookCtx): Promise; + + // ---- Turn lifecycle hooks ---- + onTurnWillStart(handler: TurnHookHandler): IDisposable; + onTurnDidStart(handler: TurnHookHandler): IDisposable; + onTurnDidEnd(handler: TurnHookHandler): IDisposable; + fireTurnWillStart(ctx: TurnHookCtx): Promise; + fireTurnDidStart(ctx: TurnHookCtx): Promise; + fireTurnDidEnd(ctx: TurnHookCtx): Promise; +} + +export const ILifecycleService = createDecorator('lifecycleService'); + +type BeforePromptHandler = (ctx: PromptCtx) => void | Promise; +type SessionHookHandler = (ctx: SessionHookCtx) => void | Promise; +type AgentHookHandler = (ctx: AgentHookCtx) => void | Promise; +type TurnHookHandler = (ctx: TurnHookCtx) => void | Promise; + +export class LifecycleService implements ILifecycleService { + readonly _serviceBrand: undefined; + + private readonly beforePromptHandlers = new Set(); + private readonly contextMessageRemovedHandlers = new Set<(index: number) => void>(); + + onBeforePrompt(handler: BeforePromptHandler): IDisposable { + this.beforePromptHandlers.add(handler); + return { dispose: () => this.beforePromptHandlers.delete(handler) }; + } + + onContextMessageRemoved(handler: (index: number) => void): IDisposable { + this.contextMessageRemovedHandlers.add(handler); + return { dispose: () => this.contextMessageRemovedHandlers.delete(handler) }; + } + + async fireBeforePrompt(ctx: PromptCtx): Promise { + for (const handler of this.beforePromptHandlers) { + await handler(ctx); + } + } + + fireContextMessageRemoved(index: number): void { + for (const handler of this.contextMessageRemovedHandlers) { + handler(index); + } + } + + private readonly sessionWillStartHandlers = new Set(); + private readonly sessionDidStartHandlers = new Set(); + private readonly sessionWillCloseHandlers = new Set(); + private readonly sessionDidCloseHandlers = new Set(); + + onSessionWillStart(handler: SessionHookHandler): IDisposable { + this.sessionWillStartHandlers.add(handler); + return { dispose: () => this.sessionWillStartHandlers.delete(handler) }; + } + + onSessionDidStart(handler: SessionHookHandler): IDisposable { + this.sessionDidStartHandlers.add(handler); + return { dispose: () => this.sessionDidStartHandlers.delete(handler) }; + } + + onSessionWillClose(handler: SessionHookHandler): IDisposable { + this.sessionWillCloseHandlers.add(handler); + return { dispose: () => this.sessionWillCloseHandlers.delete(handler) }; + } + + onSessionDidClose(handler: SessionHookHandler): IDisposable { + this.sessionDidCloseHandlers.add(handler); + return { dispose: () => this.sessionDidCloseHandlers.delete(handler) }; + } + + async fireSessionWillStart(ctx: SessionHookCtx): Promise { + for (const handler of this.sessionWillStartHandlers) { + await handler(ctx); + } + } + + async fireSessionDidStart(ctx: SessionHookCtx): Promise { + for (const handler of this.sessionDidStartHandlers) { + await handler(ctx); + } + } + + async fireSessionWillClose(ctx: SessionHookCtx): Promise { + for (const handler of this.sessionWillCloseHandlers) { + await handler(ctx); + } + } + + async fireSessionDidClose(ctx: SessionHookCtx): Promise { + for (const handler of this.sessionDidCloseHandlers) { + await handler(ctx); + } + } + + private readonly agentWillCreateHandlers = new Set(); + private readonly agentDidCreateHandlers = new Set(); + private readonly agentWillResumeHandlers = new Set(); + private readonly agentDidResumeHandlers = new Set(); + private readonly agentWillDisposeHandlers = new Set(); + + onAgentWillCreate(handler: AgentHookHandler): IDisposable { + this.agentWillCreateHandlers.add(handler); + return { dispose: () => this.agentWillCreateHandlers.delete(handler) }; + } + + onAgentDidCreate(handler: AgentHookHandler): IDisposable { + this.agentDidCreateHandlers.add(handler); + return { dispose: () => this.agentDidCreateHandlers.delete(handler) }; + } + + onAgentWillResume(handler: AgentHookHandler): IDisposable { + this.agentWillResumeHandlers.add(handler); + return { dispose: () => this.agentWillResumeHandlers.delete(handler) }; + } + + onAgentDidResume(handler: AgentHookHandler): IDisposable { + this.agentDidResumeHandlers.add(handler); + return { dispose: () => this.agentDidResumeHandlers.delete(handler) }; + } + + onAgentWillDispose(handler: AgentHookHandler): IDisposable { + this.agentWillDisposeHandlers.add(handler); + return { dispose: () => this.agentWillDisposeHandlers.delete(handler) }; + } + + async fireAgentWillCreate(ctx: AgentHookCtx): Promise { + for (const handler of this.agentWillCreateHandlers) { + await handler(ctx); + } + } + + async fireAgentDidCreate(ctx: AgentHookCtx): Promise { + for (const handler of this.agentDidCreateHandlers) { + await handler(ctx); + } + } + + async fireAgentWillResume(ctx: AgentHookCtx): Promise { + for (const handler of this.agentWillResumeHandlers) { + await handler(ctx); + } + } + + async fireAgentDidResume(ctx: AgentHookCtx): Promise { + for (const handler of this.agentDidResumeHandlers) { + await handler(ctx); + } + } + + async fireAgentWillDispose(ctx: AgentHookCtx): Promise { + for (const handler of this.agentWillDisposeHandlers) { + await handler(ctx); + } + } + + private readonly turnWillStartHandlers = new Set(); + private readonly turnDidStartHandlers = new Set(); + private readonly turnDidEndHandlers = new Set(); + + onTurnWillStart(handler: TurnHookHandler): IDisposable { + this.turnWillStartHandlers.add(handler); + return { dispose: () => this.turnWillStartHandlers.delete(handler) }; + } + + onTurnDidStart(handler: TurnHookHandler): IDisposable { + this.turnDidStartHandlers.add(handler); + return { dispose: () => this.turnDidStartHandlers.delete(handler) }; + } + + onTurnDidEnd(handler: TurnHookHandler): IDisposable { + this.turnDidEndHandlers.add(handler); + return { dispose: () => this.turnDidEndHandlers.delete(handler) }; + } + + async fireTurnWillStart(ctx: TurnHookCtx): Promise { + for (const handler of this.turnWillStartHandlers) { + await handler(ctx); + } + } + + async fireTurnDidStart(ctx: TurnHookCtx): Promise { + for (const handler of this.turnDidStartHandlers) { + await handler(ctx); + } + } + + async fireTurnDidEnd(ctx: TurnHookCtx): Promise { + for (const handler of this.turnDidEndHandlers) { + await handler(ctx); + } + } +} diff --git a/packages/agent-core/src/agent/llm-request-logger.ts b/packages/agent-core/src/agent/llm-request-logger.ts index b9ff6b665..7c943e796 100644 --- a/packages/agent-core/src/agent/llm-request-logger.ts +++ b/packages/agent-core/src/agent/llm-request-logger.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; -import type { Logger } from '#/logging/types'; +import type { Logger } from '#/_base/logging'; import type { ChatProvider, GenerateOptions, Message, Tool } from '@moonshot-ai/kosong'; import type { LLMRequestLogFields } from '../loop'; diff --git a/packages/agent-core/src/agent/llm/index.ts b/packages/agent-core/src/agent/llm/index.ts new file mode 100644 index 000000000..5fb60e608 --- /dev/null +++ b/packages/agent-core/src/agent/llm/index.ts @@ -0,0 +1,103 @@ +import { generate } from '@moonshot-ai/kosong'; +import type { ChatProvider, ModelCapability } from '@moonshot-ai/kosong'; + +import type { Logger } from '#/_base/logging'; +import type { KimiConfig } from '#/rpc'; + +import type { ModelProvider } from '../../session/provider-manager'; +import { resolveCompletionBudget } from '../../utils/completion-budget'; +import { LlmRequestLogger, splitGenerateOptions } from '../llm-request-logger'; +import { KosongLLM } from '../turn/kosong-llm'; + +/** + * Narrow view of the agent config service that {@link LlmService} needs to + * build requests and the {@link KosongLLM}. Kept minimal (rather than taking + * the whole `IAgentConfigService`) so the service is easy to construct in + * tests and stays decoupled from config's wider surface. `IAgentConfigService` + * satisfies this structurally. + */ +export interface LlmServiceConfig { + readonly modelAlias: string | undefined; + readonly provider: ChatProvider; + readonly maxOutputSize: number | undefined; + readonly systemPrompt: string; + readonly modelCapabilities: ModelCapability; +} + +/** + * Explicit dependencies for {@link LlmService}. Captures everything the + * `generate`/`llm` getters read from `Agent` so the bodies can move here + * byte-for-byte without reaching back into the agent instance. + */ +export interface LlmServiceDeps { + readonly config: LlmServiceConfig; + readonly llmRequestLogger: LlmRequestLogger; + readonly rawGenerate: typeof generate; + readonly modelProvider?: ModelProvider | undefined; + readonly log: Logger; + readonly kimiConfig?: KimiConfig | undefined; +} + +export interface ILlmService { + readonly generate: typeof generate; + readonly llm: KosongLLM; +} + +/** + * Owns the `generate` getter (request-log injection + request-scoped auth + * resolution) and the `llm` getter (`KosongLLM` construction). `Agent` + * delegates to an instance of this; behavior is byte-identical to the former + * inline getters. + */ +export class LlmService implements ILlmService { + constructor(private readonly deps: LlmServiceDeps) {} + + get generate(): typeof generate { + const { config, llmRequestLogger, rawGenerate, modelProvider, log } = this.deps; + return async (provider, systemPrompt, tools, history, callbacks, options) => { + const { requestLogFields, generateOptions } = splitGenerateOptions(options); + const modelAlias = config.modelAlias; + const run = (requestOptions: Parameters[5]) => { + llmRequestLogger.logRequest({ + provider, + modelAlias, + systemPrompt, + tools, + messages: history, + fields: requestLogFields, + }); + return rawGenerate(provider, systemPrompt, tools, history, callbacks, requestOptions); + }; + if (generateOptions?.auth !== undefined) { + return run(generateOptions); + } + const withAuth = + modelAlias === undefined ? undefined : modelProvider?.resolveAuth?.(modelAlias, { log }); + if (withAuth === undefined) { + return run(generateOptions); + } + return withAuth((auth) => { + return run({ ...generateOptions, auth }); + }); + }; + } + + get llm(): KosongLLM { + // All provider-level request config (thinking, sampling params, thinking.keep) + // is applied in ConfigState.provider so compaction shares it. See get provider(). + const { config, kimiConfig } = this.deps; + const provider = config.provider; + const loopControl = kimiConfig?.loopControl; + const completionBudgetConfig = resolveCompletionBudget({ + maxOutputSize: config.maxOutputSize, + reservedContextSize: loopControl?.reservedContextSize, + }); + return new KosongLLM({ + provider, + systemPrompt: config.systemPrompt, + capability: config.modelCapabilities, + generate: this.generate, + completionBudgetConfig, + }); + } +} diff --git a/packages/agent-core/src/agent/permission/index.ts b/packages/agent-core/src/agent/permission/index.ts index 4df71cc90..4488b00b4 100644 --- a/packages/agent-core/src/agent/permission/index.ts +++ b/packages/agent-core/src/agent/permission/index.ts @@ -1,4 +1,5 @@ import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import type { PrepareToolExecutionResult } from '../../loop'; import { createPermissionDecisionPolicies } from './policies'; import type { @@ -313,3 +314,18 @@ export class PermissionManager { return prefix; } } + +export interface IPermissionService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): PermissionManager; +} + +export const IPermissionService = createDecorator('permissionService'); + +export class PermissionService extends PermissionManager implements IPermissionService { + readonly _serviceBrand: undefined; + unwrap(): PermissionManager { + return this; + } +} diff --git a/packages/agent-core/src/agent/plan/index.ts b/packages/agent-core/src/agent/plan/index.ts index fdaafdbb1..03550b568 100644 --- a/packages/agent-core/src/agent/plan/index.ts +++ b/packages/agent-core/src/agent/plan/index.ts @@ -1,8 +1,16 @@ import { randomUUID } from 'node:crypto'; import { dirname, join } from 'pathe'; -import type { Agent } from '..'; -import { generateHeroSlug } from '../../utils/hero-slug'; +import type { Kaos } from '@moonshot-ai/kaos'; +import { createDecorator } from '../../_base/di'; +import { IAgentConfigService } from '../config'; +import { IContextService } from '../context'; +import { ILifecycleService } from '../lifecycle'; +import { IRecordsService } from '../records'; +import { IReplayService } from '../replay'; +import { IAgentStatusService } from '../status'; +import { exitReminder, fullReminder, reentryReminder, sparseReminder } from '../injection/plan-mode'; +import { generateHeroSlug } from '#/_utils/slug'; export type PlanData = null | { id: string; @@ -11,12 +19,45 @@ export type PlanData = null | { }; export type PlanFilePath = string | null; +const PLAN_MODE_DEDUP_MIN_TURNS = 2; +const PLAN_MODE_FULL_REFRESH_TURNS = 5; + export class PlanMode { protected _isActive = false; protected _planId: null | string = null; protected _planFilePath: PlanFilePath = null; - - constructor(protected readonly agent: Agent) {} + private wasActive = false; + private injectedAt: number | null = null; + + constructor( + private readonly kaos?: Kaos, + private readonly homedir?: string, + @IAgentStatusService private readonly statusService?: IAgentStatusService, + @IRecordsService private readonly records?: IRecordsService, + @IReplayService private readonly replayBuilder?: IReplayService, + @IAgentConfigService private readonly config?: IAgentConfigService, + @ILifecycleService lifecycle?: ILifecycleService, + @IContextService private readonly context?: IContextService, + ) { + lifecycle?.onBeforePrompt(async (ctx) => { + const reminder = await this.computeReminder(); + if (reminder !== undefined) { + this.injectedAt = this.context?.history.length ?? null; + ctx.injectSystemReminder(reminder, { + kind: 'injection', + variant: 'plan_mode', + }); + } + }); + lifecycle?.onContextMessageRemoved((index) => { + if (this.injectedAt === null) return; + if (index < this.injectedAt) { + this.injectedAt -= 1; + } else if (index === this.injectedAt) { + this.injectedAt = null; + } + }); + } createPlanId(): string { return generateHeroSlug(randomUUID(), new Set()); @@ -36,7 +77,7 @@ export class PlanMode { const planFilePath = this.planFilePathFor(id); this._planFilePath = planFilePath; await this.ensurePlanDirectory(planFilePath); - this.agent.records.logRecord({ type: 'plan_mode.enter', id }); + this.records?.logRecord({ type: 'plan_mode.enter', id }); enterRecorded = true; if (createFile) { await this.writeEmptyPlanFile(planFilePath); @@ -52,11 +93,11 @@ export class PlanMode { throw error; } - if (emitStatus) this.agent.emitStatusUpdated(); + if (emitStatus) this.statusService?.notifyStatusChanged(); } restoreEnter({ id }: { readonly id: string }): void { - this.agent.replayBuilder.push({ + this.replayBuilder?.push({ type: 'plan_updated', enabled: true, }); @@ -67,15 +108,15 @@ export class PlanMode { } cancel(id?: string): void { - this.agent.records.logRecord({ type: 'plan_mode.cancel', id }); - this.agent.replayBuilder.push({ + this.records?.logRecord({ type: 'plan_mode.cancel', id }); + this.replayBuilder?.push({ type: 'plan_updated', enabled: false, }); this._isActive = false; this._planId = null; this._planFilePath = null; - this.agent.emitStatusUpdated(); + this.statusService?.notifyStatusChanged(); } async clear(): Promise { @@ -84,15 +125,15 @@ export class PlanMode { } exit(id?: string): void { - this.agent.records.logRecord({ type: 'plan_mode.exit', id }); - this.agent.replayBuilder.push({ + this.records?.logRecord({ type: 'plan_mode.exit', id }); + this.replayBuilder?.push({ type: 'plan_updated', enabled: false, }); this._isActive = false; this._planId = null; this._planFilePath = null; - this.agent.emitStatusUpdated(); + this.statusService?.notifyStatusChanged(); } get isActive() { @@ -107,7 +148,7 @@ export class PlanMode { if (!this._planId || !this._planFilePath) return null; let content = ''; try { - content = await this.agent.kaos.readText(this._planFilePath); + content = (await this.kaos?.readText(this._planFilePath)) ?? ''; } catch (error) { if (!isMissingFileError(error)) throw error; } @@ -118,13 +159,63 @@ export class PlanMode { }; } + private async computeReminder(): Promise { + if (!this._isActive) { + if (!this.wasActive) return undefined; + this.wasActive = false; + this.injectedAt = null; + return exitReminder(); + } + if (!this.wasActive) { + this.injectedAt = null; + this.wasActive = true; + if (await this.hasCurrentPlanContent()) { + return reentryReminder(this._planFilePath); + } + } + const variant = this.getVariant(); + if (variant === null) return undefined; + return variant === 'full' + ? fullReminder(this._planFilePath) + : variant === 'sparse' + ? sparseReminder(this._planFilePath) + : reentryReminder(this._planFilePath); + } + + private getVariant(): 'full' | 'sparse' | 'reentry' | null { + if (this.injectedAt === null) return 'full'; + const history = this.context?.history ?? []; + let assistantTurnsSince = 0; + for (let i = this.injectedAt + 1; i < history.length; i++) { + const msg = history[i]; + if (msg === undefined) continue; + if (msg.role === 'assistant') { + assistantTurnsSince += 1; + continue; + } + if (msg.role === 'user') return 'full'; + } + if (assistantTurnsSince >= PLAN_MODE_FULL_REFRESH_TURNS) return 'full'; + if (assistantTurnsSince >= PLAN_MODE_DEDUP_MIN_TURNS) return 'sparse'; + return null; + } + + private async hasCurrentPlanContent(): Promise { + try { + const data = await this.data(); + return data !== null && data.content.trim().length > 0; + } catch { + return false; + } + } + private async writeEmptyPlanFile(path: string): Promise { await this.ensurePlanDirectory(path); - await this.agent.kaos.writeText(path, ''); + await this.kaos?.writeText(path, ''); } private async ensurePlanDirectory(path: string): Promise { - await this.agent.kaos.mkdir(dirname(path), { + await this.kaos?.mkdir(dirname(path), { parents: true, existOk: true, }); @@ -132,13 +223,28 @@ export class PlanMode { private planFilePathFor(id: string): string { const plansDir = - this.agent.homedir === undefined - ? join(this.agent.config.cwd, 'plan') - : join(this.agent.homedir, 'plans'); + this.homedir === undefined + ? join(this.config?.cwd ?? '', 'plan') + : join(this.homedir, 'plans'); return join(plansDir, `${id}.md`); } } +export interface IPlanService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): PlanMode; +} + +export const IPlanService = createDecorator('planService'); + +export class PlanService extends PlanMode implements IPlanService { + readonly _serviceBrand: undefined; + unwrap(): PlanMode { + return this; + } +} + function isMissingFileError(error: unknown): boolean { if (error === null || typeof error !== 'object') return false; const code = (error as { readonly code?: unknown }).code; diff --git a/packages/agent-core/src/agent/profile/index.ts b/packages/agent-core/src/agent/profile/index.ts new file mode 100644 index 000000000..78f692f02 --- /dev/null +++ b/packages/agent-core/src/agent/profile/index.ts @@ -0,0 +1,54 @@ +import type { Kaos } from '@moonshot-ai/kaos'; + +import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../../profile'; +import type { IAgentConfigService } from '../config'; +import type { IAgentSkillService } from '../skill'; +import type { IAgentToolService } from '../tool'; + +/** + * Narrow read-only view of the agent that {@link AgentProfileService} needs in + * order to apply a resolved profile. `Agent` satisfies this structurally, but + * the service depends only on this interface — never on the concrete `Agent` + * class — so tests can drive it with a plain stub. + * + * The service reads these fields at `useProfile()` call-time (after the agent + * has finished constructing), which is why this host can be handed to the + * service before the underlying services have been resolved, and why no DI + * cycle is introduced: the service is not injected back into any of the + * services it coordinates. + */ +export interface AgentProfileHost { + readonly kaos: Kaos; + readonly config: IAgentConfigService; + readonly skills: IAgentSkillService | null; + readonly tools: IAgentToolService; +} + +/** + * Owns the agent's `useProfile()` behavior: render the profile's system prompt + * against the live runtime context, push `{ profileName, systemPrompt }` into + * config, and activate the profile's tool set. + */ +export interface IAgentProfileService { + /** + * Applies a resolved profile to the agent. Mirrors the former + * `Agent.useProfile` signature exactly. + */ + useProfile(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void; +} + +export class AgentProfileService implements IAgentProfileService { + constructor(private readonly host: AgentProfileHost) {} + + useProfile(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void { + const systemPrompt = profile.systemPrompt({ + osEnv: this.host.kaos.osEnv, + cwd: this.host.config.cwd, + skills: this.host.skills?.registry, + cwdListing: context?.cwdListing, + agentsMd: context?.agentsMd, + }); + this.host.config.update({ profileName: profile.name, systemPrompt }); + this.host.tools.setActiveTools(profile.tools); + } +} diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 1e7cd035c..f32ba121b 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -1,4 +1,6 @@ +import { ErrorCodes, makeErrorPayload } from '#/errors'; import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import { AGENT_WIRE_PROTOCOL_VERSION, isNewerWireVersion, @@ -145,7 +147,7 @@ export class AgentRecords { private metadataInitialized = false; constructor( - private readonly agent: Agent, + protected readonly agent: Agent, private readonly persistence?: AgentRecordPersistence, ) {} @@ -242,3 +244,38 @@ export class AgentRecords { await this.persistence?.flush(); } } + +export interface IRecordsService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): AgentRecords; + emitWriteError(error: unknown, record?: AgentRecord): void; +} + +export const IRecordsService = createDecorator('recordsService'); + +export class RecordsService extends AgentRecords implements IRecordsService { + readonly _serviceBrand: undefined; + unwrap(): AgentRecords { + return this; + } + + emitWriteError(error: unknown, record?: AgentRecord): void { + const message = error instanceof Error ? error.message : String(error); + this.agent.log.error('wire record persist failed', { + agentHomedir: this.agent.homedir, + recordType: record?.type, + error, + }); + this.agent.emitEvent({ + type: 'error', + ...makeErrorPayload( + ErrorCodes.RECORDS_WRITE_FAILED, + `Failed to write agent records: ${message}`, + { + details: { recordType: record?.type }, + }, + ), + }); + } +} diff --git a/packages/agent-core/src/agent/records/persistence.ts b/packages/agent-core/src/agent/records/persistence.ts index 0e51942fa..a91c9e807 100644 --- a/packages/agent-core/src/agent/records/persistence.ts +++ b/packages/agent-core/src/agent/records/persistence.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs'; import { mkdir, open } from 'node:fs/promises'; import { dirname } from 'pathe'; -import { syncDir } from '../../utils/fs'; +import { syncDir } from '#/_utils/fs'; import type { BlobStore } from './blobref'; import { type AgentRecord, type AgentRecordPersistence } from './types'; diff --git a/packages/agent-core/src/agent/replay/index.ts b/packages/agent-core/src/agent/replay/index.ts index aea14be96..497f2be53 100644 --- a/packages/agent-core/src/agent/replay/index.ts +++ b/packages/agent-core/src/agent/replay/index.ts @@ -1,4 +1,5 @@ -import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; +import { IRecordsService } from '../records'; import type { AgentReplayRecord, AgentReplayRecordPayload } from '../../rpc/resumed'; import type { ContextMessage } from '../context'; @@ -21,16 +22,16 @@ export class ReplayBuilder { private segmentStart = 0; constructor( - public readonly agent: Agent, private readonly options: ReplayBuilderOptions = {}, + @IRecordsService private readonly agentRecords?: IRecordsService, ) {} push(record: AgentReplayRecordPayload): void { - if (this.captureLiveRecords || this.agent.records.restoring || this.postRestoring) { + if (this.captureLiveRecords || this.agentRecords?.restoring || this.postRestoring) { if (this.frozen) return; const stamped: AgentReplayRecord = { ...record, - time: this.agent.records.restoring?.time ?? Date.now(), + time: this.agentRecords?.restoring?.time ?? Date.now(), }; this.records.push(stamped); } @@ -41,7 +42,7 @@ export class ReplayBuilder { patch: Partial>, ): void { if (this.frozen) return; - if (this.agent.records.restoring) { + if (this.agentRecords?.restoring) { const last = this.records.at(-1); if (last && last.type === type) { Object.assign(last, patch); @@ -102,3 +103,18 @@ export class ReplayBuilder { } } } + +export interface IReplayService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): ReplayBuilder; +} + +export const IReplayService = createDecorator('replayService'); + +export class ReplayService extends ReplayBuilder implements IReplayService { + readonly _serviceBrand: undefined; + unwrap(): ReplayBuilder { + return this; + } +} diff --git a/packages/agent-core/src/agent/resume/index.ts b/packages/agent-core/src/agent/resume/index.ts new file mode 100644 index 000000000..10645aeb0 --- /dev/null +++ b/packages/agent-core/src/agent/resume/index.ts @@ -0,0 +1,118 @@ +import type { IBackgroundService } from '../background'; +import type { IContextService } from '../context'; +import type { ICronService } from '../cron'; +import type { IGoalService } from '../goal'; +import type { ILifecycleService } from '../lifecycle'; +import type { AgentRecordsReplayOptions, IRecordsService } from '../records'; +import type { IReplayService } from '../replay'; +import type { ITurnService } from '../turn'; + +/** + * Narrow read-only view of the agent that {@link AgentResumeService} needs in + * order to run the serialized `resume()` orchestration. `Agent` satisfies this + * structurally, but the service depends only on this interface — never on the + * concrete `Agent` class — so tests can drive it with a plain stub. + * + * The service reads these fields at `resume()` call-time (after the agent has + * finished constructing), which is why this host can be handed to the service + * before the underlying services have been resolved, and why no DI cycle is + * introduced: the service is not injected back into any of the services it + * orchestrates. + */ +export interface AgentResumeHost { + readonly id: string | undefined; + readonly lifecycle: ILifecycleService; + readonly records: IRecordsService; + readonly replayBuilder: IReplayService; + readonly goal: IGoalService; + readonly background: IBackgroundService; + readonly cron: ICronService | null; + readonly context: IContextService; + readonly turn: ITurnService; +} + +/** + * Owns the agent's serialized `resume()` orchestration: replay the records, + * then restore each runtime stage (goal / background / cron / context / turn) + * under a `try/finally` that guards the replay builder's `postRestoring` flag. + * + * Event-ized (M5.3): the resume body runs as an `onAgentWillResume` handler + * rather than being invoked inline. The {@link resume} trigger fires + * `fireAgentWillResume`; the subscribed handler performs the work, bracketed + * by the `fireAgentWillResume` / `fireAgentDidResume` lifecycle hooks. + */ +export interface IAgentResumeService { + /** + * Triggers a resume and returns the replay result. Mirrors the former + * `Agent.resume` signature and return shape exactly. + */ + resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }>; +} + +export class AgentResumeService implements IAgentResumeService { + /** + * Per-call handoff between the {@link resume} trigger and the + * `onAgentWillResume` handler that performs the work. `resume()` is + * serialized per agent (the `SessionHost` dedupes concurrent resumes by + * agent id), so a single pending slot per service is sufficient. + */ + private pendingOptions: AgentRecordsReplayOptions | undefined; + private pendingResult: { warning?: string } | undefined; + + constructor(private readonly host: AgentResumeHost) { + // The resume body runs as an `onAgentWillResume` handler. The trigger + // (`resume()`) fires the hook; this handler replays the records and + // restores the runtime stages, then captures the replay result so the + // trigger can return it. The handler deliberately does NOT fire + // `fireAgentWillResume` — the trigger owns that — so there is exactly one + // WillResume fire and no recursion. + this.host.lifecycle.onAgentWillResume(() => this.runResume()); + } + + /** + * Triggers a resume by firing `fireAgentWillResume`; the subscribed handler + * performs the actual replay + stage restoration. Returns the replay result + * (including any `warning`) once the handler completes. For id-less agents + * the lifecycle hooks are skipped (matching the former behavior) and the + * body runs directly. + */ + async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { + this.pendingOptions = options; + this.pendingResult = undefined; + try { + if (this.host.id !== undefined) { + await this.host.lifecycle.fireAgentWillResume({ agentId: this.host.id }); + } else { + await this.runResume(); + } + return this.pendingResult ?? {}; + } finally { + this.pendingOptions = undefined; + this.pendingResult = undefined; + } + } + + /** + * The serialized resume body: replay the records, restore each runtime stage + * under the `postRestoring` guard, then fire `fireAgentDidResume` and capture + * the replay result for the trigger to return. + */ + private async runResume(): Promise { + const result = await this.host.records.replay(this.pendingOptions); + try { + this.host.replayBuilder.postRestoring = true; + this.host.goal.normalizeAfterReplay(); + await this.host.background.loadFromDisk(); + await this.host.background.reconcile(); + await this.host.cron?.loadFromDisk(); + this.host.context.finishResume(); + this.host.turn.finishResume(); + } finally { + this.host.replayBuilder.postRestoring = false; + } + if (this.host.id !== undefined) { + await this.host.lifecycle.fireAgentDidResume({ agentId: this.host.id }); + } + this.pendingResult = result; + } +} diff --git a/packages/agent-core/src/agent/rpc-controller.ts b/packages/agent-core/src/agent/rpc-controller.ts new file mode 100644 index 000000000..da36cb125 --- /dev/null +++ b/packages/agent-core/src/agent/rpc-controller.ts @@ -0,0 +1,185 @@ +import type { AgentAPI } from '#/rpc'; +import { ErrorCodes, KimiError } from '#/errors'; + +import type { ModelProvider } from '../session/provider-manager'; +import type { ISubagentHostService } from '../session/subagent-host'; +import type { TelemetryClient } from '../telemetry'; +import type { PromisableMethods } from '#/_utils/types'; +import type { IBackgroundService } from './background'; +import type { ICompactionService } from './compaction'; +import type { IAgentConfigService } from './config'; +import type { IContextService } from './context'; +import type { IGoalService } from './goal'; +import type { IPermissionService } from './permission'; +import type { IPlanService } from './plan'; +import type { IAgentSkillService } from './skill'; +import type { ISwarmService } from './swarm'; +import type { IAgentToolService } from './tool/index'; +import type { ITurnService } from './turn'; +import type { IUsageService } from './usage'; + +/** + * Narrow read-only view of the agent that {@link AgentRpcController} needs in + * order to build the `rpcMethods` map. `Agent` satisfies this structurally, + * but the controller depends only on this interface — never on the concrete + * `Agent` class — so tests can drive it with a plain stub. + * + * The controller reads these fields lazily inside each RPC handler (after the + * agent has finished constructing), which is why this host can be handed to the + * controller before the underlying services have been resolved, and why no DI + * cycle is introduced: the controller is not injected back into any of the + * services it delegates to. + */ +export interface AgentRpcHost { + readonly turn: ITurnService; + readonly telemetry: TelemetryClient; + readonly context: IContextService; + readonly config: IAgentConfigService; + readonly permission: IPermissionService; + readonly modelProvider?: ModelProvider; + readonly planMode: IPlanService; + readonly swarmMode: ISwarmService; + readonly fullCompaction: ICompactionService; + readonly tools: IAgentToolService; + readonly background: IBackgroundService; + readonly skills: IAgentSkillService | null; + readonly subagentHost?: ISubagentHostService; + readonly goal: IGoalService; + readonly usage: IUsageService; +} + +/** + * Owns the agent's `rpcMethods` map: a pure delegation layer over the agent's + * services plus the telemetry side-effects that fire alongside some handlers. + */ +export interface IAgentRpcController { + /** + * The map of RPC method handlers exposed by the agent. Each access returns a + * fresh method map whose handlers delegate to the agent's services and emit + * the same telemetry events as the former `Agent.rpcMethods` getter. + */ + readonly rpcMethods: PromisableMethods; +} + +export class AgentRpcController implements IAgentRpcController { + constructor(private readonly host: AgentRpcHost) {} + + get rpcMethods(): PromisableMethods { + return { + prompt: (payload) => { + this.host.turn.prompt(payload.input); + }, + steer: (payload) => { + this.host.telemetry.track('input_steer', { parts: payload.input.length }); + this.host.turn.steer(payload.input); + }, + cancel: (payload) => { + if (this.host.turn.hasActiveTurn) { + this.host.telemetry.track('cancel', { from: 'streaming' }); + } + this.host.turn.cancel(payload.turnId); + }, + undoHistory: (payload) => { + this.host.context.undo(payload.count); + }, + setThinking: (payload) => { + const wasEnabled = this.host.config.thinkingLevel !== 'off'; + this.host.config.update({ thinkingLevel: payload.level }); + const enabled = this.host.config.thinkingLevel !== 'off'; + if (enabled !== wasEnabled) { + this.host.telemetry.track('thinking_toggle', { enabled }); + } + }, + setPermission: (payload) => { + const wasYolo = this.host.permission.mode === 'yolo'; + const wasAuto = this.host.permission.mode === 'auto'; + this.host.permission.setMode(payload.mode); + const enabled = this.host.permission.mode === 'yolo'; + if (enabled !== wasYolo) { + this.host.telemetry.track('yolo_toggle', { enabled }); + } + const afkEnabled = this.host.permission.mode === 'auto'; + if (afkEnabled !== wasAuto) { + this.host.telemetry.track('afk_toggle', { enabled: afkEnabled }); + } + }, + setModel: (payload) => { + // Validate the alias resolves before recording it so resume / runtime + // callers fail fast on missing aliases instead of deferring to the + // next prompt. + const resolved = this.host.modelProvider?.resolveProviderConfig(payload.model); + if (this.host.config.modelAlias !== payload.model) { + this.host.config.update({ modelAlias: payload.model }); + this.host.telemetry.track('model_switch', { model: payload.model }); + } + return { + model: payload.model, + providerName: resolved?.providerName, + }; + }, + getModel: () => { + return this.host.config.modelAlias ?? ''; + }, + enterPlan: async () => { + await this.host.planMode.enter(); + }, + cancelPlan: (payload) => { + this.host.planMode.cancel(payload.id); + }, + clearPlan: () => this.host.planMode.clear(), + enterSwarm: (payload) => { + this.host.swarmMode.enter(payload.trigger); + }, + exitSwarm: () => { + this.host.swarmMode.exit(); + }, + getSwarmMode: () => { + return this.host.swarmMode.isActive; + }, + beginCompaction: (payload) => { + this.host.fullCompaction.begin({ source: 'manual', instruction: payload.instruction }); + }, + cancelCompaction: () => { + if (this.host.fullCompaction.isCompacting) { + this.host.telemetry.track('cancel', { from: 'compacting' }); + } + this.host.fullCompaction.cancel(); + }, + registerTool: (payload) => { + this.host.tools.registerUserTool(payload); + }, + unregisterTool: (payload) => { + this.host.tools.unregisterUserTool(payload.name); + }, + setActiveTools: (payload) => { + this.host.tools.setActiveTools(payload.names); + }, + stopBackground: (payload) => { + void this.host.background.stop(payload.taskId, payload.reason); + }, + clearContext: () => { + this.host.context.clear(); + }, + activateSkill: (payload) => { + if (this.host.skills === null) { + throw new KimiError(ErrorCodes.SKILL_NOT_FOUND, `Skill "${payload.name}" was not found`); + } + this.host.skills.activate(payload); + }, + startBtw: () => this.host.subagentHost!.startBtw(), + createGoal: (payload) => this.host.goal.createGoal(payload), + getGoal: () => this.host.goal.getGoal(), + pauseGoal: () => this.host.goal.pauseGoal(), + resumeGoal: () => this.host.goal.resumeGoal(), + cancelGoal: () => this.host.goal.cancelGoal(), + getBackgroundOutput: (payload) => this.host.background.readOutput(payload.taskId, payload.tail), + getContext: () => this.host.context.data(), + getConfig: () => this.host.config.data(), + getPermission: () => this.host.permission.data(), + getPlan: () => this.host.planMode.data(), + getUsage: () => this.host.usage.data(), + getTools: () => this.host.tools.data(), + getBackground: (payload) => this.host.background.list(payload.activeOnly ?? false, payload.limit), + }; + } +} diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 684122fb1..edca71612 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -4,6 +4,7 @@ import type { ActivateSkillPayload } from '#/rpc'; import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import { ErrorCodes, KimiError } from '#/errors'; import { isUserActivatableSkillType } from '../../skill'; import type { SkillActivationOrigin } from '../context'; @@ -84,3 +85,18 @@ export class SkillManager { } } } + +export interface IAgentSkillService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): SkillManager; +} + +export const IAgentSkillService = createDecorator('agentSkillService'); + +export class AgentSkillService extends SkillManager implements IAgentSkillService { + readonly _serviceBrand: undefined; + unwrap(): SkillManager { + return this; + } +} diff --git a/packages/agent-core/src/agent/skill/prompt.ts b/packages/agent-core/src/agent/skill/prompt.ts index 54968b7ab..d402d015c 100644 --- a/packages/agent-core/src/agent/skill/prompt.ts +++ b/packages/agent-core/src/agent/skill/prompt.ts @@ -1,4 +1,4 @@ -import { escapeXml } from '#/utils/xml-escape'; +import { escapeXml } from '#/_utils/xml'; import type { SkillSource } from '../../skill'; export type SkillPromptTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; diff --git a/packages/agent-core/src/agent/status/index.ts b/packages/agent-core/src/agent/status/index.ts new file mode 100644 index 000000000..ae0a9e918 --- /dev/null +++ b/packages/agent-core/src/agent/status/index.ts @@ -0,0 +1,79 @@ +import type { UsageStatus } from '#/rpc'; + +import { createDecorator } from '../../_base/di'; +import type { IDomainEventBus } from '#/event'; +import type { IAgentConfigService } from '../config'; +import type { IContextService } from '../context'; +import type { IPermissionService } from '../permission'; +import type { IPlanService } from '../plan'; +import type { IRecordsService } from '../records'; +import type { ISwarmService } from '../swarm'; +import type { IUsageService } from '../usage'; + +/** + * Narrow read-only view of the agent that {@link AgentStatusService} needs in + * order to build the `agent.status.updated` payload. `Agent` satisfies this + * structurally, but the service depends only on this interface — never on the + * concrete `Agent` class — so tests can drive it with a plain stub. + * + * All fields are read lazily inside `notifyStatusChanged()` (after the agent + * has finished constructing), which is why this host can be handed to the + * service before the underlying services have been resolved. + */ +export interface AgentStatusHost { + readonly records: IRecordsService; + readonly config: IAgentConfigService; + readonly context: IContextService; + readonly usage: IUsageService; + readonly planMode: IPlanService; + readonly swarmMode: ISwarmService; + readonly permission: IPermissionService; + readonly eventBus: IDomainEventBus; +} + +export interface IAgentStatusService { + readonly _serviceBrand: undefined; + + /** + * Recompute and publish the `agent.status.updated` event. Callers (plan / + * swarm / usage / context / config / permission) invoke this after mutating + * status-driving state; the event payload and the conditions under which it + * fires are identical to the former `Agent.emitStatusUpdated()`. + */ + notifyStatusChanged(): void; +} + +export const IAgentStatusService = createDecorator('agentStatusService'); + +export class AgentStatusService implements IAgentStatusService { + readonly _serviceBrand: undefined; + + constructor(private readonly host: AgentStatusHost) {} + + notifyStatusChanged(): void { + const { records, config, context, usage, planMode, swarmMode, permission, eventBus } = this.host; + if (records.restoring) return; + if (!config.hasModel) return; + + const contextTokens = context.tokenCount; + const maxContextTokens = config.modelCapabilities.max_context_tokens; + const contextUsage = + maxContextTokens !== undefined && maxContextTokens > 0 + ? contextTokens / maxContextTokens + : undefined; + const usageStatus: UsageStatus | undefined = usage.status(); + const model = config.model; + + eventBus.publish({ + type: 'agent.status.updated', + model, + contextTokens, + maxContextTokens, + contextUsage, + planMode: planMode.isActive, + swarmMode: swarmMode.isActive, + permission: permission.mode, + usage: usageStatus, + }); + } +} diff --git a/packages/agent-core/src/agent/swarm/index.ts b/packages/agent-core/src/agent/swarm/index.ts index f90ce97e3..acf44dbc8 100644 --- a/packages/agent-core/src/agent/swarm/index.ts +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -1,4 +1,7 @@ -import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; +import { IContextService } from '../context'; +import { IRecordsService } from '../records'; +import { IAgentStatusService } from '../status'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; @@ -13,19 +16,23 @@ export type SwarmModeTrigger = 'manual' | 'task' | 'tool'; export class SwarmMode { protected active: SwarmModeTrigger | null = null; - constructor(protected readonly agent: Agent) {} + constructor( + @IAgentStatusService private readonly statusService?: IAgentStatusService, + @IRecordsService private readonly records?: IRecordsService, + @IContextService private readonly context?: IContextService, + ) {} enter(trigger: SwarmModeTrigger): void { if (this.active !== null) return; - this.agent.records.logRecord({ type: 'swarm_mode.enter', trigger }); + this.records?.logRecord({ type: 'swarm_mode.enter', trigger }); this.active = trigger; if (trigger !== 'tool') { - this.agent.context.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { + this.context?.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { kind: 'injection', variant: 'swarm_mode', }); } - this.agent.emitStatusUpdated(); + this.statusService?.notifyStatusChanged(); } restoreEnter(trigger: SwarmModeTrigger): void { @@ -34,16 +41,16 @@ export class SwarmMode { exit(): void { if (this.active === null) return; - this.agent.records.logRecord({ type: 'swarm_mode.exit' }); + this.records?.logRecord({ type: 'swarm_mode.exit' }); const trigger = this.active; this.active = null; - this.agent.emitStatusUpdated(); + this.statusService?.notifyStatusChanged(); if (trigger === 'tool') return; - if (this.agent.context.popMatchedMessage((origin) => origin?.kind === 'injection' && origin.variant === 'swarm_mode')) { + if (this.context?.popMatchedMessage((origin) => origin?.kind === 'injection' && origin.variant === 'swarm_mode')) { return; } - if (!this.agent.records.restoring) { - this.agent.context.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { + if (!this.records?.restoring) { + this.context?.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { kind: 'injection', variant: 'swarm_mode_exit', }); @@ -58,3 +65,19 @@ export class SwarmMode { return this.active === 'task' || this.active === 'tool'; } } + +export interface ISwarmService extends Pick { + readonly _serviceBrand: undefined; + + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): SwarmMode; +} + +export const ISwarmService = createDecorator('swarmService'); + +export class SwarmService extends SwarmMode implements ISwarmService { + readonly _serviceBrand: undefined; + unwrap(): SwarmMode { + return this; + } +} diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index b80a87f31..446a99c8c 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -3,10 +3,11 @@ import type { ChatProvider, Tool } from '@moonshot-ai/kosong'; import picomatch from 'picomatch'; import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import { makeErrorPayload } from '../../errors'; import type { ExecutableTool } from '../../loop'; import { createMcpAuthTool } from '../../mcp/auth-tool'; -import type { McpConnectionManager, McpServerEntry } from '../../mcp'; +import type { IMcpConnectionService, McpServerEntry } from '../../mcp'; import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient } from '../../mcp/types'; @@ -203,7 +204,7 @@ export class ToolManager { return true; } - private handleMcpServerStatusChange(mcp: McpConnectionManager, entry: McpServerEntry): void { + private handleMcpServerStatusChange(mcp: IMcpConnectionService, entry: McpServerEntry): void { if (entry.status === 'connected') { this.registerConnectedMcpServer(mcp, entry); return; @@ -233,7 +234,7 @@ export class ToolManager { } } - private registerNeedsAuthMcpServer(mcp: McpConnectionManager, entry: McpServerEntry): void { + private registerNeedsAuthMcpServer(mcp: IMcpConnectionService, entry: McpServerEntry): void { // Replace whatever tools (real or synthetic) were registered before; a // server flipping to needs-auth means previous tokens were invalidated. this.unregisterMcpServer(entry.name); @@ -264,7 +265,7 @@ export class ToolManager { }); } - private registerConnectedMcpServer(mcp: McpConnectionManager, entry: McpServerEntry): void { + private registerConnectedMcpServer(mcp: IMcpConnectionService, entry: McpServerEntry): void { const resolved = mcp.resolved(entry.name); if (resolved === undefined) return; const result = this.registerMcpServer( @@ -361,8 +362,9 @@ export class ToolManager { kaos, toolServices, config: { cwd, provider, modelCapabilities }, - background, + background: backgroundService, } = this.agent; + const background = backgroundService.unwrap(); const videoUploader = this.createVideoUploader(provider); const workspace = extendWorkspaceWithSkillRoots( { @@ -400,9 +402,9 @@ export class ToolManager { new b.TaskListTool(background), new b.TaskOutputTool(background), new b.TaskStopTool(background), - this.agent.cron && new b.CronCreateTool(this.agent.cron), - this.agent.cron && new b.CronListTool(this.agent.cron), - this.agent.cron && new b.CronDeleteTool(this.agent.cron), + this.agent.cron && new b.CronCreateTool(this.agent.cron.unwrap()), + this.agent.cron && new b.CronListTool(this.agent.cron.unwrap()), + this.agent.cron && new b.CronDeleteTool(this.agent.cron.unwrap()), this.agent.skills?.registry.listInvocableSkills().length && new b.SkillTool(this.agent), this.agent.subagentHost && @@ -415,7 +417,7 @@ export class ToolManager { }, ), this.agent.subagentHost && - new b.AgentSwarmTool(this.agent.subagentHost, this.agent.swarmMode), + new b.AgentSwarmTool(this.agent.subagentHost, this.agent.swarmMode.unwrap()), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), ] @@ -460,3 +462,18 @@ export class ToolManager { .filter((tool) => !!tool); } } + +export interface IAgentToolService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): ToolManager; +} + +export const IAgentToolService = createDecorator('agentToolService'); + +export class AgentToolService extends ToolManager implements IAgentToolService { + readonly _serviceBrand: undefined; + unwrap(): ToolManager { + return this; + } +} diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 7cd2e7330..c9fdbc947 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -16,6 +16,7 @@ import { import { basename } from 'pathe'; import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; import { ErrorCodes, type KimiErrorPayload, @@ -35,7 +36,7 @@ import { } from '../../loop/index'; import type { AgentEvent, TurnEndedEvent } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; -import { abortable, isUserCancellation, userCancellationReason } from '../../utils/abort'; +import { abortable, isUserCancellation, userCancellationReason } from '#/_utils/abort'; import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; @@ -440,6 +441,7 @@ export class TurnFlow { signal: AbortSignal, standalone: boolean, ): Promise { + await this.agent.lifecycle.fireTurnWillStart({ turnId }); this.currentStep = 0; this.stepToolCallKeys.clear(); this.toolCallDupType.clear(); @@ -451,6 +453,7 @@ export class TurnFlow { this.agent.usage.beginTurn(); this.agent.emitEvent({ type: 'turn.started', turnId, origin }); this.agent.context.appendUserMessage(input, origin); + await this.agent.lifecycle.fireTurnDidStart({ turnId }); const startedAt = Date.now(); let ended: TurnEndedEvent; @@ -503,6 +506,10 @@ export class TurnFlow { this.agent.telemetry.track('api_error', properties); } } + } finally { + // Fires whether the turn completed, was cancelled, or failed; a rejecting + // handler rejects the fire call (matching fireBeforePrompt semantics). + await this.agent.lifecycle.fireTurnDidEnd({ turnId }); } // Emit the terminal turn.ended and (for a standalone turn) release the active // turn in the SAME synchronous frame, so the session is observably idle the @@ -1049,6 +1056,21 @@ function toolInputRecord(args: unknown): Record { return isPlainRecord(args) ? args : {}; } +export interface ITurnService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): TurnFlow; +} + +export const ITurnService = createDecorator('turnService'); + +export class TurnService extends TurnFlow implements ITurnService { + readonly _serviceBrand: undefined; + unwrap(): TurnFlow { + return this; + } +} + function toolOutputText(output: ExecutableToolResult['output']): string { if (typeof output === 'string') return output; return output diff --git a/packages/agent-core/src/agent/usage/index.ts b/packages/agent-core/src/agent/usage/index.ts index e978e7d56..c1abc5341 100644 --- a/packages/agent-core/src/agent/usage/index.ts +++ b/packages/agent-core/src/agent/usage/index.ts @@ -1,7 +1,9 @@ import type { UsageStatus } from '#/rpc'; import { addUsage, type TokenUsage } from '@moonshot-ai/kosong'; -import type { Agent } from '..'; +import { createDecorator } from '../../_base/di'; +import { IRecordsService } from '../records'; +import { IAgentStatusService } from '../status'; export type UsageRecordScope = 'session' | 'turn'; @@ -13,7 +15,10 @@ export class UsageRecorder { private readonly byModel: Record = {}; private currentTurn: TokenUsage | undefined; - constructor(protected readonly agent?: Agent) {} + constructor( + @IAgentStatusService private readonly statusService?: IAgentStatusService, + @IRecordsService private readonly records?: IRecordsService, + ) {} beginTurn(): void { this.currentTurn = undefined; @@ -24,7 +29,7 @@ export class UsageRecorder { } record(model: string, usage: TokenUsage, scope: UsageRecordScope = 'session'): void { - this.agent?.records.logRecord({ + this.records?.logRecord({ type: 'usage.record', model, usage, @@ -37,7 +42,7 @@ export class UsageRecorder { this.currentTurn = this.currentTurn === undefined ? copyUsage(usage) : addUsage(this.currentTurn, usage); } - this.agent?.emitStatusUpdated(); + this.statusService?.notifyStatusChanged(); } data(): UsageStatus { @@ -70,6 +75,23 @@ export class UsageRecorder { } } +export interface IUsageService extends Pick { + readonly _serviceBrand: undefined; + + /** @internal migration bridge — reach the raw recorder; do not use in new code. */ + unwrap(): UsageRecorder; +} + +export const IUsageService = createDecorator('usageService'); + +export class UsageService extends UsageRecorder implements IUsageService { + readonly _serviceBrand: undefined; + + unwrap(): UsageRecorder { + return this; + } +} + function totalUsage(byModel: Record): TokenUsage | undefined { let total: TokenUsage | undefined; for (const usage of Object.values(byModel)) { diff --git a/packages/agent-core/src/services/approval/approval.ts b/packages/agent-core/src/approval/approval.ts similarity index 98% rename from packages/agent-core/src/services/approval/approval.ts rename to packages/agent-core/src/approval/approval.ts index 104149675..161c2a96a 100644 --- a/packages/agent-core/src/services/approval/approval.ts +++ b/packages/agent-core/src/approval/approval.ts @@ -51,8 +51,8 @@ * handler. */ -import { createDecorator } from '../../di'; -import type { ApprovalRequest, ApprovalResponse } from '../../rpc'; +import { createDecorator } from '#/_base/di'; +import type { ApprovalRequest, ApprovalResponse } from '#/rpc'; import type { ApprovalRequest as ProtocolApprovalRequest, ApprovalResponse as ProtocolApprovalResponse, diff --git a/packages/agent-core/src/approval/index.ts b/packages/agent-core/src/approval/index.ts new file mode 100644 index 000000000..bdf0b9c9f --- /dev/null +++ b/packages/agent-core/src/approval/index.ts @@ -0,0 +1,11 @@ +// approval/index.ts — public contract surface (pure contract: no *Service impl, +// no tools, so no registerApprovalServices / registerApprovalTools). +// Mirrors the surface historically re-exported from services/index.ts so the +// package root barrel stays byte-for-byte compatible for consumers like server. +export { IApprovalService } from './approval'; +export type { ApprovalRequest, ApprovalResponse } from './approval'; +export { + toAgentCoreResponse as approvalToAgentCoreResponse, + toBrokerRequest as approvalToBrokerRequest, + type ToBrokerRequestParams as ApprovalToBrokerRequestParams, +} from './approval'; diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..96bc0e1cc 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -22,7 +22,7 @@ import { type ThinkingConfig, validateConfig, } from '#/config/schema'; -import { atomicWrite } from '#/utils/fs'; +import { atomicWrite } from '#/_utils/fs'; import { parse as parseToml, stringify as stringifyToml, TomlError } from 'smol-toml'; /* ------------------------------------------------------------------ */ diff --git a/packages/agent-core/src/services/coreProcess/coreProcess.ts b/packages/agent-core/src/coreProcess/coreProcess.ts similarity index 62% rename from packages/agent-core/src/services/coreProcess/coreProcess.ts rename to packages/agent-core/src/coreProcess/coreProcess.ts index 65895cf9e..5b0bb0a1f 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcess.ts +++ b/packages/agent-core/src/coreProcess/coreProcess.ts @@ -17,6 +17,17 @@ * consumers; the public package barrel does NOT re-export `SDKRpcClientBase`, * so daemon-side code stays one abstraction layer away. * + * Facade: + * - `ICoreRuntime` is the public runtime facade: `rpc`, `ready()`, + * `dispose()`, and `getCoreApi()` (in-process wire-controller access). + * Consumers inject `@ICoreRuntime`. + * + * DI token: + * - The decorator string remains `'coreProcessService'` for now (renaming it + * to `'coreRuntime'` is deferred — see M7.1 STATUS). The deprecated + * process-service alias was removed in M7.1; `ICoreRuntime` is now the + * sole identifier and resolves against that unchanged string token. + * * Lifecycle: * - `ready()` resolves when both the `KimiCore` plugin/config load AND the * SDK-side RPC binding have settled. Construction is eager (Singleton @@ -24,13 +35,16 @@ * - `dispose()` is idempotent. It flips an internal flag so future `rpc` * method dispatch throws before reaching `KimiCore`, then walks the * `Disposable` child stack. `KimiCore` itself has no `dispose()` today — - * when it gets one, we wire it here. + * tearing down its `SessionHost` instances is async (`SessionHost.close()`) + * and cannot be awaited from this synchronous `dispose()` contract, so + * core tear-down is deferred (see M6.3 notes); the adapter-level dispose + * still short-circuits `rpc.*` / `getCoreApi()` post-dispose. * * Role: cross-process adapter — see `packages/services/AGENTS.md`. */ -import { createDecorator } from '../../di'; -import type { CoreRPC, KimiCoreOptions } from '../../rpc'; +import { createDecorator } from '#/_base/di'; +import type { CoreRPC, KimiCoreOptions } from '../rpc'; import { type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; export interface CoreProcessServiceOptions extends KimiCoreOptions { @@ -52,7 +66,7 @@ export interface CoreProcessServiceOptions extends KimiCoreOptions { readonly identity?: KimiHostIdentity; } -export interface ICoreProcessService { +export interface ICoreRuntime { readonly _serviceBrand: undefined; /** The core RPC methods. Service impls call e.g. `core.rpc.createSession(...)`. */ @@ -66,10 +80,26 @@ export interface ICoreProcessService { /** * Tear down the adapter. After dispose, `rpc.(...)` rejects with a - * "core process disposed" error before reaching `KimiCore`. Idempotent. + * "core process disposed" error before reaching `KimiCore`, and + * `getCoreApi()` throws. Idempotent. */ dispose(): void; + + /** + * In-process (zero-serialization) CoreAPI handle. Returns the underlying + * `KimiCore` directly so in-package services (e.g. `PromptService`) can + * route per-agent calls without crossing the `createRPC` JSON + * serialize/deserialize boundary that backs the `rpc` proxy. + * + * Method signatures and return shapes are identical to `rpc`; the only + * difference is the absence of the serialize hop. Throws after dispose, + * mirroring the `rpc` proxy's post-dispose contract. + */ + getCoreApi(): CoreRPC; } +// The decorator string stays `'coreProcessService'` (rename deferred; see +// M7.1 STATUS). `createDecorator` keys identifiers by name, so this string is +// the canonical DI token every consumer resolves against. // eslint-disable-next-line @typescript-eslint/no-redeclare -export const ICoreProcessService = createDecorator('coreProcessService'); +export const ICoreRuntime = createDecorator('coreProcessService'); diff --git a/packages/agent-core/src/services/coreProcess/coreProcessClient.ts b/packages/agent-core/src/coreProcess/coreProcessClient.ts similarity index 90% rename from packages/agent-core/src/services/coreProcess/coreProcessClient.ts rename to packages/agent-core/src/coreProcess/coreProcessClient.ts index 24e78e15c..4771ce9a6 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessClient.ts +++ b/packages/agent-core/src/coreProcess/coreProcessClient.ts @@ -15,12 +15,12 @@ * NOT here. The peer-service interfaces stay SDK-shaped. */ -import type { ApprovalRequest, ApprovalResponse, Event, QuestionRequest, QuestionResult, SDKAPI, ToolCallRequest, ToolCallResponse } from '../../rpc'; +import type { ApprovalRequest, ApprovalResponse, Event, QuestionRequest, QuestionResult, SDKAPI, ToolCallRequest, ToolCallResponse } from '../rpc'; -import type { IApprovalService } from '../approval/approval'; -import type { IEventService } from '../event/event'; -import type { ILogService } from '../logger/logger'; -import type { IQuestionService } from '../question/question'; +import type { IApprovalService } from '#/approval'; +import type { IEventService } from '#/event'; +import type { ILogService } from '../services/logger/logger'; +import type { IQuestionService } from '#/question'; export interface CoreProcessClientDeps { readonly eventService: IEventService; diff --git a/packages/agent-core/src/services/coreProcess/coreProcessService.ts b/packages/agent-core/src/coreProcess/coreProcessService.ts similarity index 65% rename from packages/agent-core/src/services/coreProcess/coreProcessService.ts rename to packages/agent-core/src/coreProcess/coreProcessService.ts index 1865e9838..e7a58368a 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessService.ts +++ b/packages/agent-core/src/coreProcess/coreProcessService.ts @@ -1,26 +1,26 @@ /** - * `CoreProcessService` — implementation of `ICoreProcessService`. + * `CoreProcessService` — implementation of `ICoreRuntime`. */ -import { createRPC, KimiCore } from '../../rpc'; -import { Disposable, registerSingleton, SyncDescriptor } from '../../di'; -import type { CoreAPI, CoreRPC, SDKAPI } from '../../rpc'; -import type { OAuthTokenProviderResolver } from '../../session/provider-manager'; +import { createRPC, KimiCore } from '../rpc'; +import { Disposable, IInstantiationService, registerSingleton, SyncDescriptor } from '#/_base/di'; +import type { CoreAPI, CoreRPC, SDKAPI } from '../rpc'; +import type { OAuthTokenProviderResolver } from '../session/provider-manager'; import { createKimiDefaultHeaders, type KimiHostIdentity, } from '@moonshot-ai/kimi-code-oauth'; -import { createManagedAuthFacade } from '../auth/managedAuth'; +import { createManagedAuthFacade } from '../services/auth/managedAuth'; import { BridgeClientAPI } from './coreProcessClient'; -import { IApprovalService } from '../approval/approval'; -import { IEnvironmentService } from '../environment/environment'; -import { IEventService } from '../event/event'; -import { ILogService } from '../logger/logger'; -import { IQuestionService } from '../question/question'; -import { ICoreProcessService, type CoreProcessServiceOptions } from './coreProcess'; - -export class CoreProcessService extends Disposable implements ICoreProcessService { +import { IApprovalService } from '#/approval'; +import { IEnvironmentService } from '../services/environment/environment'; +import { IEventService } from '#/event'; +import { ILogService } from '../services/logger/logger'; +import { IQuestionService } from '#/question'; +import { ICoreRuntime, type CoreProcessServiceOptions } from './coreProcess'; + +export class CoreProcessService extends Disposable implements ICoreRuntime { readonly _serviceBrand: undefined; /** @@ -58,6 +58,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic @IApprovalService approvalService: IApprovalService, @IQuestionService questionService: IQuestionService, @ILogService logService: ILogService, + @IInstantiationService private readonly ix: IInstantiationService, ) { super(); @@ -65,51 +66,24 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic // function KimiCore receives, `sdkRpc` is the one we satisfy. const [coreRpc, sdkRpc] = createRPC(); - // Default-wire the OAuth token resolver. Without this, KimiCore's - // `ProviderManager.resolveAuth` sees `resolveOAuthTokenProvider === - // undefined` and synthesizes a closure that ALWAYS throws - // `AUTH_LOGIN_REQUIRED` — even after a successful device-code login that - // persisted a fresh token to disk. The daemon's `/auth` readiness probe - // is a different code path (file existence on the credentials store) so - // it stays green; the failure only surfaces inside the prompt turn, as - // an `auth.login_required` error after `turn.step.started`. We bridge - // the gap by default-constructing a managed auth facade against the same - // home + config paths KimiCore will use, and handing its - // `resolveOAuthTokenProvider` into the core. Callers (e.g. node-sdk - // tests) can still override via `options.resolveOAuthTokenProvider`. - const resolveOAuthTokenProvider: OAuthTokenProviderResolver = - options.resolveOAuthTokenProvider ?? - CoreProcessService._defaultOAuthTokenResolver(env.homeDir, env.configPath); - - // Default-wire the Kimi request headers (User-Agent + X-Msh-* device - // identity). Without this, KimiCore's outbound fetch carries the - // default Node fetch User-Agent and the managed Kimi-for-Coding - // endpoint rejects with 40340 ("only available for Coding Agents - // such as Kimi CLI, Claude Code, …"). Mirrors what `SDKRpcClient` - // does for the in-process TUI path (node-sdk's sdk-rpc-client.ts). - // Caller-supplied `kimiRequestHeaders` always wins; absent that, we - // synthesize from `options.identity`. Hosts that pass neither - // (no identity, no headers) still construct — but their requests will - // trip the 40340 guard. - const kimiRequestHeaders: Record | undefined = - options.kimiRequestHeaders ?? - CoreProcessService._defaultKimiRequestHeaders(env.homeDir, options.identity); - - // `appVersion` flows into Session records (`app_version`) and tool - // call ctx. Prefer explicit > identity.version so callers can pin - // a different value if they need to. - const appVersion: string | undefined = - options.appVersion ?? options.identity?.version; - // 2. Construct the core. KimiCore's ctor wires itself into `coreRpc` and // exposes `this.sdk: Promise` for the reverse direction. + // + // The cross-cutting defaults (OAuth token resolver, Kimi request + // headers, identity-derived `appVersion`) are computed by the host + // bootstrap (`packages/server/src/start.ts`) and handed in via + // `options`. This adapter is intentionally thin: it forwards + // `options` through to KimiCore and only overrides `homeDir` / + // `configPath` from the resolved environment so the daemon's + // canonical paths win over any caller-supplied values, and injects + // the DI `instantiationService`. See `_defaultOAuthTokenResolver` / + // `_defaultKimiRequestHeaders` for the default-wiring logic the + // bootstrap now owns. this._core = new KimiCore(coreRpc, { ...options, homeDir: env.homeDir, configPath: env.configPath, - kimiRequestHeaders, - appVersion, - resolveOAuthTokenProvider, + instantiationService: this.ix, }); // 3. Satisfy the SDK side with a BridgeClientAPI that routes to peer services. @@ -137,12 +111,45 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic return this._ready; } + /** + * In-process (zero-serialization) CoreAPI handle. Returns the underlying + * `KimiCore` directly so in-package services (e.g. `PromptService`) can + * route per-agent calls without crossing the `createRPC` JSON + * serialize/deserialize boundary that backs the `rpc` proxy (see + * `simulateNetwork` in `src/rpc/client.ts`). + * + * Method signatures and return shapes are identical to `rpc`; the only + * difference is the absence of the serialize hop and the + * controlled-promise dispatch. Throws after dispose, mirroring the `rpc` + * proxy's post-dispose contract. + * + * Advertised on the `ICoreRuntime` facade (promoted from a concrete-only + * seam in M6.3) so the in-process, serialization-free path is part of the + * runtime contract rather than a localized cast. + */ + getCoreApi(): CoreRPC { + if (this._store.isDisposed) { + throw new Error('CoreProcessService has been disposed'); + } + // `KimiCore` implements `PromisableMethods`; `CoreRPC` is the + // promisified `RPCMethods` (adds an optional `options` param). + // At runtime the KimiCore methods satisfy the `CoreRPC` contract — they + // accept the payload, ignore the extra options arg, and return promises + // — so the cast is a type-level accommodation only. + return this._core as unknown as CoreRPC; + } + override dispose(): void { if (this._store.isDisposed) return; - // KimiCore does not currently expose a dispose() — when it does, we'll - // await/call it here BEFORE super.dispose(). For now, disposing the - // service flips _disposed, which makes future rpc.* invocations reject - // before they reach KimiCore. + // KimiCore does not currently expose a dispose(), and its session + // tear-down (`SessionHost.close()`) is async — it disposes agents, stops + // crons, cancels active turns (with a timeout), shuts down MCP, and + // closes log sinks — which cannot be awaited from this synchronous + // `IDisposable`-style contract. Bridging it with fire-and-forget would + // risk unhandled rejections and partial teardown, so core tear-down is + // deferred (M6.3). Disposing the service flips `_disposed`, which makes + // future `rpc.*` invocations and `getCoreApi()` reject/throw before they + // reach KimiCore, then walks the Disposable child stack. super.dispose(); } @@ -231,13 +238,13 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic // `(options, @IEnvironmentService, @IEventService, @IApprovalService, // @IQuestionService, @ILogService)` — the leading `options` slot is a pure data bag so we // register with `[{}]` as a sane default. Daemon-side `start.ts` overrides -// this descriptor via `services.set(ICoreProcessService, new +// this descriptor via `services.set(ICoreRuntime, new // SyncDescriptor(CoreProcessService, [opts.coreProcessOptions ?? {}], false))` // when it has access to the real options bag. Later registrations win — both // at registry level and at `ServiceCollection` level. // `supportsDelayedInstantiation = false` preserves current reverse-dispose // semantics. registerSingleton( - ICoreProcessService, + ICoreRuntime, new SyncDescriptor(CoreProcessService, [{} as CoreProcessServiceOptions], false), ); diff --git a/packages/agent-core/src/coreProcess/index.ts b/packages/agent-core/src/coreProcess/index.ts new file mode 100644 index 000000000..dbaac507c --- /dev/null +++ b/packages/agent-core/src/coreProcess/index.ts @@ -0,0 +1,4 @@ +export { BridgeClientAPI } from './coreProcessClient'; +export type { CoreProcessClientDeps } from './coreProcessClient'; +export { ICoreRuntime, type CoreProcessServiceOptions } from './coreProcess'; +export { CoreProcessService } from './coreProcessService'; diff --git a/packages/agent-core/src/di/test.ts b/packages/agent-core/src/di/test.ts deleted file mode 100644 index 2ff2d15ba..000000000 --- a/packages/agent-core/src/di/test.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - createServices, - TestInstantiationService, -} from './testInstantiationService'; -export type { ServiceIdCtorPair } from './testInstantiationService'; diff --git a/packages/agent-core/src/errors/index.ts b/packages/agent-core/src/errors/index.ts index 66379cbe4..317c7f3c5 100644 --- a/packages/agent-core/src/errors/index.ts +++ b/packages/agent-core/src/errors/index.ts @@ -15,10 +15,3 @@ export { toKimiErrorPayload, type KimiErrorPayload, } from './serialize'; -export { - onUnexpectedError, - resetUnexpectedErrorHandler, - safelyCallListener, - setUnexpectedErrorHandler, - type UnexpectedErrorHandler, -} from './unexpectedError'; diff --git a/packages/agent-core/src/event/event-bus.ts b/packages/agent-core/src/event/event-bus.ts new file mode 100644 index 000000000..f8227c170 --- /dev/null +++ b/packages/agent-core/src/event/event-bus.ts @@ -0,0 +1,57 @@ +import type { AgentEvent } from '#/rpc'; +import { createDecorator } from '../_base/di'; +import type { IDisposable } from '../_base/di'; + +export interface IDomainEventBus { + readonly _serviceBrand: undefined; + + publish(event: AgentEvent): void; + + subscribe( + type: T, + handler: (event: Extract) => void, + ): IDisposable; + + subscribeAll(handler: (event: AgentEvent) => void): IDisposable; +} + +export const IDomainEventBus = createDecorator('agentEventBus'); + +type AnyHandler = (event: AgentEvent) => void; + +export class DomainEventBus implements IDomainEventBus { + readonly _serviceBrand: undefined; + + private readonly typed = new Map>(); + private readonly all = new Set(); + + constructor(private readonly forward?: (event: AgentEvent) => void) {} + + publish(event: AgentEvent): void { + const typed = this.typed.get(event.type); + if (typed !== undefined) { + for (const handler of typed) handler(event); + } + for (const handler of this.all) handler(event); + this.forward?.(event); + } + + subscribe( + type: T, + handler: (event: Extract) => void, + ): IDisposable { + let set = this.typed.get(type); + if (set === undefined) { + set = new Set(); + this.typed.set(type, set); + } + const h = handler as AnyHandler; + set.add(h); + return { dispose: () => set.delete(h) }; + } + + subscribeAll(handler: (event: AgentEvent) => void): IDisposable { + this.all.add(handler); + return { dispose: () => this.all.delete(handler) }; + } +} diff --git a/packages/agent-core/src/services/event/event.ts b/packages/agent-core/src/event/event.ts similarity index 92% rename from packages/agent-core/src/services/event/event.ts rename to packages/agent-core/src/event/event.ts index 9e1d224a2..7a13f58c8 100644 --- a/packages/agent-core/src/services/event/event.ts +++ b/packages/agent-core/src/event/event.ts @@ -25,21 +25,21 @@ * (e.g. `PromptService.onDidComplete`, `SessionService.onDidCreate`). */ -import { createDecorator } from '../../di'; -import type { Event } from '../../base/common/event'; +import { createDecorator } from '#/_base/di'; +import type { Event } from '#/_base/event'; import type { Event as ProtocolEvent } from '@moonshot-ai/protocol'; /** * Naming convention inside this file: * - * - `Event` (from `@moonshot-ai/agent-core/base/common/event`) — the generic + * - `Event` (from `@moonshot-ai/agent-core/_base/event`) — the generic * VSCode-style emitter accessor type. `Event` is the listener-tuple * type used to declare `readonly onDidXxx: Event`. * - `ProtocolEvent` (alias of `@moonshot-ai/protocol`'s `Event`) — the * wire-level event union published through the bus. Aliased here because * the top-level `Event` symbol must refer to the emitter type so the * accessor declarations read naturally (`Event` not - * `import('…/base/common/event').Event`). + * `import('…/_base/event').Event`). */ export interface IEventService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core/src/services/event/eventService.ts b/packages/agent-core/src/event/eventService.ts similarity index 96% rename from packages/agent-core/src/services/event/eventService.ts rename to packages/agent-core/src/event/eventService.ts index ce3622a4c..b732ae939 100644 --- a/packages/agent-core/src/services/event/eventService.ts +++ b/packages/agent-core/src/event/eventService.ts @@ -13,8 +13,8 @@ * Publishing after `dispose()` is a no-op. */ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; -import { Emitter } from '../../base/common/event'; +import { Disposable, InstantiationType, registerSingleton } from '#/_base/di'; +import { Emitter } from '#/_base/event'; import type { Event as ProtocolEvent } from '@moonshot-ai/protocol'; import { IEventService } from './event'; diff --git a/packages/agent-core/src/event/index.ts b/packages/agent-core/src/event/index.ts new file mode 100644 index 000000000..f1e9b734b --- /dev/null +++ b/packages/agent-core/src/event/index.ts @@ -0,0 +1,25 @@ +/** + * `event` domain barrel (di-v3). + * + * Two buses live here: + * + * - {@link IEventService} / {@link EventService} (`./event.ts` / `./eventService.ts`) + * — the daemon's in-process pub-sub bus for the protocol `Event` union + * (`AgentEvent & { agentId, sessionId }`). `WSBroadcastService` + * (`@moonshot-ai/server`) subscribes via `onDidPublish` to do WS fan-out. + * + * - {@link IDomainEventBus} / {@link DomainEventBus} (`./event-bus.ts`) — the + * agent's in-process pub-sub for bare `AgentEvent`s (no transport stamps). + * + * {@link shouldProjectToProtocol} (`./projection.ts`) is the documented, + * tested policy pinning the projection between the two. + * + * Internal support files are not re-exported. The contract + impl are named + * exports (not `export *`) so the barrel surface stays explicit and mirrors + * the symbols `services/index.ts` used to re-export for this domain. + */ + +export { IEventService } from './event'; +export { EventService } from './eventService'; +export { DomainEventBus, IDomainEventBus } from './event-bus'; +export { shouldProjectToProtocol } from './projection'; diff --git a/packages/agent-core/src/event/projection.ts b/packages/agent-core/src/event/projection.ts new file mode 100644 index 000000000..6c290b6e5 --- /dev/null +++ b/packages/agent-core/src/event/projection.ts @@ -0,0 +1,63 @@ +/** + * Domain → protocol event projection boundary. + * + * Two buses sit on either side of the agent/daemon split: + * + * - {@link IDomainEventBus} (`./event-bus.ts`) — the agent's in-process + * pub-sub for `AgentEvent` (domain events produced by the `Agent` / turn + * loop and the per-domain services). It carries bare domain events with no + * `agentId` / `sessionId` stamped on them. + * + * - `IEventService` (`./event.ts`) — the daemon's transport + * bus. A pub-sub for the protocol `Event` + * (= `AgentEvent & { agentId, sessionId }`). `WSBroadcastService` + * (`@moonshot-ai/server/services/WSBroadcastService`) subscribes to it via + * `onDidPublish` to do WS fan-out, journaling, and replay. + * + * The projection between the two is the `forward` callback `DomainEventBus` + * is constructed with. In production it is wired in + * `agent/factory.ts` as: + * + * ```ts + * new DomainEventBus((event) => { + * if (!agent.records.restoring) void agent.rpc?.emitEvent?.(event); + * }); + * ``` + * + * `agent.rpc.emitEvent` crosses the in-process RPC to the daemon, where + * `BridgeClientAPI.emitEvent` + * (`coreProcess/coreProcessClient.ts`) calls + * `IEventService.publish(event)`. So every domain event that flows through + * `IDomainEventBus` lands on `IEventService` and therefore reaches + * `WSBroadcastService`. + * + * **Current policy: every `AgentEvent` published on `IDomainEventBus` is + * projected to the protocol bus.** There is no per-event-type filter — the + * only gate is the `agent.records.restoring` lifecycle flag inside the + * `forward` callback, which is a runtime replay/restore state, not a + * per-type rule. {@link shouldProjectToProtocol} encodes this policy so the + * boundary has a single, testable source of truth. + * + * This module is documentation + a pure helper. It intentionally does NOT + * change which events are projected, and it leaves `IEventService` and + * `WSBroadcastService` untouched. + */ + +import type { AgentEvent } from '#/rpc'; + +/** + * Returns whether a domain `AgentEvent` published on {@link IDomainEventBus} + * is projected onto the protocol transport bus (`IEventService`), and + * therefore forwarded to `WSBroadcastService`. + * + * Today the projection is total: every domain event projects. The helper + * exists so the boundary is named and pinned by tests; when a future policy + * starts dropping events (e.g. in-process-only diagnostics) this is the + * single place the rule changes. + */ +export function shouldProjectToProtocol(event: AgentEvent): boolean { + // Reference the parameter so the signature stays meaningful even though the + // current policy is unconditional — every domain event projects. + void event; + return true; +} diff --git a/packages/agent-core/src/flags/resolver.ts b/packages/agent-core/src/flags/resolver.ts index 2f12608a7..a8149d230 100644 --- a/packages/agent-core/src/flags/resolver.ts +++ b/packages/agent-core/src/flags/resolver.ts @@ -1,4 +1,5 @@ import { parseBooleanEnv } from '#/config/resolve'; +import { createDecorator } from '../_base/di'; import { FLAG_DEFINITIONS, type FlagId } from './registry'; import type { @@ -91,6 +92,21 @@ export class FlagResolver { } } +export interface IFlagService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw resolver; do not use in new code. */ + unwrap(): FlagResolver; +} + +export const IFlagService = createDecorator('flagService'); + +export class FlagService extends FlagResolver implements IFlagService { + readonly _serviceBrand: undefined; + unwrap(): FlagResolver { + return this; + } +} + /** * Compatibility accessor for callers that only need process-global env behavior. * Runtime code that belongs to a KimiCore/Session/Agent should use the scoped diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 14dcec22a..34b2deba8 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -14,10 +14,9 @@ export { log, redact, resolveGlobalLogPath, -} from './logging/logger'; -export { resolveLoggingConfig } from './logging/resolve-config'; -export type { ResolveLoggingInput } from './logging/resolve-config'; -export { installGlobalProxyDispatcher } from './utils/proxy'; + resolveLoggingConfig, +} from './_base/logging'; +export { installGlobalProxyDispatcher } from './_utils/net'; export type { LogContext, LogEntry, @@ -25,10 +24,11 @@ export type { LogPayload, Logger, LoggingConfig, + ResolveLoggingInput, RootLogger, SessionAttachInput, SessionLogHandle, -} from './logging/types'; +} from './_base/logging'; export { USER_PROMPT_ORIGIN } from './agent/context'; export type { AgentContextData, @@ -83,18 +83,59 @@ export type { } from './loop/types'; // ─── Dependency injection container ──────────────────────────────────────── -export * from './di'; +export * from './_base/di'; + +// ─── Base — unexpected-error reporting ───────────────────────────────────── +// `onUnexpectedError` / `safelyCallListener` / `setUnexpectedErrorHandler` / +// `resetUnexpectedErrorHandler` / `UnexpectedErrorHandler` were historically +// re-exported via `./errors`; they now live in `_base/errors`. Re-exporting +// here keeps the package root surface unchanged for consumers like `server`. +export * from './_base/errors'; + +// ─── Approval contract (di-v3) ───────────────────────────────────────────── +// keeps package root surface unchanged for server +export * from './approval'; + +// ─── Event contract (di-v3) ──────────────────────────────────────────────── +// keeps package root surface unchanged for server (`IEventService` / `EventService`) +export * from './event'; + +// ─── Question contract (di-v3) ───────────────────────────────────────────── +// keeps package root surface unchanged for server (`IQuestionService` / `QuestionRequest`) +export * from './question'; + +// ─── CoreProcess contract (di-v3) ────────────────────────────────────────── +// keeps package root surface unchanged for server (`ICoreRuntime` / `CoreProcessService`) +export * from './coreProcess'; + +// ─── Message contract (di-v3) ────────────────────────────────────────────── +// keeps package root surface unchanged for server (`IMessageService` / `toProtocolMessage`) +export * from './message'; + +// ─── Prompt contract (di-v3) ─────────────────────────────────────────────── +// keeps package root surface unchanged for server (`IPromptService` / `PromptService`) +export * from './prompt'; + +// ─── Scope mechanism (di-v3) ─────────────────────────────────────────────── +// Exposes `LifecycleScope`, `registerScopedService` / +// `getScopedServiceDescriptors` / `markBuilt` / `isBuilt`, the `I*Context` +// identity decorators, `IScopeHandle` / `IServiceAccessor`, the +// `ScopeBuilder` family, and the manager-pattern base/contracts. The scope +// barrel is explicit and its names do not collide with the rest of this +// top-level surface (verified before re-exporting), so a wildcard re-export +// is safe here. +export * from './scope'; // ─── Base — Event / Emitter ────────────────────────────────────────── // NOTE: only `Emitter` is re-exported from the top-level barrel — the new // VSCode-style `Event` symbol collides with `./rpc`'s `Event` (agent-core // protocol Event union, exported via `export * from './rpc'` above). Callers // that need the emitter `Event` type import it from the explicit sub-path -// `@moonshot-ai/agent-core/base/common/event` (declared in `package.json` +// `@moonshot-ai/agent-core/_base/event` (declared in `package.json` // `exports`). This keeps the existing top-level `Event` semantics stable for // consumers like `services/src/event/event.ts` while letting new code reach // for the emitter type without naming clashes. -export { Emitter } from './base/common/event'; +export { Emitter } from './_base/event'; // ─── In-process services (merged from @moonshot-ai/services) ───────────────── // Re-exports the `IXxxService` contracts, default `XxxService` implementations, diff --git a/packages/agent-core/src/loop/retry.ts b/packages/agent-core/src/loop/retry.ts index 199b409e3..cbf659442 100644 --- a/packages/agent-core/src/loop/retry.ts +++ b/packages/agent-core/src/loop/retry.ts @@ -1,9 +1,9 @@ import { sleep } from '@antfu/utils'; import * as retry from 'retry'; -import type { Logger } from '#/logging/types'; +import type { Logger } from '#/_base/logging'; -import { abortable } from '../utils/abort'; +import { abortable } from '#/_utils/abort'; import type { LoopEventDispatcher } from './events'; import { isAbortError } from './errors'; import type { LLM, LLMChatParams, LLMChatResponse } from './llm'; diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts index 326dba854..c7fc35003 100644 --- a/packages/agent-core/src/loop/run-turn.ts +++ b/packages/agent-core/src/loop/run-turn.ts @@ -8,7 +8,7 @@ import { addUsage, emptyUsage, type TokenUsage } from '@moonshot-ai/kosong'; -import type { Logger } from '#/logging/types'; +import type { Logger } from '#/_base/logging'; import { createMaxStepsExceededError, diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 1468f9cd2..2cb82599b 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -15,7 +15,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; -import type { Logger } from '#/logging/types'; +import type { Logger } from '#/_base/logging'; import { compileToolArgsValidator, validateToolArgs, @@ -24,7 +24,7 @@ import { } from '../tools/args-validator'; import { PathSecurityError } from '../tools/policies/path-access'; -import { isUserCancellation } from '../utils/abort'; +import { isUserCancellation } from '#/_utils/abort'; import { errorMessage, isAbortError } from './errors'; import type { LoopEventDispatcher, LoopToolCallEvent } from './events'; import type { LLM, LLMChatResponse } from './llm'; diff --git a/packages/agent-core/src/loop/turn-step.ts b/packages/agent-core/src/loop/turn-step.ts index b06cd67df..96a200cfd 100644 --- a/packages/agent-core/src/loop/turn-step.ts +++ b/packages/agent-core/src/loop/turn-step.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto'; import type { TokenUsage } from '@moonshot-ai/kosong'; -import type { Logger } from '#/logging/types'; +import type { Logger } from '#/_base/logging'; import type { LoopEventDispatcher } from './events'; import type { LLM, LLMChatParams, LLMChatResponse } from './llm'; diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index adffda0ca..837b0ee98 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -1,6 +1,6 @@ import { ErrorCodes, KimiError } from '#/errors'; import type { McpServerStdioConfig } from '#/config/schema'; -import { proxyEnvForChild, reconcileChildNoProxy } from '#/utils/proxy'; +import { proxyEnvForChild, reconcileChildNoProxy } from '#/_utils/net'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 7d3c9c1f3..15128d51b 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -1,10 +1,11 @@ import { ErrorCodes, KimiError } from '#/errors'; import type { McpServerConfig } from '#/config/schema'; -import { log as defaultLog } from '#/logging/logger'; -import type { Logger } from '#/logging/types'; +import { log as defaultLog } from '#/_base/logging'; +import type { Logger } from '#/_base/logging'; import type { Tool } from '@moonshot-ai/kosong'; -import { abortable } from '../utils/abort'; +import { abortable } from '#/_utils/abort'; +import { createDecorator } from '../_base/di'; import { HttpMcpClient } from './client-http'; import { isRemoteMcpConfig } from './client-remote'; import { SseMcpClient } from './client-sse'; @@ -465,6 +466,21 @@ function isUnauthorizedLikeError(error: unknown): boolean { return /\b401\b/.test(error.message) || /unauthorized/i.test(error.message); } +export interface IMcpConnectionService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): McpConnectionManager; +} + +export const IMcpConnectionService = createDecorator('mcpConnectionService'); + +export class McpConnectionService extends McpConnectionManager implements IMcpConnectionService { + readonly _serviceBrand: undefined; + unwrap(): McpConnectionManager { + return this; + } +} + function formatStartupError(error: unknown, client: RuntimeMcpClient | undefined): string { const base = error instanceof Error ? error.message : String(error); const tail = stderrTail(client); diff --git a/packages/agent-core/src/message/index.ts b/packages/agent-core/src/message/index.ts new file mode 100644 index 000000000..f47d0e1a1 --- /dev/null +++ b/packages/agent-core/src/message/index.ts @@ -0,0 +1,15 @@ +export { + IMessageService, + MessageNotFoundError, + deriveMessageId, + parseMessageId, + toProtocolMessage, +} from './message'; +export type { MessageListQuery } from './message'; +export { MessageService } from './messageService'; +export { + readWireRecords, + readWireTranscript, + reduceWireRecords, +} from './transcript'; +export type { TranscriptEntry, WireTranscript } from './transcript'; diff --git a/packages/agent-core/src/services/message/message.ts b/packages/agent-core/src/message/message.ts similarity index 98% rename from packages/agent-core/src/services/message/message.ts rename to packages/agent-core/src/message/message.ts index 07bfe9877..eb4c6aa05 100644 --- a/packages/agent-core/src/services/message/message.ts +++ b/packages/agent-core/src/message/message.ts @@ -1,7 +1,7 @@ /** * `IMessageService` — daemon-facing message history interface. * - * Wraps `ICoreProcessService.rpc.getContext({sessionId, agentId})` and adapts + * Wraps `ICoreRuntime.rpc.getContext({sessionId, agentId})` and adapts * agent-core's `ContextMessage` history shape (kosong `Message` + origin) to * the protocol's SCHEMAS.md §3 `Message` discriminated-by-content union. * @@ -43,8 +43,8 @@ * at the route layer. This impl receives a fully-validated query. */ -import { createDecorator } from '../../di'; -import type { ContextMessage } from '../../agent/context'; +import { createDecorator } from '#/_base/di'; +import type { ContextMessage } from '../agent/context'; import type { CursorQuery, Message, diff --git a/packages/agent-core/src/services/message/messageService.ts b/packages/agent-core/src/message/messageService.ts similarity index 85% rename from packages/agent-core/src/services/message/messageService.ts rename to packages/agent-core/src/message/messageService.ts index 65e644623..d8cf893d6 100644 --- a/packages/agent-core/src/services/message/messageService.ts +++ b/packages/agent-core/src/message/messageService.ts @@ -22,15 +22,15 @@ import { stat } from 'node:fs/promises'; import path from 'node:path'; -import { Disposable, InstantiationType, registerSingleton } from '../../di'; -import type { SessionSummary } from '../../rpc'; +import { Disposable, InstantiationType, registerSingleton } from '#/_base/di'; +import type { CoreRPC, SessionSummary } from '../rpc'; import type { Message, PageResponse, } from '@moonshot-ai/protocol'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; -import { SessionNotFoundError } from '../session/session'; +import { ICoreRuntime } from '#/coreProcess'; +import { SessionNotFoundError } from '#/session'; import { IMessageService, MessageNotFoundError, @@ -57,12 +57,22 @@ interface TranscriptCacheEntry { readonly transcript: WireTranscript; } +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class MessageService extends Disposable implements IMessageService { readonly _serviceBrand: undefined; private readonly transcriptCache = new Map(); - constructor(@ICoreProcessService private readonly core: ICoreProcessService) { + constructor(@ICoreRuntime private readonly core: ICoreRuntime) { super(); } @@ -121,7 +131,7 @@ export class MessageService extends Disposable implements IMessageService { * base). Throws `SessionNotFoundError` (→ 40401) on miss. */ private async _requireSession(sid: string): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); const summary = all.find((s) => s.id === sid); if (summary === undefined) { throw new SessionNotFoundError(sid); @@ -158,7 +168,7 @@ export class MessageService extends Disposable implements IMessageService { ): Promise { await this._resumeSession(sid); const transcript = await this._readTranscriptCached(sid, summary.sessionDir); - const context = await this.core.rpc.getContext({ + const context = await this.coreApi().getContext({ sessionId: sid, agentId: MAIN_AGENT_ID, }); @@ -176,7 +186,7 @@ export class MessageService extends Disposable implements IMessageService { private async _resumeSession(sid: string): Promise { try { - await this.core.rpc.resumeSession({ sessionId: sid }); + await this.coreApi().resumeSession({ sessionId: sid }); } catch { throw new SessionNotFoundError(sid); } @@ -214,6 +224,18 @@ export class MessageService extends Disposable implements IMessageService { return undefined; } } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } } // Self-register under the global singleton registry. All ctor deps are diff --git a/packages/agent-core/src/services/message/transcript.ts b/packages/agent-core/src/message/transcript.ts similarity index 99% rename from packages/agent-core/src/services/message/transcript.ts rename to packages/agent-core/src/message/transcript.ts index ae53f9811..5cc921c90 100644 --- a/packages/agent-core/src/services/message/transcript.ts +++ b/packages/agent-core/src/message/transcript.ts @@ -42,9 +42,9 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import type { AgentRecord } from '../../agent/records'; -import type { ContextMessage } from '../../agent/context'; -import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop'; +import type { AgentRecord } from '../agent/records'; +import type { ContextMessage } from '../agent/context'; +import type { ExecutableToolResult, LoopRecordedEvent } from '../loop'; type ContentPart = ContextMessage['content'][number]; diff --git a/packages/agent-core/src/plugin/index.ts b/packages/agent-core/src/plugin/index.ts index 5722be5bd..c7ebc48df 100644 --- a/packages/agent-core/src/plugin/index.ts +++ b/packages/agent-core/src/plugin/index.ts @@ -3,8 +3,8 @@ export { parseManifest } from './manifest'; export type { ParsedManifestResult } from './manifest'; export { readInstalled, writeInstalled } from './store'; export type { InstalledFile, InstalledRecord } from './store'; -export { PluginManager } from './manager'; -export type { PluginManagerOptions } from './manager'; +export { PluginManager, PluginService } from './manager'; +export type { PluginManagerOptions, IPluginService } from './manager'; export { resolveInstallSource } from './source'; export type { InstallSource, ResolvedSource } from './source'; export { downloadZip, extractZip } from './archive'; diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index 2d3c1a700..cc1a5c3e9 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import type { McpServerConfig } from '../config/schema'; +import { createDecorator } from '../_base/di'; import { discoverSkills, type SkillRoot } from '../skill'; import { downloadZip, extractZip } from './archive'; import { resolveGithubSource } from './github-resolver'; @@ -280,6 +281,21 @@ export class PluginManager { } } +export interface IPluginService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): PluginManager; +} + +export const IPluginService = createDecorator('pluginService'); + +export class PluginService extends PluginManager implements IPluginService { + readonly _serviceBrand: undefined; + unwrap(): PluginManager { + return this; + } +} + async function normalizeInstallRoot(rootPath: string): Promise { const trimmed = rootPath.trim(); if (!path.isAbsolute(trimmed)) { diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 001f7d19f..e948608e0 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -1,4 +1,4 @@ -import { renderPrompt } from '../utils/render-prompt'; +import { renderPrompt } from '#/_utils/template'; import type { RawAgentProfile, RawSubagentProfile, diff --git a/packages/agent-core/src/prompt/index.ts b/packages/agent-core/src/prompt/index.ts new file mode 100644 index 000000000..b02ba154b --- /dev/null +++ b/packages/agent-core/src/prompt/index.ts @@ -0,0 +1,17 @@ +export { + IPromptService, + PromptAlreadyCompletedError, + PromptNotFoundError, + SessionBusyError, +} from './prompt'; +export type { + AgentStatePatch, + AgentStateSnapshot, + PromptAbortResult, + PromptDispatchLogEntry, + SyntheticPromptAbortedEvent, + SyntheticPromptCompletedEvent, + SyntheticPromptSteeredEvent, + SyntheticPromptSubmittedEvent, +} from './prompt'; +export { PromptService } from './promptService'; diff --git a/packages/agent-core/src/services/prompt/prompt.ts b/packages/agent-core/src/prompt/prompt.ts similarity index 98% rename from packages/agent-core/src/services/prompt/prompt.ts rename to packages/agent-core/src/prompt/prompt.ts index fa8bd72bd..7cdcdbbc2 100644 --- a/packages/agent-core/src/services/prompt/prompt.ts +++ b/packages/agent-core/src/prompt/prompt.ts @@ -62,12 +62,12 @@ * * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for type-only * `Event` / `TurnStartedEvent` etc. Runtime calls go through - * `ICoreProcessService.rpc.`. Lifecycle synthesis emits events through + * `ICoreRuntime.rpc.`. Lifecycle synthesis emits events through * `IEventService.publish` (also a daemon-side interface; agent-core not touched). */ -import { createDecorator } from '../../di'; -import type { Event } from '../../base/common/event'; +import { createDecorator } from '#/_base/di'; +import type { Event } from '#/_base/event'; import type { PromptListResponse, PromptSubmission, diff --git a/packages/agent-core/src/services/prompt/promptService.ts b/packages/agent-core/src/prompt/promptService.ts similarity index 91% rename from packages/agent-core/src/services/prompt/promptService.ts rename to packages/agent-core/src/prompt/promptService.ts index 4476d54d8..a703d77a9 100644 --- a/packages/agent-core/src/services/prompt/promptService.ts +++ b/packages/agent-core/src/prompt/promptService.ts @@ -2,8 +2,8 @@ * `PromptService` — implementation of `IPromptService`. */ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; -import { Emitter } from '../../base/common/event'; +import { Disposable, InstantiationType, registerSingleton } from '#/_base/di'; +import { Emitter } from '#/_base/event'; import type { Event, PromptItem, @@ -13,14 +13,15 @@ import type { PromptSubmitResult, PromptThinking, } from '@moonshot-ai/protocol'; -import type { PermissionMode } from '../../agent/permission'; +import type { PermissionMode } from '../agent/permission'; import { ulid } from 'ulid'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; -import { IAuthSummaryService } from '../authSummary/authSummary'; -import { IEventService } from '../event/event'; -import { ILogService } from '../logger/logger'; -import { ISessionService, SessionNotFoundError } from '../session/session'; +import { ICoreRuntime } from '#/coreProcess'; +import type { CoreRPC } from '../rpc'; +import { IAuthSummaryService } from '../services/authSummary/authSummary'; +import { IEventService } from '#/event'; +import { ILogService } from '../services/logger/logger'; +import { ISessionService, SessionNotFoundError } from '#/session'; import { IPromptService, PromptNotFoundError, @@ -38,6 +39,16 @@ import { const MAIN_AGENT_ID = 'main'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + function promptKey(sessionId: string, agentId: string): string { return `${sessionId}\u0000${agentId}`; } @@ -255,7 +266,7 @@ export class PromptService /** * Per-session ring buffer of stateless-control setter dispatches. * Each entry records `{ts, kind, payload, promptId}` immediately after - * the underlying `core.rpc.*` setter resolves inside `_applyAgentState`. + * the underlying `coreApi().*` setter resolves inside `_applyAgentState`. * The buffer is capped at `DISPATCH_LOG_CAP`; on overflow the oldest * entry is dropped. Cleared on `ISessionService.onDidClose` together * with the shadow. Exposed via `_dispatchLogForTest` for the daemon's @@ -283,7 +294,7 @@ export class PromptService readonly onDidAbort = this._onDidAbort.event; constructor( - @ICoreProcessService private readonly core: ICoreProcessService, + @ICoreRuntime private readonly core: ICoreRuntime, @IEventService private readonly eventService: IEventService, @IAuthSummaryService private readonly auth: IAuthSummaryService, @ISessionService private readonly sessionService: ISessionService, @@ -333,7 +344,7 @@ export class PromptService async submit(sid: string, body: PromptSubmission): Promise { await this._requireSession(sid); - await this.core.rpc.resumeSession({ sessionId: sid }); + await this.coreApi().resumeSession({ sessionId: sid }); // Readiness gate. Throws AuthProvisioningRequired / // AuthTokenMissing / AuthModelNotResolved before we mint a prompt_id and @@ -361,9 +372,9 @@ export class PromptService async startBtw(sid: string): Promise { await this._requireSession(sid); - await this.core.rpc.resumeSession({ sessionId: sid }); + await this.coreApi().resumeSession({ sessionId: sid }); await this.auth.ensureReady(); - return this.core.rpc.startBtw({ sessionId: sid, agentId: MAIN_AGENT_ID }); + return this.coreApi().startBtw({ sessionId: sid, agentId: MAIN_AGENT_ID }); } async steer(sid: string, promptIds: readonly string[]): Promise { @@ -392,7 +403,7 @@ export class PromptService this._replaceQueue(sid, MAIN_AGENT_ID, remaining); try { - await this.core.rpc.steer({ + await this.coreApi().steer({ sessionId: sid, agentId: MAIN_AGENT_ID, input: steerContentToCoreParts(selected), @@ -438,16 +449,16 @@ export class PromptService try { this._logger.debug( { sid, promptId: state.promptId, agentId: state.agentId, partCount: input.length }, - '[DBG prompt-service.submit] -> core.rpc.prompt(...)', + '[DBG prompt-service.submit] -> coreApi.prompt(...)', ); - await this.core.rpc.prompt({ + await this.coreApi().prompt({ sessionId: sid, agentId: state.agentId, input, }); this._logger.debug( { sid, promptId: state.promptId }, - '[DBG prompt-service.submit] core.rpc.prompt(...) resolved', + '[DBG prompt-service.submit] coreApi.prompt(...) resolved', ); } catch (error) { // Clear our active-prompt state so the next submit succeeds; surface @@ -457,7 +468,7 @@ export class PromptService } this._logger.debug( { sid, promptId: state.promptId, err: (error as Error)?.message ?? error }, - '[DBG prompt-service.submit] core.rpc.prompt(...) threw', + '[DBG prompt-service.submit] coreApi.prompt(...) threw', ); throw error; } @@ -508,7 +519,7 @@ export class PromptService agentId: state.agentId, }; if (state.turnId !== null) cancelArgs.turnId = state.turnId; - await this.core.rpc.cancel(cancelArgs); + await this.coreApi().cancel(cancelArgs); } catch (error) { // Roll back the optimistic flag so the route surfaces a real error; // the caller will see a 50001 (internal) via the global error handler. @@ -544,7 +555,7 @@ export class PromptService // No daemon-managed active prompt. Cancel whatever agent-core turn is // running (e.g. a skill activation) without requiring a turnId. // TurnFlow.cancel(undefined) is a safe no-op when idle. - await this.core.rpc.cancel({ sessionId: sid, agentId: MAIN_AGENT_ID }); + await this.coreApi().cancel({ sessionId: sid, agentId: MAIN_AGENT_ID }); return { aborted: true }; } @@ -561,7 +572,7 @@ export class PromptService * `submit` (per-turn override) and `SessionService.update` * (`POST /sessions/{sid}/profile`). Validates the session exists, * bootstraps the shadow lazily, then diff-dispatches each non-shadow - * field through the matching `core.rpc.*` setter. Dispatch-log + * field through the matching `coreApi().*` setter. Dispatch-log * entries are tagged with the `source` so downstream observers can * tell prompt-driven and profile-driven setters apart. * @@ -602,10 +613,10 @@ export class PromptService private async _ensureAgentStateBootstrapped(sid: string): Promise { if (this._agentState.has(sid)) return; const [config, permission, plan, swarmMode] = await Promise.all([ - this.core.rpc.getConfig({ sessionId: sid, agentId: MAIN_AGENT_ID }), - this.core.rpc.getPermission({ sessionId: sid, agentId: MAIN_AGENT_ID }), - this.core.rpc.getPlan({ sessionId: sid, agentId: MAIN_AGENT_ID }), - this.core.rpc.getSwarmMode({ sessionId: sid, agentId: MAIN_AGENT_ID }), + this.coreApi().getConfig({ sessionId: sid, agentId: MAIN_AGENT_ID }), + this.coreApi().getPermission({ sessionId: sid, agentId: MAIN_AGENT_ID }), + this.coreApi().getPlan({ sessionId: sid, agentId: MAIN_AGENT_ID }), + this.coreApi().getSwarmMode({ sessionId: sid, agentId: MAIN_AGENT_ID }), ]); const snapshot: AgentStateSnapshot = {}; if (config.modelAlias !== undefined) snapshot.model = config.modelAlias; @@ -622,7 +633,7 @@ export class PromptService /** * Diff-dispatch: for each of the four controls present on `patch`, - * call the matching `core.rpc.*` setter ONLY when the value differs + * call the matching `coreApi().*` setter ONLY when the value differs * from the shadow. Each setter runs serially so any failure surfaces * to the caller. Each successful setter also appends to the per-session * dispatch-log ring buffer; absence of an entry between two prompts is @@ -649,13 +660,13 @@ export class PromptService if (patch.model !== undefined && patch.model !== shadow.model) { const payload = { sessionId: sid, agentId, model: patch.model }; - await this.core.rpc.setModel(payload); + await this.coreApi().setModel(payload); shadow.model = patch.model; this._recordDispatch(sid, 'setModel', payload, promptId, source); } if (patch.thinking !== undefined && patch.thinking !== shadow.thinking) { const payload = { sessionId: sid, agentId, level: patch.thinking as PromptThinking }; - await this.core.rpc.setThinking(payload); + await this.coreApi().setThinking(payload); shadow.thinking = patch.thinking; this._recordDispatch(sid, 'setThinking', payload, promptId, source); } @@ -668,20 +679,20 @@ export class PromptService agentId, mode: patch.permission_mode as PermissionMode, }; - await this.core.rpc.setPermission(payload); + await this.coreApi().setPermission(payload); shadow.permissionMode = patch.permission_mode as PermissionMode; this._recordDispatch(sid, 'setPermission', payload, promptId, source); } if (patch.plan_mode !== undefined && patch.plan_mode !== shadow.planMode) { const payload = { sessionId: sid, agentId }; if (patch.plan_mode) { - await this.core.rpc.enterPlan(payload); + await this.coreApi().enterPlan(payload); this._recordDispatch(sid, 'enterPlan', payload, promptId, source); } else { // `cancelPlan({id?})` accepts an omitted id — `PlanMode.cancel` // clears whatever id is currently active. Shadow doesn't track // ids, so we always omit. - await this.core.rpc.cancelPlan(payload); + await this.coreApi().cancelPlan(payload); this._recordDispatch(sid, 'cancelPlan', payload, promptId, source); } shadow.planMode = patch.plan_mode; @@ -694,10 +705,10 @@ export class PromptService const payload = { sessionId: sid, agentId }; if (patch.swarm_mode) { const enterPayload = { ...payload, trigger: 'manual' as const }; - await this.core.rpc.enterSwarm(enterPayload); + await this.coreApi().enterSwarm(enterPayload); this._recordDispatch(sid, 'enterSwarm', enterPayload, promptId, source); } else { - await this.core.rpc.exitSwarm(payload); + await this.coreApi().exitSwarm(payload); this._recordDispatch(sid, 'exitSwarm', payload, promptId, source); } shadow.swarmMode = patch.swarm_mode; @@ -714,7 +725,7 @@ export class PromptService objective: patch.goal_objective, replace: false, }; - await this.core.rpc.createGoal(payload); + await this.coreApi().createGoal(payload); this._recordDispatch(sid, 'createGoal', payload, promptId, source); // `goal_objective` is a one-shot creation trigger; do not keep it on // the shadow. @@ -726,15 +737,15 @@ export class PromptService const payload = { sessionId: sid, agentId }; switch (patch.goal_control) { case 'pause': - await this.core.rpc.pauseGoal(payload); + await this.coreApi().pauseGoal(payload); this._recordDispatch(sid, 'pauseGoal', payload, promptId, source); break; case 'resume': - await this.core.rpc.resumeGoal(payload); + await this.coreApi().resumeGoal(payload); this._recordDispatch(sid, 'resumeGoal', payload, promptId, source); break; case 'cancel': - await this.core.rpc.cancelGoal(payload); + await this.coreApi().cancelGoal(payload); this._recordDispatch(sid, 'cancelGoal', payload, promptId, source); break; } @@ -906,7 +917,7 @@ export class PromptService /** * Test helper — inject an active prompt record. Used by daemon e2e tests * that need to exercise the lifecycle-synthesis path WITHOUT driving a - * real `core.rpc.prompt(...)` call (which would require an in-memory + * real core prompt dispatch (which would require an in-memory * KimiCore loaded with provider credentials). Not part of the public * contract; the underscore prefix is a "do not use in prod" signal. */ @@ -983,8 +994,20 @@ export class PromptService }); } + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site below reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } + private async _requireSession(sid: string): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); if (!all.some((s) => s.id === sid)) { throw new SessionNotFoundError(sid); } @@ -1003,7 +1026,7 @@ export class PromptService } // Self-register under the global singleton registry. All ctor deps are -// `@I…`-injected (@ICoreProcessService / @IEventService / @IAuthSummaryService); +// `@I…`-injected (@ICoreRuntime / @IEventService / @IAuthSummaryService); // `staticArguments = []`. `supportsDelayedInstantiation = false` preserves // current reverse-dispose semantics. registerSingleton(IPromptService, PromptService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/question/index.ts b/packages/agent-core/src/question/index.ts new file mode 100644 index 000000000..28a2484c3 --- /dev/null +++ b/packages/agent-core/src/question/index.ts @@ -0,0 +1,8 @@ +export { IQuestionService } from './question'; +export type { QuestionRequest, QuestionResult } from './question'; +export { + toAgentCoreResponse as questionToAgentCoreResponse, + toBrokerRequest as questionToBrokerRequest, + dismissedResult as questionDismissedResult, + type QuestionToBrokerRequestParams, +} from './question'; diff --git a/packages/agent-core/src/services/question/question.ts b/packages/agent-core/src/question/question.ts similarity index 99% rename from packages/agent-core/src/services/question/question.ts rename to packages/agent-core/src/question/question.ts index 772502556..663b53d3c 100644 --- a/packages/agent-core/src/services/question/question.ts +++ b/packages/agent-core/src/question/question.ts @@ -45,8 +45,8 @@ * happens for question. */ -import { createDecorator } from '../../di'; -import type { QuestionAnswers as InProcessQuestionAnswers, QuestionItem as InProcessQuestionItem, QuestionRequest as InProcessQuestionRequest, QuestionRequest, QuestionResponse as InProcessQuestionResponse, QuestionResult } from '../../rpc'; +import { createDecorator } from '#/_base/di'; +import type { QuestionAnswers as InProcessQuestionAnswers, QuestionItem as InProcessQuestionItem, QuestionRequest as InProcessQuestionRequest, QuestionRequest, QuestionResponse as InProcessQuestionResponse, QuestionResult } from '#/rpc'; import type { QuestionItem as ProtocolQuestionItem, QuestionOption as ProtocolQuestionOption, diff --git a/packages/agent-core/src/rpc/client.ts b/packages/agent-core/src/rpc/client.ts index d50cb5f3f..625c386be 100644 --- a/packages/agent-core/src/rpc/client.ts +++ b/packages/agent-core/src/rpc/client.ts @@ -1,4 +1,4 @@ -import type { PromisableMethods, Promisify } from '#/utils/types'; +import type { PromisableMethods, Promisify } from '#/_utils/types'; import { createControlledPromise, objectMap } from '@antfu/utils'; import { @@ -6,7 +6,7 @@ import { type KimiErrorPayload, toKimiErrorPayload, } from '../errors'; -import { abortable } from '../utils/abort'; +import { abortable } from '#/_utils/abort'; import type { CoreAPI } from './core-api'; import type { SDKAPI } from './sdk-api'; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index c9c07ad6e..3638889d9 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -2,12 +2,12 @@ import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { ErrorCodes, KimiError } from '#/errors'; -import { getRootLogger, log } from '#/logging/logger'; -import { PluginManager } from '#/plugin'; +import { getRootLogger, log } from '#/_base/logging'; +import { PluginService, type IPluginService } from '#/plugin'; import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url'; import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url'; import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search'; -import type { PromisableMethods } from '#/utils/types'; +import type { PromisableMethods } from '#/_utils/types'; import { getCoreVersion } from '#/version'; import { resolveThinkingLevel } from '../agent/config/thinking'; import { Agent } from '../agent'; @@ -25,19 +25,22 @@ import { } from '../config'; import { FLAG_DEFINITIONS, - FlagResolver, + FlagService, type ExperimentalFeatureState, + type IFlagService, } from '../flags'; -import type { Logger } from '../logging/types'; +import type { Logger } from '#/_base/logging'; import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp'; import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; +import { type SessionHost } from '../session/session-host'; import { exportSessionDirectory } from '../session/export'; import { - ProviderManager, type BearerTokenProvider, + ProviderService, type BearerTokenProvider, + type IProviderService, type OAuthTokenProviderResolver } from '../session/provider-manager'; import { SessionAPIImpl } from '../session/rpc'; -import { normalizeWorkDir, SessionStore } from '../session/store/index'; +import { normalizeWorkDir, SessionStoreService, type ISessionStoreService } from '../session/store/index'; import { noopTelemetryClient, withTelemetryContext, @@ -45,6 +48,10 @@ import { type TelemetryClient, type TelemetryProperties, } from '../telemetry'; +import { + InstantiationService, + type IInstantiationService, +} from '../_base/di'; import type { CoreRPCClient } from './client'; import type { ActivateSkillPayload, @@ -126,14 +133,16 @@ export interface KimiCoreOptions { readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; readonly appVersion?: string; + readonly instantiationService?: IInstantiationService | undefined; } export class KimiCore implements PromisableMethods { readonly sdk: Promise; readonly homeDir: string; readonly configPath: string; - readonly sessions = new Map(); + readonly sessions = new Map(); readonly telemetry: TelemetryClient; + private readonly scope: IInstantiationService; private kaos: Promise | undefined; private runtime: ToolServices | undefined; @@ -144,12 +153,12 @@ export class KimiCore implements PromisableMethods { private readonly kimiRequestHeaders: Record | undefined; private readonly resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined; private readonly skillDirs: readonly string[]; - private readonly sessionStore: SessionStore; - readonly plugins: PluginManager; + private readonly sessionStore: ISessionStoreService; + readonly plugins: IPluginService; private pluginsReady: Promise; private pluginsLoadError: Error | undefined; private readonly appVersion: string | undefined; - private readonly experimentalFlags: FlagResolver; + private readonly experimentalFlags: IFlagService; constructor( protected readonly rpcClient: CoreRPCClient, @@ -168,6 +177,11 @@ export class KimiCore implements PromisableMethods { this.skillDirs = options.skillDirs ?? []; this.telemetry = options.telemetry ?? noopTelemetryClient; this.appVersion = options.appVersion; + this.scope = new InstantiationService( + undefined, + true, + options.instantiationService as InstantiationService | undefined, + ); ensureKimiHome(this.homeDir); // Schema errors degrade (invalid sections are dropped with warnings) so a // typo cannot prevent startup, but a file that cannot be used at all — @@ -182,13 +196,13 @@ export class KimiCore implements PromisableMethods { if (this.configWarnings.length > 0) { log.warn('config load degraded', { warnings: this.configWarnings }); } - this.experimentalFlags = new FlagResolver( + this.experimentalFlags = new FlagService( process.env, FLAG_DEFINITIONS, this.config.experimental, ); - this.sessionStore = new SessionStore(this.homeDir); - this.plugins = new PluginManager({ kimiHomeDir: this.homeDir }); + this.sessionStore = new SessionStoreService(this.homeDir); + this.plugins = new PluginService({ kimiHomeDir: this.homeDir }); // Capture the error rather than swallow it: mutators and explicit /plugins // reads rethrow so the user sees what's wrong; createSession/resumeSession // degrade silently (no plugin skills, no sessionStart injections) so the harness still @@ -263,6 +277,7 @@ export class KimiCore implements PromisableMethods { telemetry: sessionTelemetry, pluginSessionStarts, appVersion: this.appVersion, + instantiationService: this.scope, }); try { session.metadata = { @@ -296,7 +311,7 @@ export class KimiCore implements PromisableMethods { await session.close().catch(() => {}); throw error; } - this.sessions.set(id, session); + this.sessions.set(id, session.host); if (Object.keys(clientTelemetry).length > 0) { sessionTelemetry.track('session_started', { resumed: false }); } @@ -312,9 +327,9 @@ export class KimiCore implements PromisableMethods { } async closeSession({ sessionId }: CloseSessionPayload): Promise { - const session = this.sessions.get(sessionId); - if (session) { - await session.close(); + const host = this.sessions.get(sessionId); + if (host) { + await host.session.close(); this.sessions.delete(sessionId); } } @@ -333,8 +348,9 @@ export class KimiCore implements PromisableMethods { overrides: { kaos?: Kaos; persistenceKaos?: Kaos }, ): Promise { const summary = await this.sessionStore.get(input.sessionId); - const active = this.sessions.get(summary.id); - if (active !== undefined) { + const host = this.sessions.get(summary.id); + if (host !== undefined) { + const active = host.session; if (overrides.kaos !== undefined) { active.setToolKaos(overrides.kaos.withCwd(summary.workDir)); } @@ -373,6 +389,7 @@ export class KimiCore implements PromisableMethods { initializeMainAgent: false, pluginSessionStarts, appVersion: this.appVersion, + instantiationService: this.scope, }); let warning: string | undefined; try { @@ -386,13 +403,14 @@ export class KimiCore implements PromisableMethods { }); throw error; } - this.sessions.set(summary.id, session); + this.sessions.set(summary.id, session.host); return resumeSessionResult(summary, session, warning); } async reloadSession(input: ReloadSessionPayload): Promise { const summary = await this.sessionStore.get(input.sessionId); - const active = this.sessions.get(summary.id); + const host = this.sessions.get(summary.id); + const active = host?.session; if (active?.hasActiveTurn === true) { throw new KimiError( ErrorCodes.TURN_AGENT_BUSY, @@ -414,7 +432,8 @@ export class KimiCore implements PromisableMethods { async forkSession(input: ForkSessionPayload): Promise { const source = await this.sessionStore.get(input.sessionId); - const active = this.sessions.get(source.id); + const host = this.sessions.get(source.id); + const active = host?.session; if (active?.hasActiveTurn === true) { throw new KimiError( ErrorCodes.SESSION_FORK_ACTIVE_TURN, @@ -442,9 +461,9 @@ export class KimiCore implements PromisableMethods { } async renameSession({ sessionId, ...payload }: RenameSessionRequest): Promise { - const session = this.sessions.get(sessionId); - if (session !== undefined) { - await new SessionAPIImpl(session).renameSession(payload); + const host = this.sessions.get(sessionId); + if (host !== undefined) { + await new SessionAPIImpl(host.session).renameSession(payload); return; } await this.sessionStore.rename(sessionId, payload.title); @@ -452,7 +471,8 @@ export class KimiCore implements PromisableMethods { async exportSession(input: ExportSessionPayload): Promise { const summary = await this.sessionStore.get(input.sessionId); - const active = this.sessions.get(input.sessionId); + const host = this.sessions.get(input.sessionId); + const active = host?.session; // Closed sessions have no `Session.log`; create an ad-hoc child bound to // their id so the entries still route to the session log file. const exportLog = @@ -839,8 +859,8 @@ export class KimiCore implements PromisableMethods { }; } - private resolveProviderManager(sessionId: string): ProviderManager { - return new ProviderManager({ + private resolveProviderManager(sessionId: string): IProviderService { + return new ProviderService({ config: () => this.config, kimiRequestHeaders: this.kimiRequestHeaders, resolveOAuthTokenProvider: this.resolveOAuthTokenProvider, @@ -890,13 +910,13 @@ export class KimiCore implements PromisableMethods { } private sessionApi(sessionId: string): SessionAPIImpl { - const session = this.sessions.get(sessionId); - if (session === undefined) { + const host = this.sessions.get(sessionId); + if (host === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, { details: { sessionId }, }); } - return new SessionAPIImpl(session); + return new SessionAPIImpl(host.session); } private reloadProviderManager(): KimiConfig { diff --git a/packages/agent-core/src/scope/builder.ts b/packages/agent-core/src/scope/builder.ts new file mode 100644 index 000000000..32e182469 --- /dev/null +++ b/packages/agent-core/src/scope/builder.ts @@ -0,0 +1,123 @@ +/** + * Scope builders for the di-v3 scope mechanism. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` + * (`ScopeBuilder` 4-step pipeline). + * + * Each scope (`Session` / `Agent` / `Turn`) is built by the same 4-step + * pipeline: + * + * 1. **Inject the scope identity context** into a fresh `ServiceCollection` + * (e.g. `collection.set(ISessionContext, context)`), so in-scope services + * can `@ISessionContext` their identity instead of receiving raw ids. + * 2. **Install Pattern-1 statically registered services** — read + * `getScopedServiceDescriptors(scope)` and add each `(id, SyncDescriptor)` + * to the collection. Descriptors are lazy: nothing is instantiated until + * the first `accessor.get(id)`. + * 3. **Reserved build hook (Pattern 2)** — NOT enabled in this version; a + * clearly marked no-op so Pattern 2 can be added later without touching + * Pattern-1 callers. + * 4. **Reserved post-build interceptor (Pattern 3)** — NOT enabled in this + * version; same reasoning as step 3. + * + * Then `parent.createChild(collection)` produces the child container and the + * returned {@link IScopeHandle} wraps it. `markBuilt()` is called on the first + * build only — afterwards the registry rejects further registrations (the + * already-built collection will not re-read it). + */ + +import type { IInstantiationService, ServiceIdentifier } from '../_base/di'; +import { ServiceCollection } from '../_base/di'; +import { IAgentContext } from './context/agentContext'; +import { ISessionContext } from './context/sessionContext'; +import { ITurnContext } from './context/turnContext'; +import { type IScopeHandle, ScopeHandle } from './handle'; +import { LifecycleScope } from './lifecycle'; +import { getScopedServiceDescriptors, isBuilt, markBuilt } from './registry'; + +/** Minimal shape every scope identity context must satisfy for the builder. */ +export interface ScopeIdentityContext { + readonly id: string; +} + +/** + * Generic 4-step scope builder, parameterized by the lifecycle scope and the + * identity context it injects. The concrete builders below fix those two + * parameters; subclasses / future patterns only need to override the reserved + * hook methods. + */ +export class ScopeBuilder { + constructor( + private readonly scope: LifecycleScope, + private readonly contextId: ServiceIdentifier, + ) {} + + /** Build a scope under `parent`, injecting `context` as its identity. */ + build(parent: IInstantiationService, context: TContext): IScopeHandle { + const collection = new ServiceCollection(); + + // ① inject scope identity context + collection.set(this.contextId, context); + + // ② install Pattern-1 statically registered services as SyncDescriptors + for (const [id, descriptor] of getScopedServiceDescriptors(this.scope)) { + collection.set(id, descriptor); + } + + // ③ reserved build hook (Pattern 2) — NOT enabled in this version. + this.runBuildHooks(collection, context); + + // ④ reserved post-build interceptor (Pattern 3) — NOT enabled in this version. + this.runPostBuildInterceptors(collection, context); + + const child = parent.createChild(collection); + + if (!isBuilt()) { + markBuilt(); + } + + return new ScopeHandle(this.scope, context.id, child); + } + + /** + * Reserved Pattern-2 build hook. Intentional no-op in this version — override + * (or wire a registry) when Pattern 2 is introduced. Runs after Pattern-1 + * descriptors are installed, before `createChild`. + */ + protected runBuildHooks(_collection: ServiceCollection, _context: TContext): void { + // no-op: Pattern 2 is not enabled in this version. + } + + /** + * Reserved Pattern-3 post-build interceptor. Intentional no-op in this + * version — runs after the build hook, before `createChild`. + */ + protected runPostBuildInterceptors( + _collection: ServiceCollection, + _context: TContext, + ): void { + // no-op: Pattern 3 is not enabled in this version. + } +} + +/** Builds the Session scope (top-most business scope; parent is Core/root). */ +export class SessionScopeBuilder extends ScopeBuilder { + constructor() { + super(LifecycleScope.Session, ISessionContext); + } +} + +/** Builds the Agent scope (child of a Session scope). */ +export class AgentScopeBuilder extends ScopeBuilder { + constructor() { + super(LifecycleScope.Agent, IAgentContext); + } +} + +/** Builds the Turn scope (child of an Agent scope). */ +export class TurnScopeBuilder extends ScopeBuilder { + constructor() { + super(LifecycleScope.Turn, ITurnContext); + } +} diff --git a/packages/agent-core/src/scope/context/agentContext.ts b/packages/agent-core/src/scope/context/agentContext.ts new file mode 100644 index 000000000..5d1a85428 --- /dev/null +++ b/packages/agent-core/src/scope/context/agentContext.ts @@ -0,0 +1,46 @@ +/** + * Scope identity context for the Agent scope. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` + * (`IAgentContext`) plus DR10 (context field-name normalization). + * + * A service living in the Agent scope (or below) injects this via + * `@IAgentContext` to read its identity — `id`, `parentId`, `abortSignal`, + * `executionScope` — instead of receiving raw ids as method arguments. An Agent + * is owned by a Session, so `parentId` is the owning `sessionId`. + * + * DR10 field-name normalization: the raw source uses per-scope names + * (`agentId`, `sessionId`, `signal`). The canonical names here are `id` / + * `parentId` / `abortSignal` / `executionScope` so cross-scope code (managers, + * aggregators, builders) can be generic. + */ + +import { createDecorator } from '../../_base/di'; + +/** + * Identity of the Agent scope. + * + * `executionScope` is the Kaos-domain execution-environment snapshot (cwd / env). + * It is typed as `unknown` here as a placeholder — P3/P4 will refine it to the + * real `IExecutionScope` once that type exists. Do not import a not-yet-existing + * module. + */ +export interface IAgentContext { + /** Agent id (canonical name; source calls it `agentId`). */ + readonly id: string; + /** Owning Session id (canonical name; source calls it `sessionId`). */ + readonly parentId: string; + /** Aborts when the Agent scope is disposed. */ + readonly abortSignal: AbortSignal; + /** + * Execution-environment snapshot. Placeholder `unknown` until P3/P4 introduce + * the real `IExecutionScope` type. + */ + readonly executionScope: unknown; +} + +/** + * DI decorator / service identifier for {@link IAgentContext}. + */ +export const IAgentContext = createDecorator('agentContext'); diff --git a/packages/agent-core/src/scope/context/index.ts b/packages/agent-core/src/scope/context/index.ts new file mode 100644 index 000000000..659b3c2bd --- /dev/null +++ b/packages/agent-core/src/scope/context/index.ts @@ -0,0 +1,17 @@ +/** + * Barrel for the di-v3 scope identity contexts. + * + * Re-exports the four scope identity contexts. Each name carries both the + * interface type and the `createDecorator` value (declaration merging), so a + * single `import { IAgentContext }` gives consumers the type and the decorator. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` (I*Context) plus + * DR10 (context field-name normalization: `id` / `parentId` / `abortSignal` / + * `executionScope`). + */ + +export { IAgentContext } from './agentContext'; +export { ISessionContext } from './sessionContext'; +export { IToolCallContext } from './toolCallContext'; +export { ITurnContext } from './turnContext'; diff --git a/packages/agent-core/src/scope/context/sessionContext.ts b/packages/agent-core/src/scope/context/sessionContext.ts new file mode 100644 index 000000000..517256459 --- /dev/null +++ b/packages/agent-core/src/scope/context/sessionContext.ts @@ -0,0 +1,49 @@ +/** + * Scope identity context for the Session scope. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` + * (`ISessionContext`) plus DR10 (context field-name normalization). + * + * A service living in the Session scope (or below) injects this via + * `@ISessionContext` to read its identity — `id`, `parentId`, `abortSignal`, + * `executionScope` — instead of receiving raw ids as method arguments. Session + * is the top-most business scope: its parent is Core, which carries no business + * identity, so `parentId` is `undefined`. + * + * DR10 field-name normalization: the raw source uses per-scope names + * (`sessionId`, `signal`). The canonical names here are `id` / `parentId` / + * `abortSignal` / `executionScope` so cross-scope code (managers, aggregators, + * builders) can be generic. + */ + +import { createDecorator } from '../../_base/di'; + +/** + * Identity of the Session scope. + * + * `executionScope` is the Kaos-domain execution-environment snapshot (cwd / env). + * It is typed as `unknown` here as a placeholder — P3/P4 will refine it to the + * real `IExecutionScope` once that type exists. Do not import a not-yet-existing + * module. + */ +export interface ISessionContext { + /** Session id (canonical name; source calls it `sessionId`). */ + readonly id: string; + /** + * Session has no business parent (its parent is Core). Always `undefined`. + */ + readonly parentId?: undefined; + /** Aborts when the Session scope is disposed. */ + readonly abortSignal: AbortSignal; + /** + * Execution-environment snapshot. Placeholder `unknown` until P3/P4 introduce + * the real `IExecutionScope` type. + */ + readonly executionScope: unknown; +} + +/** + * DI decorator / service identifier for {@link ISessionContext}. + */ +export const ISessionContext = createDecorator('sessionContext'); diff --git a/packages/agent-core/src/scope/context/toolCallContext.ts b/packages/agent-core/src/scope/context/toolCallContext.ts new file mode 100644 index 000000000..bd1a4ea3e --- /dev/null +++ b/packages/agent-core/src/scope/context/toolCallContext.ts @@ -0,0 +1,46 @@ +/** + * Scope identity context for the ToolCall scope. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` + * (`IToolCallContext`) plus DR10 (context field-name normalization). + * + * A service living in the ToolCall scope injects this via `@IToolCallContext` + * to read its identity — `id`, `parentId`, `abortSignal`, `executionScope` — + * instead of receiving raw ids as method arguments. A ToolCall is owned by a + * Turn, so `parentId` is the owning `turnId`. + * + * DR10 field-name normalization: the raw source uses per-scope names + * (`toolCallId`, `turnId`, `signal`). The canonical names here are `id` / + * `parentId` / `abortSignal` / `executionScope` so cross-scope code (managers, + * aggregators, builders) can be generic. + */ + +import { createDecorator } from '../../_base/di'; + +/** + * Identity of the ToolCall scope. + * + * `executionScope` is the Kaos-domain execution-environment snapshot (cwd / env). + * It is typed as `unknown` here as a placeholder — P3/P4 will refine it to the + * real `IExecutionScope` once that type exists. Do not import a not-yet-existing + * module. + */ +export interface IToolCallContext { + /** ToolCall id (canonical name; source calls it `toolCallId`). */ + readonly id: string; + /** Owning Turn id (canonical name; source calls it `turnId`). */ + readonly parentId: string; + /** Aborts when the owning turn is cancelled / the scope is disposed. */ + readonly abortSignal: AbortSignal; + /** + * Execution-environment snapshot. Placeholder `unknown` until P3/P4 introduce + * the real `IExecutionScope` type. + */ + readonly executionScope: unknown; +} + +/** + * DI decorator / service identifier for {@link IToolCallContext}. + */ +export const IToolCallContext = createDecorator('toolCallContext'); diff --git a/packages/agent-core/src/scope/context/turnContext.ts b/packages/agent-core/src/scope/context/turnContext.ts new file mode 100644 index 000000000..0acc30bfb --- /dev/null +++ b/packages/agent-core/src/scope/context/turnContext.ts @@ -0,0 +1,47 @@ +/** + * Scope identity context for the Turn scope. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` + * (`ITurnContext`) plus DR10 (context field-name normalization). + * + * A service living in the Turn scope (or below) injects this via + * `@ITurnContext` to read its identity — `id`, `parentId`, `abortSignal`, + * `executionScope` — instead of receiving raw ids as method arguments. A Turn + * is owned by an Agent, so `parentId` is the owning `agentId`. `abortSignal` + * fires on ESC / abort-driven cancellation of the turn. + * + * DR10 field-name normalization: the raw source uses per-scope names + * (`turnId`, `agentId`, `signal`). The canonical names here are `id` / + * `parentId` / `abortSignal` / `executionScope` so cross-scope code (managers, + * aggregators, builders) can be generic. + */ + +import { createDecorator } from '../../_base/di'; + +/** + * Identity of the Turn scope. + * + * `executionScope` is the Kaos-domain execution-environment snapshot (cwd / env). + * It is typed as `unknown` here as a placeholder — P3/P4 will refine it to the + * real `IExecutionScope` once that type exists. Do not import a not-yet-existing + * module. + */ +export interface ITurnContext { + /** Turn id (canonical name; source calls it `turnId`). */ + readonly id: string; + /** Owning Agent id (canonical name; source calls it `agentId`). */ + readonly parentId: string; + /** Aborts on ESC / abort-driven cancellation of the turn. */ + readonly abortSignal: AbortSignal; + /** + * Execution-environment snapshot. Placeholder `unknown` until P3/P4 introduce + * the real `IExecutionScope` type. + */ + readonly executionScope: unknown; +} + +/** + * DI decorator / service identifier for {@link ITurnContext}. + */ +export const ITurnContext = createDecorator('turnContext'); diff --git a/packages/agent-core/src/scope/handle.ts b/packages/agent-core/src/scope/handle.ts new file mode 100644 index 000000000..447f26f88 --- /dev/null +++ b/packages/agent-core/src/scope/handle.ts @@ -0,0 +1,143 @@ +/** + * Scope handle for the di-v3 scope mechanism. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` (`Scope handle`, + * `dispose()` flow + manager `onDid*` pairing). + * + * A `ScopeBuilder` (P1.3) returns an {@link IScopeHandle} for every scope it + * builds. The handle is the public face of a scope: it carries the scope + * identity (`id` / `scope`), the child container's `accessor` (used to resolve + * services lazily), and the two dispose events that bracket teardown. + * + * Dispose event semantics (strong contract): + * + * - `onWillDispose` fires **before** the child container is torn down — scoped + * services are still resolvable, so listeners may snapshot / flush (final + * usage, transcript flush, final goal state). `dispose()` awaits every + * listener (including async ones) before continuing. + * - `onDidDispose` fires **after** the child container is disposed — the data + * is gone. Subscribers must only update their own state and must NOT touch + * child services (resolving them throws because the container is disposed). + */ + +import { Emitter, type Event } from '../_base/event'; +import type { IInstantiationService, ServiceIdentifier } from '../_base/di'; +import type { LifecycleScope } from './lifecycle'; + +/** + * Read-only accessor over a scope's child container. Resolves services lazily: + * the first `get(id)` instantiates a `SyncDescriptor`-backed service; later + * `get`s return the cached instance. After {@link IScopeHandle.dispose} the + * underlying container is disposed and `get` throws. + */ +export interface IServiceAccessor { + get(id: ServiceIdentifier): T; +} + +/** + * Public handle returned by a `ScopeBuilder` for a built scope. + */ +export interface IScopeHandle { + /** This scope's identity id (== the injected context's `id`). */ + readonly id: string; + /** Which lifecycle scope this handle represents. */ + readonly scope: LifecycleScope; + /** Lazy accessor over the scope's child container. */ + readonly accessor: IServiceAccessor; + /** Fires before teardown; scoped services are still resolvable. Awaited. */ + readonly onWillDispose: Event; + /** Fires after teardown; scoped services are gone. Do not resolve them. */ + readonly onDidDispose: Event; + /** + * Tears the scope down: fires `onWillDispose` (awaiting every listener), + * disposes the child container (which disposes each scoped service in reverse + * construction order), then fires `onDidDispose`. Idempotent. + * + * `reason` is accepted for forward compatibility (e.g. cancellation / + * abort); the void events do not carry it in this version. + */ + dispose(reason?: string): Promise; +} + +/** + * Concrete {@link IScopeHandle} produced by the builders in `./builder`. + * + * Wraps a child `IInstantiationService`. The `accessor` resolves through the + * child via `invokeFunction`, so resolution walks the DI parent chain and stays + * lazy. `onWillDispose` is the stock `Emitter` augmented so that async + * listeners' returned promises are collected and awaited by `dispose()`; + * `onDidDispose` is the stock `Emitter` fired synchronously. + */ +export class ScopeHandle implements IScopeHandle { + readonly id: string; + readonly scope: LifecycleScope; + readonly accessor: IServiceAccessor; + readonly onWillDispose: Event; + readonly onDidDispose: Event; + + private readonly _onWillDispose = new Emitter(); + private readonly _onDidDispose = new Emitter(); + /** Promises returned by async `onWillDispose` listeners, awaited in dispose. */ + private readonly _willDisposeWork: Promise[] = []; + private _disposed = false; + + constructor( + scope: LifecycleScope, + id: string, + private readonly child: IInstantiationService, + ) { + this.scope = scope; + this.id = id; + + this.accessor = { + get: (serviceId: ServiceIdentifier): T => + child.invokeFunction((accessor) => accessor.get(serviceId)), + }; + + // Wrap the stock Emitter so async listeners' promises are captured and can + // be awaited by dispose() — the Emitter itself only fires synchronously. + this.onWillDispose = (listener, thisArg, disposables) => + this._onWillDispose.event( + (e) => { + const result = listener.call(thisArg, e); + if ( + result !== null && + result !== undefined && + typeof (result as PromiseLike).then === 'function' + ) { + this._willDisposeWork.push(Promise.resolve(result)); + } + }, + undefined, + disposables, + ); + + this.onDidDispose = this._onDidDispose.event; + } + + async dispose(_reason?: string): Promise { + if (this._disposed) { + return; + } + this._disposed = true; + + // [1] fire onWillDispose and await every listener (data still present). + this._onWillDispose.fire(); + await Promise.allSettled(this._willDisposeWork); + + // [2] dispose the child container — DI disposes each scoped service in + // reverse construction order, then recurses into grandchildren. + this.child.dispose(); + + // [3] fire onDidDispose synchronously (data gone). + this._onDidDispose.fire(); + + this._onWillDispose.dispose(); + this._onDidDispose.dispose(); + } + + get isDisposed(): boolean { + return this._disposed; + } +} diff --git a/packages/agent-core/src/scope/index.ts b/packages/agent-core/src/scope/index.ts new file mode 100644 index 000000000..4cc03773f --- /dev/null +++ b/packages/agent-core/src/scope/index.ts @@ -0,0 +1,53 @@ +/** + * Barrel for the di-v3 scope mechanism. + * + * Re-exports the public scope surface so consumers can reach it from a single + * entry point (`#/scope`) and, transitively, from the top-level + * `@moonshot-ai/agent-core` barrel. + * + * The surface is intentionally explicit (not `export *`) so internal helpers + * (`ScopeRegistry`, `scopeRegistry`, `_resetScopeRegistryForTests`, + * `ScopeHandle`, `ScopeIdentityContext`, `ScopedServiceEntry`) stay private to + * the mechanism and do not leak onto the package's public API. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md`. + */ + +// Lifecycle scope enum (Core → Session → Agent → Turn → ToolCall). +export { LifecycleScope } from './lifecycle'; + +// Pattern-1 scoped service registry entry points. +export { + getScopedServiceDescriptors, + isBuilt, + markBuilt, + registerScopedService, +} from './registry'; + +// Scope handle + the read-only accessor it exposes. +export type { IScopeHandle, IServiceAccessor } from './handle'; + +// Generic + per-scope builders. +export { + AgentScopeBuilder, + ScopeBuilder, + SessionScopeBuilder, + TurnScopeBuilder, +} from './builder'; + +// Manager pattern base + contracts. +export { ScopeManager } from './manager'; +export type { + IChildLifecycleEvent, + IManagerEventBus, + IManagerService, +} from './manager'; + +// Scope identity contexts (declaration-merged interface + decorator value). +export { + IAgentContext, + ISessionContext, + IToolCallContext, + ITurnContext, +} from './context'; diff --git a/packages/agent-core/src/scope/lifecycle.ts b/packages/agent-core/src/scope/lifecycle.ts new file mode 100644 index 000000000..db0442680 --- /dev/null +++ b/packages/agent-core/src/scope/lifecycle.ts @@ -0,0 +1,18 @@ +/** + * Lifecycle scopes for the di-v3 scope mechanism. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` (LifecycleScope + * enum). Scopes nest `Core → Session → Agent → Turn → ToolCall`; DI resolution + * walks from the child up the parent chain until it reaches Core. + * + * String-valued (not numeric) so a scope is self-describing in logs, warnings, + * and serialized handles. + */ +export enum LifecycleScope { + Core = 'core', + Session = 'session', + Agent = 'agent', + Turn = 'turn', + ToolCall = 'toolCall', +} diff --git a/packages/agent-core/src/scope/manager.ts b/packages/agent-core/src/scope/manager.ts new file mode 100644 index 000000000..cff99d7c1 --- /dev/null +++ b/packages/agent-core/src/scope/manager.ts @@ -0,0 +1,173 @@ +/** + * Manager service pattern for the di-v3 scope mechanism. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` + * (`Manager 上行流(主动 attach 订阅)`, `dispose() 流 + manager onDid* 配对`, + * invariant 12). + * + * A manager service: + * + * - lives in the **parent** scope of the scope it manages; + * - is the **sole up-going event publisher** for its child scopes; + * - attaches to a child's per-scope event source via `child.accessor.get(...)` + * and re-emits collection-view events that add the child id; + * - pairs `dispose()` with an `onDid*` fire + `eventBus.publish(...)` in a + * `try/finally` so teardown always completes (invariant 12); + * - never exposes its write methods to child-scope services — children receive + * only their own scope handle and accessor, never a handle back to the + * manager, so a child cannot reverse-call into the manager. + * + * This module ships the generic {@link ScopeManager} base class, which captures + * the enforceable parts of the pattern (the single child-tracking map plus the + * dispose pairing). Real domain managers (AgentLifecycleService, TurnService, + * ...) land in later phases per domain; here we only provide the base plus a + * test double (see `test/scope/manager.test.ts`). + */ + +import { Emitter, type Event } from '../_base/event'; +import type { IScopeHandle } from './handle'; + +/** + * Event fired by a manager after a child scope has finished disposing. At that + * point the child's scoped services are already gone, so listeners must only + * update their own state and must not resolve child services. + */ +export interface IChildLifecycleEvent { + readonly childId: string; + readonly reason?: string; +} + +/** + * Minimal event-bus port a manager publishes lifecycle events to. + * + * Kept generic and decoupled from the concrete `IDomainEventBus` (which is + * coupled to the rpc `AgentEvent` shape) so the scope mechanism does not depend + * on that bus, and so tests can supply a recording fake. A real domain manager + * wires this to whatever bus its scope owns. + */ +export interface IManagerEventBus { + publish(event: TPublish): void; +} + +/** + * Contract for a manager service managing child scopes of type `TChild`. + * + * The manager's write methods ({@link IManagerService.disposeChild} plus the + * subclass's create/add methods) are NOT exposed to child-scope services: a + * child only ever sees its own {@link IScopeHandle} and `accessor`, never the + * manager. This is enforced by the contract — there is no API surface here + * that a child could call back into. + */ +export interface IManagerService { + /** Fires after a child scope has been disposed (its data is already gone). */ + readonly onDidChildDispose: Event; + /** Read-only view of the children currently tracked by this manager. */ + readonly children: ReadonlyMap; + /** True if a child with `childId` is currently tracked. */ + hasChild(childId: string): boolean; + /** + * Disposes a tracked child and pairs the teardown with `onDidChildDispose` + * + `eventBus.publish` in a `try/finally` (invariant 12). No-op when the + * child is unknown. + */ + disposeChild(childId: string, reason?: string): Promise; +} + +/** + * Generic base class for a manager service. Captures the enforceable parts of + * the manager pattern: + * + * - the single allowed `Map` of tracked child handles; + * - the {@link ScopeManager.disposeChild} `try/finally` that always drops the + * child, fires `onDidChildDispose`, and publishes to the bus — even when + * `child.dispose()` rejects; + * - protected hooks ({@link ScopeManager.trackChild}, {@link ScopeManager.getChild}, + * {@link ScopeManager.publish}) subclasses use to register children, attach + * to their event sources, and re-emit collection-view events. + * + * Subclasses implement {@link ScopeManager.buildDisposeEvent} to map a child + * dispose into the concrete bus event shape. The base guarantees that event is + * published in the `finally` block of {@link ScopeManager.disposeChild}. + */ +export abstract class ScopeManager< + TChild extends IScopeHandle, + TPublish = unknown, +> implements IManagerService { + private readonly _children = new Map(); + private readonly _onDidChildDispose = new Emitter(); + + readonly onDidChildDispose: Event = + this._onDidChildDispose.event; + + constructor(private readonly eventBus: IManagerEventBus) {} + + /** Read-only view of the tracked children (`Map`). */ + get children(): ReadonlyMap { + return this._children; + } + + hasChild(childId: string): boolean { + return this._children.has(childId); + } + + /** + * Register a child handle under its `id`. Subclasses call this after building + * the child scope and attaching to its event sources. + */ + protected trackChild(child: TChild): void { + this._children.set(child.id, child); + } + + /** Look up a tracked child by id (subclass-only). */ + protected getChild(childId: string): TChild | undefined { + return this._children.get(childId); + } + + /** + * Publish a bus event (subclass-only). Used both for re-emitted + * collection-view events and, indirectly, for the dispose event. + */ + protected publish(event: TPublish): void { + this.eventBus.publish(event); + } + + /** + * Disposes a tracked child and pairs the teardown with `onDidChildDispose` + * + `eventBus.publish` in a `try/finally`. No-op when the child is unknown. + * + * Invariant 12: even if `child.dispose()` rejects, the manager still drops + * the child, fires `onDidChildDispose`, and publishes the bus event. The + * rejection is re-thrown after the `finally` block runs, matching the + * scope-mechanism contract. + */ + async disposeChild(childId: string, reason?: string): Promise { + const child = this._children.get(childId); + if (child === undefined) { + return; + } + try { + await child.dispose(reason); + } finally { + this._children.delete(childId); + this._onDidChildDispose.fire({ childId, reason }); + this.eventBus.publish(this.buildDisposeEvent(childId, reason)); + } + } + + /** + * Map a child dispose into the bus event the manager publishes. Subclasses + * define the concrete shape; the base guarantees it is published in the + * `finally` block of {@link ScopeManager.disposeChild}. + */ + protected abstract buildDisposeEvent( + childId: string, + reason?: string, + ): TPublish; + + /** Tears down the manager's own emitters and drops every tracked child. */ + dispose(): void { + this._onDidChildDispose.dispose(); + this._children.clear(); + } +} diff --git a/packages/agent-core/src/scope/registry.ts b/packages/agent-core/src/scope/registry.ts new file mode 100644 index 000000000..57bfec3cf --- /dev/null +++ b/packages/agent-core/src/scope/registry.ts @@ -0,0 +1,180 @@ +/** + * Process-wide registry for scoped services (Pattern 1 of the di-v3 scope + * mechanism) plus the `registerScopedService` entry point. + * + * Normative source: + * `.agents/skills/service-skill/explanation/scope-mechanism.md` + * (`ScopeRegistry`, `registerScopedService`). + * + * Shape: `Map>`. Each scope owns + * a table of `id -> SyncDescriptor`. Writes are lazy — only the descriptor is + * stored, nothing is instantiated. `ScopeBuilder` (P1.3) is the sole reader; + * business code registers, it never reads. + * + * The registry is a module-level singleton: one per process, permanent for the + * lifetime of the process. A test-only reset exists so cases stay isolated. + */ + +import { SyncDescriptor } from '../_base/di'; +import type { ServiceIdentifier } from '../_base/di'; +import { InstantiationType, registerSingleton } from '../_base/di'; +import { LifecycleScope } from './lifecycle'; + +/** + * Read entry for a single scope's table: the service id paired with the + * descriptor `ScopeBuilder` installs into the scope's `ServiceCollection`. + */ +export type ScopedServiceEntry = readonly [ + ServiceIdentifier, + SyncDescriptor, +]; + +/** + * Process-wide, two-level registry of scoped service descriptors. + * + * Lazy: `register` stores the `SyncDescriptor` only — it never `new`s the + * service. Instantiation happens later, inside the scope's child + * `InstantiationService`. + */ +export class ScopeRegistry { + private readonly tables = new Map< + LifecycleScope, + Map, SyncDescriptor> + >(); + + /** + * Write `descriptor` for `id` under `scope`, overwriting any prior entry + * (last-write-wins). Does not instantiate. + */ + register( + scope: LifecycleScope, + id: ServiceIdentifier, + descriptor: SyncDescriptor, + ): void { + let table = this.tables.get(scope); + if (table === undefined) { + table = new Map, SyncDescriptor>(); + this.tables.set(scope, table); + } + table.set(id as ServiceIdentifier, descriptor as SyncDescriptor); + } + + /** True if `id` is already registered under `scope`. */ + has(scope: LifecycleScope, id: ServiceIdentifier): boolean { + return this.tables.get(scope)?.has(id as ServiceIdentifier) ?? false; + } + + /** + * Snapshot of the `(id, descriptor)` entries registered under `scope`. + * Empty when the scope has no registrations. This is the read entry point + * for `ScopeBuilder`; business code must not consume it. + */ + descriptors(scope: LifecycleScope): ReadonlyArray { + const table = this.tables.get(scope); + if (table === undefined) { + return []; + } + return Array.from(table.entries()); + } + + /** Test-only: drop every scope table. */ + clear(): void { + this.tables.clear(); + } +} + +/** The single process-wide registry instance. */ +export const scopeRegistry = new ScopeRegistry(); + +/** + * Set on the first `ScopeBuilder.build()`. After it flips, further + * registrations are rejected (warn + ignore) because the already-built + * collection will not re-read the registry. + */ +let built = false; + +/** Called by `ScopeBuilder.build()` (P1.3) on its first invocation. */ +export function markBuilt(): void { + built = true; +} + +/** True once the first `ScopeBuilder.build()` has run. */ +export function isBuilt(): boolean { + return built; +} + +function warn(message: string): void { + // eslint-disable-next-line no-console + console.warn(`[registerScopedService] ${message}`); +} + +/** + * Register a service implementation under `id` for a given `scope`. + * + * Behavior contract (per scope-mechanism.md): + * + * 1. **Lazy write**: stores `new SyncDescriptor(ctor, [], supportsDelayed)` + * under `(scope, id)`; nothing is instantiated. + * 2. **Core alias**: `registerScopedService(Core, id, ctor, type)` routes + * straight to the existing `registerSingleton(id, ctor, type)` — reuse, not + * duplication — so all five scopes share one registration API. + * 3. **Duplicate, last-write-wins + warn**: re-registering the same + * `(scope, id)` warns and overwrites. + * 4. **`{ replace: true }`**: silent overwrite (plugin overriding builtin). + * 5. **After first build**: registration warns and is ignored — the built + * collection will not re-read the registry. + */ +export function registerScopedService( + scope: LifecycleScope, + id: ServiceIdentifier, + ctor: new (...args: never[]) => T, + type: InstantiationType, + options?: { replace?: boolean }, +): void { + if (built) { + warn( + `registration of ${String(id)} in scope "${scope}" happened after the first ` + + `ScopeBuilder.build(); ignoring because the built collection will not re-read the registry`, + ); + return; + } + + // Core scope is an alias for the existing singleton registry. + if (scope === LifecycleScope.Core) { + registerSingleton(id, ctor, type); + return; + } + + if (!options?.replace && scopeRegistry.has(scope, id)) { + warn( + `duplicate registration of ${String(id)} in scope "${scope}"; last write wins`, + ); + } + + const descriptor = new SyncDescriptor( + ctor as new (...args: unknown[]) => T, + [], + type === InstantiationType.Delayed, + ); + scopeRegistry.register(scope, id, descriptor); +} + +/** + * Read the descriptors registered under `scope`. Used by `ScopeBuilder` + * (P1.3) to install statically registered services into the scope. + */ +export function getScopedServiceDescriptors( + scope: LifecycleScope, +): ReadonlyArray { + return scopeRegistry.descriptors(scope); +} + +/** + * Test-only escape hatch: clear every scope table and reset the `built` flag. + * Real code must never call this — registrations are permanent for the + * lifetime of the process. + */ +export function _resetScopeRegistryForTests(): void { + scopeRegistry.clear(); + built = false; +} diff --git a/packages/agent-core/src/services/AGENTS.md b/packages/agent-core/src/services/AGENTS.md index f6e9730d2..162be6b58 100644 --- a/packages/agent-core/src/services/AGENTS.md +++ b/packages/agent-core/src/services/AGENTS.md @@ -35,7 +35,7 @@ and the **interface shape**, not the suffix. Patterns: | Business facade | mostly `Promise` returns | `IPromptService.submit(...)` | | One-shot broker | `request(req): Promise` + `resolve(id, resp)` | `IApprovalService` | | Pub-sub bus | `publish(e)` + `readonly onDidXxx: Event` | `IEventService` | -| Cross-process adapter | `readonly rpc: ...` + `ready(): Promise` | `ICoreProcessService` | +| Cross-process adapter | `readonly rpc: ...` + `ready(): Promise` | `ICoreRuntime` | ## File / folder convention (normative) @@ -51,44 +51,157 @@ and the **interface shape**, not the suffix. Patterns: Example domain layout: coreProcess/ - coreProcess.ts ← ICoreProcessService, CoreProcessServiceOptions - coreProcessService.ts ← CoreProcessService implements ICoreProcessService + coreProcess.ts ← ICoreRuntime, CoreProcessServiceOptions + coreProcessService.ts ← CoreProcessService implements ICoreRuntime + coreProcessClient.ts ← BridgeClientAPI (SDK-side RPC dispatch) + +`ICoreRuntime` is the sole identifier for the core-process adapter; the +deprecated process-service alias was removed. Its decorator string remains +`'coreProcessService'` (rename deferred), so the DI token is stable across +the rename. This mirrors `vscode/src/vs/platform//common/.ts` + `Service.ts`. -## Out of scope (intentionally deferred) - -The following are recognised as VSCode-aligned improvements but **NOT** -covered by this convention today; they would be follow-up refactors: - -1. **Split `ICoreProcessService.rpc` (current `CoreRPC` mega-proxy) - into per-domain typed slices**, so a `SessionService` only sees - `Pick` and not the - entire `CoreAPI`. Pure soft slicing (no RPC-layer changes) gives - us boundary discipline + test ergonomics without the IPC-channel - cost. -2. **Dissolve `IEventService`** into per-service typed `Event` - properties wired off a single core stream. The first step is done: - `IEventService` is now a transport-agnostic pure pub-sub bus - (`publish` + `onDidPublish`) and the server's WS-specific concerns - (per-session seq, ring buffer, WS fan-out, replay) live on a separate - server-only `IWSBroadcastService` that subscribes to the bus. The - remaining step is folding the central stream into per-domain typed - emitters on each `IXxxService` so consumers can subscribe to a - narrow `Event` rather than the full firehose. -3. **Real channel registry** (`getChannel(name) / registerChannel(...)` - on `ICoreProcessService`) mirroring VSCode's `IMainProcessService`. +## Domain decomposition (normative) + +A domain folder MAY decompose into up to five roles when the aggregate's +concerns warrant the split. Not every domain needs all five — introduce a +role only when it has a clear owner and a non-empty contract. The +`.ts` + `Service.ts` layout above is the **command** role +for a single-write-owner domain; the roles below extend it. + +| Role | File | Interface | Purpose | Introduce when | +|---|---|---|---|---| +| command | `Service.ts` | `IService` | Aggregate mutations/writes: create / update / archive / restore / purge / fork. The only write entry point for the aggregate. | The aggregate has a lifecycle that needs a stable owner. | +| query | `QueryService.ts` | `IQueryService` | Read models: list / search / count across scopes. No side effects. | The aggregate is listed / searched / counted under more than one scope. | +| runtime | `RuntimeService.ts` | `IRuntimeService` | Event-driven live state: per-id status / live state and status-change subscriptions. A projection, not truth. | The aggregate has live state derived from in-process objects / event streams that must not be written back to truth. | +| repository | `Repository.ts` | `IRepository` | Single-entity persistence: create / get / update and archive / restore / delete as atomic ops. Holds the aggregate's truth. | The aggregate persists and needs a single source of truth behind the service layer. | +| index | `Index.ts` | `IIndex` | Read-model summary index: upsert / remove / list / count over Summary rows. | list / search would otherwise scan truth; the index keeps one read model. | + +`repository` and `index` are persistence-layer contracts (Domain / +Persistence in the service-skill concept docs), not application services — +they sit below command / query / runtime and are not registered as +top-level `*Service` singletons. + +#### Where repositories and indexes live (normative) + +The roles table above describes the *shape* of a repository/index contract; +its *home* depends on which layer consumes it directly: + +- **Repositories and indexes consumed directly by a runtime aggregate live in + the runtime layer** (for example `src/session/sessionRepository.ts`, + `src/session/<...>Index.ts`). They are colocated with the runtime aggregate + that owns them because the runtime must not import from `services/` (the + dependency-direction fence below). They are NOT `*Service` DI singletons + and are NOT under `services/`. +- **Command / query / runtime facades and read-model services consumed at the + RPC / SDK boundary live under `services//`**. Those facades depend + on the runtime repositories / indexes (services → runtime is allowed) and + expose them upward. + +This does not change the dependency direction: the runtime never imports from +`services/`; repositories/indexes live in whichever layer consumes them, and +the `services/` facades depend on them — never the reverse. + +### Dependency direction within a domain (normative) + +These rules are enforced by the ROADMAP and checked by the M7.2 import fence: + +- `repository/` and `index/` do NOT depend on the application service layer + (command / query / runtime). They are the layer the services sit on. +- Within a domain, the command / query / runtime roles do NOT call each + other's business methods. Cross-role effects compose through domain events + / lifecycle hooks, not direct business calls. A query needing per-id + enrichment, or a command needing a sibling read, goes through the lower + layer (`index` / `repository`) or an event. +- The runtime↔services rule at the top of this file still holds: `services/` + may import the agent-core runtime; the runtime must not import back into + `services/`. + +### Migration gate (normative) + +Before any domain's migration milestone starts, that domain MUST have a +finalized concept doc at +`.agents/skills/service-skill/explanation/domains/.md`, plus any +supporting notes under +`.agents/skills/service-skill/reference/domains//`. No concept doc +→ the milestone does not start. This restates the gate in the ROADMAP global +constraints. + +### How to add a domain (normative) + +1. **Concept doc first.** Finalize + `.agents/skills/service-skill/explanation/domains/.md` (plus any + `reference/domains//` notes) before the milestone starts — the + migration gate above blocks otherwise. +2. **Pick the roles.** Start with the command role + (`.ts` + `Service.ts`). Add `query` / `runtime` only + when the aggregate has a second read scope or live state; add + `repository` / `index` when it owns truth or needs a read-model index. + Empty roles are not created. +3. **Apply the layering rule.** `repository` / `index` sit below the + service layer and live where they are consumed — colocated with the + runtime aggregate when the runtime owns them, never imported by + `services/`. Command / query / runtime facades consumed at the RPC / + SDK boundary live under `services//` and depend on those + repositories / indexes (services → runtime, never the reverse). +4. **Register.** Self-register each impl with + `registerSingleton(IXxxService, XxxService, InstantiationType.Delayed)` + and re-export contracts + impl from `index.ts` so the package barrel + runs the side effect. `repository` / `index` are not top-level + singletons. +5. **Fence.** The M7.2 dependency-direction test enforces runtime ↛ + services, repository/index ↛ services, and no cross-service business + imports. New domains must keep it green; the within-domain + cross-role rule is code-review convention, not grep-enforced. + +### Reference index + +- command — [`command-service.md`](../../../../.agents/skills/service-skill/reference/patterns/command-service.md) +- query — [`query-service.md`](../../../../.agents/skills/service-skill/reference/patterns/query-service.md) +- runtime — [`runtime-service.md`](../../../../.agents/skills/service-skill/reference/patterns/runtime-service.md) +- repository + index — [`repository-and-index.md`](../../../../.agents/skills/service-skill/reference/patterns/repository-and-index.md) + +## Out of scope / completed + +Done by the DI domain-runtime-services refactor: + +1. **CoreRPC slicing** — facades no longer depend on the `CoreRPC` + mega-proxy. Each routes to the in-process `CoreAPI` through + `ICoreRuntime.getCoreApi()` (zero-serialization) or through peer + domain services, so a `SessionService` only sees the methods it + actually calls. +2. **Domain decomposition** — domains split into command / query / + runtime / repository / index roles when the aggregate warrants it + (e.g. `session/` → `ISessionService` + `ISessionQueryService` + + `ISessionRuntimeService`; `SessionRepository` + `SessionIndex` live in + the runtime layer). See the roles table above. +3. **Event projection boundary** — domain lifecycle hooks + (`onSessionWillStart`, `onSessionWillClose`, `onAgentWillResume`, …) + carry the cross-cutting effects that used to be hard-wired, and the + `IDomainEventBus` + `event/projection` boundary turns core events into + protocol events. `IEventService` stays a transport-agnostic pub-sub + bus (`publish` + `onDidPublish`); WS fan-out remains on the + server-only `IWSBroadcastService`. + +Still deferred (would be follow-up refactors): + +1. **Per-domain typed emitters** — fold the central `IEventService` + firehose into narrow `Event` properties on each `IXxxService`, so + consumers subscribe to a domain stream rather than the full bus. +2. **Real channel registry** (`getChannel(name) / registerChannel(...)` + on `ICoreRuntime`) mirroring VSCode's `IMainProcessService`. Requires agent-core RPC layer changes. -When taking on (1) or (2), the new types still follow the rules above — -no new suffixes get reintroduced. +When taking on either, the new types still follow the rules above — no +new suffixes get reintroduced. -## Per-domain layout (current) +## Per-domain layout (terminal) | Folder | Contracts | Impl | Decorator | |---|---|---|---| -| `coreProcess/` | `coreProcess.ts` | `coreProcessService.ts` | `ICoreProcessService` | +| `coreProcess/` | `coreProcess.ts` | `coreProcessService.ts`, `coreProcessClient.ts` | `ICoreRuntime` | | `event/` | `event.ts` | `eventService.ts` | `IEventService` | | `approval/` | `approval.ts` | (impl lives in server) | `IApprovalService` | | `question/` | `question.ts` | (impl lives in server) | `IQuestionService` | @@ -96,16 +209,26 @@ no new suffixes get reintroduced. | `logger/` | `logger.ts` | (adapter lives in server) | `ILogService` | | `fileStore/` | `fileStore.ts` | `fileStoreService.ts` | `IFileStore` | | `fs/` | `fs.ts`, `fsSearch.ts`, `fsGit.ts`, `fsWatcher.ts`, `fsPathSafety.ts` | `fsService.ts`, `fsSearchService.ts`, `fsGitService.ts`, `fsWatcherService.ts` | `IFsService`, `IFsSearchService`, `IFsGitService`, `IFsWatcher` | -| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts` | `workspaceRegistryService.ts`, `workspaceFsService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService` | +| `workspace/` | `workspaceRegistry.ts`, `workspaceFs.ts`, `workspace.ts` | `workspaceRegistryService.ts`, `workspaceFsService.ts`, `workspaceService.ts` | `IWorkspaceRegistry`, `IWorkspaceFsService`, `IWorkspaceService` | | `config/` | `config.ts` | `configService.ts` | `IConfigService` | -| `session/` | `session.ts` | `sessionService.ts` | `ISessionService` | +| `session/` | `session.ts` | `sessionService.ts`, `sessionQueryService.ts`, `sessionRuntimeService.ts` | `ISessionService`, `ISessionQueryService`, `ISessionRuntimeService` | | `message/` | `message.ts` | `messageService.ts` | `IMessageService` | | `prompt/` | `prompt.ts` | `promptService.ts` | `IPromptService` | | `tool/` | `tool.ts` | `toolService.ts` | `IToolService` | | `mcp/` | `mcp.ts` | `mcpService.ts` | `IMcpService` | +| `modelCatalog/` | `modelCatalog.ts` | `modelCatalogService.ts` | `IModelCatalogService` | +| `skill/` | `skill.ts` | `skillService.ts` | `ISkillService` | | `task/` | `task.ts` | `taskService.ts` | `ITaskService` | | `oauth/` | `oauth.ts` | `oauthService.ts` | `IOAuthService` | | `authSummary/` | `authSummary.ts` | `authSummaryService.ts` | `IAuthSummaryService` | +| `terminal/` | `terminal.ts` | `terminalService.ts` | `ITerminalService` | +| `plugin/` | (runtime, not under `services/`) | `#/plugin` | (not a DI service facade) | + +`plugin/` is a runtime-layer aggregate at `src/plugin/` (imported as +`#/plugin`), consumed by `services/` facades rather than exposing a +`*Service` of its own. It is listed here so the boundary is explicit: the +runtime owns plugin loading / manifests / storage; `services/` only +projects it upward. Adding a new service: create the folder + contracts + impl pair, add a bottom-of-file `registerSingleton(IXxxService, XxxService, @@ -125,7 +248,7 @@ This layer uses the registry-based wiring pattern modelled on 1. **Each `Service.ts` impl file self-registers** at the bottom: ```ts - import { registerSingleton, InstantiationType } from '../../di'; + import { registerSingleton, InstantiationType } from '../../_base/di'; // …class body… registerSingleton(IXxxService, XxxService, InstantiationType.Delayed); ``` @@ -146,7 +269,7 @@ This layer uses the registry-based wiring pattern modelled on 3. **Server-side `services.set(...)` may override** the registry-derived entry for services that need runtime static args (e.g. - `services.set(ICoreProcessService, new SyncDescriptor(CoreProcessService, + `services.set(ICoreRuntime, new SyncDescriptor(CoreProcessService, [opts.coreProcessOptions ?? {}], false))` in `start.ts`) or for prebuilt instances carrying external closures (`PinoLogger`, `FastifyRestGateway`). `registerSingleton` does not throw on a diff --git a/packages/agent-core/src/services/authSummary/authSummary.ts b/packages/agent-core/src/services/authSummary/authSummary.ts index ae4f7c214..d1529877b 100644 --- a/packages/agent-core/src/services/authSummary/authSummary.ts +++ b/packages/agent-core/src/services/authSummary/authSummary.ts @@ -21,13 +21,13 @@ * differentiate them. * * **Implementation** (`AuthSummaryService`): Reads the live config via - * `ICoreProcessService.rpc.getKimiConfig({})` and the managed-OAuth credential + * `ICoreRuntime.rpc.getKimiConfig({})` and the managed-OAuth credential * state via a cached-token lookup. Both are cheap (in-process RPC + * a token-file existence probe), so we run them on every call instead of * caching — keeps the staleness window at zero. */ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { AuthSummary } from '@moonshot-ai/protocol'; export interface IAuthSummaryService { diff --git a/packages/agent-core/src/services/authSummary/authSummaryService.ts b/packages/agent-core/src/services/authSummary/authSummaryService.ts index e288d2e9e..c4e8ba548 100644 --- a/packages/agent-core/src/services/authSummary/authSummaryService.ts +++ b/packages/agent-core/src/services/authSummary/authSummaryService.ts @@ -2,12 +2,13 @@ * `AuthSummaryService` — implementation of `IAuthSummaryService`. */ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { KimiConfig } from '../../config'; +import type { CoreRPC } from '../../rpc'; import type { AuthSummary } from '@moonshot-ai/protocol'; import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; import { IEnvironmentService } from '../environment/environment'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; +import { ICoreRuntime } from '#/coreProcess'; import { IAuthSummaryService, AuthProvisioningRequiredError, @@ -18,6 +19,16 @@ import { /** Wire name of the OAuth-managed provider (`@moonshot-ai/kimi-code-oauth`'s `KIMI_CODE_PROVIDER_NAME`). */ const MANAGED_PROVIDER_NAME = 'managed:kimi-code'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class AuthSummaryService extends Disposable implements IAuthSummaryService { @@ -27,7 +38,7 @@ export class AuthSummaryService constructor( @IEnvironmentService private readonly env: IEnvironmentService, - @ICoreProcessService private readonly core: ICoreProcessService, + @ICoreRuntime private readonly core: ICoreRuntime, ) { super(); this._authFacade = createManagedAuthFacade(env); @@ -117,7 +128,19 @@ export class AuthSummaryService // KimiCore's `this.config` only refreshes when something explicitly // asks for `reload`. Without this flag, `GET /v1/auth` would stay // `ready:false` for the entire daemon lifetime after first login. - return this.core.rpc.getKimiConfig({ reload: true }); + return this.coreApi().getKimiConfig({ reload: true }); + } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); } private async _hasCachedToken(providerName: string): Promise { @@ -140,7 +163,7 @@ function nonEmpty(value: string | undefined): string | null { } // Self-register under the global singleton registry. All ctor deps are -// `@I…`-injected (@IEnvironmentService / @ICoreProcessService); +// `@I…`-injected (@IEnvironmentService / @ICoreRuntime); // `staticArguments = []`. `supportsDelayedInstantiation = false` preserves // current reverse-dispose semantics. registerSingleton(IAuthSummaryService, AuthSummaryService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/services/config/config.ts b/packages/agent-core/src/services/config/config.ts index 0375fa431..9f2384171 100644 --- a/packages/agent-core/src/services/config/config.ts +++ b/packages/agent-core/src/services/config/config.ts @@ -1,4 +1,4 @@ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { ConfigResponse, PatchConfigRequest } from '@moonshot-ai/protocol'; export interface IConfigService { diff --git a/packages/agent-core/src/services/config/configService.ts b/packages/agent-core/src/services/config/configService.ts index 582210bec..704dbbe9d 100644 --- a/packages/agent-core/src/services/config/configService.ts +++ b/packages/agent-core/src/services/config/configService.ts @@ -1,29 +1,40 @@ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { KimiConfig, ProviderConfig } from '../../config'; +import type { CoreRPC } from '../../rpc'; import type { ConfigResponse, PatchConfigRequest } from '@moonshot-ai/protocol'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; -import { IEventService } from '../event/event'; +import { ICoreRuntime } from '#/coreProcess'; +import { IEventService } from '#/event'; import { IConfigService } from './config'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class ConfigService extends Disposable implements IConfigService { readonly _serviceBrand: undefined; constructor( - @ICoreProcessService private readonly core: ICoreProcessService, + @ICoreRuntime private readonly core: ICoreRuntime, @IEventService private readonly eventService: IEventService, ) { super(); } async get(): Promise { - const config = await this.core.rpc.getKimiConfig({ reload: true }); + const config = await this.coreApi().getKimiConfig({ reload: true }); return toConfigResponse(config); } async set(patch: PatchConfigRequest): Promise { const camelPatch = convertKeysSnakeToCamel(patch) as Record; - const updated = await this.core.rpc.setKimiConfig(camelPatch); + const updated = await this.coreApi().setKimiConfig(camelPatch); const response = toConfigResponse(updated); this.eventService.publish({ @@ -36,6 +47,18 @@ export class ConfigService extends Disposable implements IConfigService { return response; } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } } function toConfigResponse(config: KimiConfig): ConfigResponse { diff --git a/packages/agent-core/src/services/environment/environment.ts b/packages/agent-core/src/services/environment/environment.ts index a8eea68f1..fd3e3e85d 100644 --- a/packages/agent-core/src/services/environment/environment.ts +++ b/packages/agent-core/src/services/environment/environment.ts @@ -8,7 +8,7 @@ * arg" pattern in services that only need path resolution. */ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; export interface IEnvironmentService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core/src/services/fileStore/fileStore.ts b/packages/agent-core/src/services/fileStore/fileStore.ts index 100b73826..e8240b2d9 100644 --- a/packages/agent-core/src/services/fileStore/fileStore.ts +++ b/packages/agent-core/src/services/fileStore/fileStore.ts @@ -1,7 +1,7 @@ import type { Readable } from 'node:stream'; -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { FileMeta } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/fileStore/fileStoreService.ts b/packages/agent-core/src/services/fileStore/fileStoreService.ts index 66391ee2d..4b963da33 100644 --- a/packages/agent-core/src/services/fileStore/fileStoreService.ts +++ b/packages/agent-core/src/services/fileStore/fileStoreService.ts @@ -7,7 +7,7 @@ import type { Readable } from 'node:stream'; import { ulid } from 'ulid'; -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { FileMeta } from '@moonshot-ai/protocol'; import { IEnvironmentService } from '../environment/environment'; diff --git a/packages/agent-core/src/services/fs/fs.ts b/packages/agent-core/src/services/fs/fs.ts index f7a3e6f8d..2daae7e64 100644 --- a/packages/agent-core/src/services/fs/fs.ts +++ b/packages/agent-core/src/services/fs/fs.ts @@ -1,6 +1,6 @@ -import { createDecorator } from '../../di'; -import type { IDisposable } from '../../di'; +import { createDecorator } from '../../_base/di'; +import type { IDisposable } from '../../_base/di'; import type { FsEntry, FsListManyRequest, diff --git a/packages/agent-core/src/services/fs/fsGit.ts b/packages/agent-core/src/services/fs/fsGit.ts index 772fb58fb..d60415b13 100644 --- a/packages/agent-core/src/services/fs/fsGit.ts +++ b/packages/agent-core/src/services/fs/fsGit.ts @@ -1,8 +1,8 @@ import path from 'node:path'; -import { createDecorator } from '../../di'; -import type { IDisposable } from '../../di'; +import { createDecorator } from '../../_base/di'; +import type { IDisposable } from '../../_base/di'; import type { FsDiffRequest, FsDiffResponse, diff --git a/packages/agent-core/src/services/fs/fsGitService.ts b/packages/agent-core/src/services/fs/fsGitService.ts index 5784ecd77..d6a910153 100644 --- a/packages/agent-core/src/services/fs/fsGitService.ts +++ b/packages/agent-core/src/services/fs/fsGitService.ts @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { FsDiffRequest, FsDiffResponse, @@ -11,7 +11,7 @@ import type { FsGitStatusResponse, FsPullRequest, } from '@moonshot-ai/protocol'; -import { ISessionService } from '../session/session'; +import { ISessionService } from '#/session'; import { FsPathNotFoundError } from './fs'; import { IFsGitService, FsGitUnavailableError, parsePorcelain, parseNumstat } from './fsGit'; diff --git a/packages/agent-core/src/services/fs/fsSearch.ts b/packages/agent-core/src/services/fs/fsSearch.ts index 5632ebeb0..148b87d4b 100644 --- a/packages/agent-core/src/services/fs/fsSearch.ts +++ b/packages/agent-core/src/services/fs/fsSearch.ts @@ -1,6 +1,6 @@ -import { createDecorator } from '../../di'; -import type { IDisposable } from '../../di'; +import { createDecorator } from '../../_base/di'; +import type { IDisposable } from '../../_base/di'; import type { FsGrepRequest, FsGrepResponse, diff --git a/packages/agent-core/src/services/fs/fsSearchService.ts b/packages/agent-core/src/services/fs/fsSearchService.ts index 184243f60..a77d2af33 100644 --- a/packages/agent-core/src/services/fs/fsSearchService.ts +++ b/packages/agent-core/src/services/fs/fsSearchService.ts @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { FsGrepFileHit, FsGrepMatch, @@ -16,7 +16,7 @@ import type { } from '@moonshot-ai/protocol'; import ignore, { type Ignore } from 'ignore'; -import { ISessionService } from '../session/session'; +import { ISessionService } from '#/session'; import { ILogService } from '../logger/logger'; import { IFsSearchService, FsGrepTimeoutError } from './fsSearch'; diff --git a/packages/agent-core/src/services/fs/fsService.ts b/packages/agent-core/src/services/fs/fsService.ts index ba678f2f7..9098a7b28 100644 --- a/packages/agent-core/src/services/fs/fsService.ts +++ b/packages/agent-core/src/services/fs/fsService.ts @@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { FsEntry, FsListManyRequest, @@ -19,7 +19,7 @@ import type { } from '@moonshot-ai/protocol'; import ignore, { type Ignore } from 'ignore'; -import { ISessionService, SessionNotFoundError } from '../session/session'; +import { ISessionService, SessionNotFoundError } from '#/session'; import { IFsService, diff --git a/packages/agent-core/src/services/fs/fsWatcher.ts b/packages/agent-core/src/services/fs/fsWatcher.ts index 278aa554d..cfc9a83a4 100644 --- a/packages/agent-core/src/services/fs/fsWatcher.ts +++ b/packages/agent-core/src/services/fs/fsWatcher.ts @@ -1,6 +1,6 @@ import type { FSWatcher } from 'chokidar'; -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { FsChangeEntry } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/fs/fsWatcherService.ts b/packages/agent-core/src/services/fs/fsWatcherService.ts index 8811c3a47..1259366ab 100644 --- a/packages/agent-core/src/services/fs/fsWatcherService.ts +++ b/packages/agent-core/src/services/fs/fsWatcherService.ts @@ -2,9 +2,9 @@ import nodePath from 'node:path'; import { FSWatcher } from 'chokidar'; -import { Disposable, DisposableMap, ReferenceCollection, dispose } from '../../di'; -import type { IDisposable, IReference } from '../../di'; -import { ISessionService } from '../session/session'; +import { Disposable, DisposableMap, ReferenceCollection, dispose } from '../../_base/di'; +import type { IDisposable, IReference } from '../../_base/di'; +import { ISessionService } from '#/session'; import type { FsChangeAction, diff --git a/packages/agent-core/src/services/index.ts b/packages/agent-core/src/services/index.ts index a76c6d95c..d6c546527 100644 --- a/packages/agent-core/src/services/index.ts +++ b/packages/agent-core/src/services/index.ts @@ -1,31 +1,3 @@ -export { BridgeClientAPI } from './coreProcess/coreProcessClient'; -export type { CoreProcessClientDeps } from './coreProcess/coreProcessClient'; -export { - ICoreProcessService, - type CoreProcessServiceOptions, -} from './coreProcess/coreProcess'; -export { CoreProcessService } from './coreProcess/coreProcessService'; - -export { IEventService } from './event/event'; -export { EventService } from './event/eventService'; - -export { IApprovalService } from './approval/approval'; -export type { ApprovalRequest, ApprovalResponse } from './approval/approval'; -export { - toAgentCoreResponse as approvalToAgentCoreResponse, - toBrokerRequest as approvalToBrokerRequest, - type ToBrokerRequestParams as ApprovalToBrokerRequestParams, -} from './approval/approval'; - -export { IQuestionService } from './question/question'; -export type { QuestionRequest, QuestionResult } from './question/question'; -export { - toAgentCoreResponse as questionToAgentCoreResponse, - toBrokerRequest as questionToBrokerRequest, - dismissedResult as questionDismissedResult, - type QuestionToBrokerRequestParams, -} from './question/question'; - export { IEnvironmentService } from './environment/environment'; export { ILogService } from './logger/logger'; @@ -95,6 +67,8 @@ export { RECENT_ROOTS_LIMIT, } from './workspace/workspaceFs'; export { WorkspaceFsService } from './workspace/workspaceFsService'; +export { IWorkspaceService } from './workspace/workspace'; +export { WorkspaceService } from './workspace/workspaceService'; export { IAuthSummaryService, @@ -122,48 +96,6 @@ export { ModelCatalogService } from './modelCatalog/modelCatalogService'; export { IConfigService } from './config/config'; export { ConfigService } from './config/configService'; -export { - ISessionService, - SessionNotFoundError, - SessionUndoUnavailableError, - toProtocolSession, -} from './session/session'; -export type { SessionClientTelemetry, SessionCreateOptions, SessionListQuery } from './session/session'; -export { SessionService } from './session/sessionService'; - -export { - IMessageService, - MessageNotFoundError, - deriveMessageId, - parseMessageId, - toProtocolMessage, -} from './message/message'; -export type { MessageListQuery } from './message/message'; -export { MessageService } from './message/messageService'; -export { - readWireRecords, - readWireTranscript, - reduceWireRecords, -} from './message/transcript'; -export type { TranscriptEntry, WireTranscript } from './message/transcript'; - -export { - IPromptService, - PromptAlreadyCompletedError, - PromptNotFoundError, - SessionBusyError, -} from './prompt/prompt'; -export type { - AgentStateSnapshot, - PromptAbortResult, - PromptDispatchLogEntry, - SyntheticPromptAbortedEvent, - SyntheticPromptCompletedEvent, - SyntheticPromptSteeredEvent, - SyntheticPromptSubmittedEvent, -} from './prompt/prompt'; -export { PromptService } from './prompt/promptService'; - export { IToolService, toProtocolTool, diff --git a/packages/agent-core/src/services/logger/logger.ts b/packages/agent-core/src/services/logger/logger.ts index 140a6e245..a91cf874a 100644 --- a/packages/agent-core/src/services/logger/logger.ts +++ b/packages/agent-core/src/services/logger/logger.ts @@ -1,6 +1,6 @@ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; export interface ILogService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core/src/services/mcp/mcp.ts b/packages/agent-core/src/services/mcp/mcp.ts index 20e2d40d5..d45004848 100644 --- a/packages/agent-core/src/services/mcp/mcp.ts +++ b/packages/agent-core/src/services/mcp/mcp.ts @@ -1,7 +1,7 @@ /** * `IMcpService` — daemon-facing MCP server surface. * - * Wraps `ICoreProcessService.rpc.{listMcpServers, reconnectMcpServer}` and adapts + * Wraps `ICoreRuntime.rpc.{listMcpServers, reconnectMcpServer}` and adapts * the agent-core `McpServerInfo` shape into SCHEMAS §8 `McpServer`. The * adapter helper (`toProtocolMcpServer`) is co-located here. * @@ -32,7 +32,7 @@ * name-as-id at the wire boundary. Both are 1:1 within a daemon process. */ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { McpServerInfo } from '../../rpc'; import type { McpServer, diff --git a/packages/agent-core/src/services/mcp/mcpService.ts b/packages/agent-core/src/services/mcp/mcpService.ts index d1cf7fd5a..2c8a29acf 100644 --- a/packages/agent-core/src/services/mcp/mcpService.ts +++ b/packages/agent-core/src/services/mcp/mcpService.ts @@ -2,20 +2,31 @@ * `McpService` — implementation of `IMcpService`. */ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { McpServer } from '@moonshot-ai/protocol'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; +import type { CoreRPC } from '../../rpc'; +import { ICoreRuntime } from '#/coreProcess'; import { IMcpService, McpServerNotFoundError, toProtocolMcpServer, } from './mcp'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class McpService extends Disposable implements IMcpService { readonly _serviceBrand: undefined; - constructor(@ICoreProcessService private readonly core: ICoreProcessService) { + constructor(@ICoreRuntime private readonly core: ICoreRuntime) { super(); } @@ -26,7 +37,7 @@ export class McpService extends Disposable implements IMcpService { // RPC plumbing isn't reachable until a session is open). const sessionId = await this._anyKnownSessionId(); if (sessionId === undefined) return []; - const raw = await this.core.rpc.listMcpServers({ sessionId }); + const raw = await this.coreApi().listMcpServers({ sessionId }); return raw.map(toProtocolMcpServer); } @@ -40,11 +51,11 @@ export class McpService extends Disposable implements IMcpService { // call will reject for unknown names; we pre-check so the route can // emit a deterministic 40408 envelope without depending on agent-core // error message shape. - const known = await this.core.rpc.listMcpServers({ sessionId }); + const known = await this.coreApi().listMcpServers({ sessionId }); if (!known.some((s) => s.name === serverId)) { throw new McpServerNotFoundError(serverId); } - await this.core.rpc.reconnectMcpServer({ sessionId, name: serverId }); + await this.coreApi().reconnectMcpServer({ sessionId, name: serverId }); return { restarting: true }; } @@ -53,13 +64,25 @@ export class McpService extends Disposable implements IMcpService { * most recently created session id, or `undefined` when no sessions exist. */ private async _anyKnownSessionId(): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); if (all.length === 0) return undefined; // Sort by createdAt desc — newest sessions are the most likely to have // an active MCP RPC binding. const sorted = [...all].sort((a, b) => b.createdAt - a.createdAt); return sorted[0]?.id; } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } } // Self-register under the global singleton registry. All ctor deps are diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts index b17387297..c15095929 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts @@ -1,4 +1,4 @@ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { KimiConfig, ModelAlias, ProviderConfig } from '../../config'; import type { ModelCatalogItem, diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index bd8eb79f3..90d9809d1 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -1,5 +1,6 @@ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { KimiConfig, ModelAlias, ProviderConfig } from '../../config'; +import type { CoreRPC } from '../../rpc'; import type { ModelCatalogItem, ProviderCatalogItem, @@ -16,7 +17,7 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; +import { ICoreRuntime } from '#/coreProcess'; import { IEnvironmentService } from '../environment/environment'; import { IModelCatalogService, @@ -26,6 +27,16 @@ import { toProtocolProvider, } from './modelCatalog'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class ModelCatalogService extends Disposable implements IModelCatalogService { @@ -35,7 +46,7 @@ export class ModelCatalogService constructor( @IEnvironmentService env: IEnvironmentService, - @ICoreProcessService private readonly core: ICoreProcessService, + @ICoreRuntime private readonly core: ICoreRuntime, ) { super(); this._authFacade = createManagedAuthFacade(env); @@ -43,7 +54,7 @@ export class ModelCatalogService static _createForTest( env: IEnvironmentService, - core: ICoreProcessService, + core: ICoreRuntime, authFacade: ServicesAuthFacade, ): ModelCatalogService { const service = new ModelCatalogService(env, core); @@ -83,7 +94,7 @@ export class ModelCatalogService throw new ModelNotFoundError(modelId); } - const updated = await this.core.rpc.setKimiConfig({ defaultModel: modelId }); + const updated = await this.coreApi().setKimiConfig({ defaultModel: modelId }); const updatedAlias = updated.models?.[modelId] ?? alias; return { default_model: modelId, @@ -148,8 +159,8 @@ export class ModelCatalogService collectModelIdsForAliases(config, refreshedAliasKeys), collectModelIdsForAliases(next, refreshedAliasKeys), ); - await this.core.rpc.removeKimiProvider({ providerId: KIMI_CODE_PROVIDER_NAME }); - await this.core.rpc.setKimiConfig({ + await this.coreApi().removeKimiProvider({ providerId: KIMI_CODE_PROVIDER_NAME }); + await this.coreApi().setKimiConfig({ providers: next.providers, models: next.models, defaultModel: next.defaultModel, @@ -173,7 +184,19 @@ export class ModelCatalogService } private async _readConfig(): Promise { - return this.core.rpc.getKimiConfig({ reload: true }); + return this.coreApi().getKimiConfig({ reload: true }); + } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); } private async _provider( diff --git a/packages/agent-core/src/services/oauth/oauth.ts b/packages/agent-core/src/services/oauth/oauth.ts index 58871bac7..59c3024e3 100644 --- a/packages/agent-core/src/services/oauth/oauth.ts +++ b/packages/agent-core/src/services/oauth/oauth.ts @@ -62,7 +62,7 @@ * `DeviceCodeTimeoutError`. */ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { OAuthFlowSnapshot, OAuthFlowStart, diff --git a/packages/agent-core/src/services/oauth/oauthService.ts b/packages/agent-core/src/services/oauth/oauthService.ts index 055556993..f99d75e5c 100644 --- a/packages/agent-core/src/services/oauth/oauthService.ts +++ b/packages/agent-core/src/services/oauth/oauthService.ts @@ -2,8 +2,8 @@ * `OAuthService` — implementation of `IOAuthService`. */ -import { Disposable, DisposableMap, InstantiationType, registerSingleton } from '../../di'; -import type { IDisposable } from '../../di'; +import { Disposable, DisposableMap, InstantiationType, registerSingleton } from '../../_base/di'; +import type { IDisposable } from '../../_base/di'; import { DeviceCodeTimeoutError, KIMI_CODE_PROVIDER_NAME, diff --git a/packages/agent-core/src/services/session/session.ts b/packages/agent-core/src/services/session/session.ts deleted file mode 100644 index de64868cf..000000000 --- a/packages/agent-core/src/services/session/session.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { createDecorator } from '../../di'; -import { encodeWorkDirKey } from '../../session/store'; -import type { Event } from '../../base/common/event'; -import type { SessionSummary } from '../../rpc'; -import type { SessionMeta } from '../../session'; -import { - emptySessionUsage, - type CompactSessionRequest, - type CompactSessionResponse, - type CursorQuery, - type PageResponse, - type Session, - type SessionChildCreate, - type SessionCreate, - type SessionFork, - type SessionStatusResponse, - type SessionUpdate, - type UndoSessionRequest, - type UndoSessionResponse, -} from '@moonshot-ai/protocol'; - -export interface SessionListQuery extends CursorQuery { - status?: import('@moonshot-ai/protocol').SessionStatus; - workDir?: string; - includeArchive?: boolean; -} - -export interface SessionClientTelemetry { - id?: string; - name?: string; - version?: string; - uiMode?: string; -} - -export interface SessionCreateOptions { - client?: SessionClientTelemetry; -} - -export interface ISessionService { - readonly _serviceBrand: undefined; - - create(input: SessionCreate, options?: SessionCreateOptions): Promise; - - list(query: SessionListQuery): Promise>; - - get(id: string): Promise; - - update(id: string, input: SessionUpdate): Promise; - - fork(id: string, input: SessionFork): Promise; - - listChildren(id: string, query: SessionListQuery): Promise>; - - createChild(id: string, input: SessionChildCreate): Promise; - - getStatus(id: string): Promise; - - compact(id: string, input: CompactSessionRequest): Promise; - - undo(id: string, input: UndoSessionRequest): Promise; - - archive(id: string): Promise<{ archived: true }>; - - readonly onDidCreate: Event<{ session: Session }>; - - readonly onDidClose: Event<{ sessionId: string }>; -} - -export const ISessionService = createDecorator('sessionService'); - -export class SessionUndoUnavailableError extends Error { - readonly sessionId: string; - constructor(sessionId: string, message = 'Nothing to undo in the active context.') { - super(message); - this.name = 'SessionUndoUnavailableError'; - this.sessionId = sessionId; - } -} - -export class SessionNotFoundError extends Error { - readonly sessionId: string; - constructor(sessionId: string) { - super(`session ${sessionId} does not exist`); - this.name = 'SessionNotFoundError'; - this.sessionId = sessionId; - } -} - -export function toProtocolSession( - summary: SessionSummary, - meta?: SessionMeta | undefined, -): Session { - const summaryMetadata = (summary.metadata ?? {}) as Record; - const customMetadata = (meta?.custom ?? {}) as Record; - const cwd = - (typeof customMetadata['cwd'] === 'string' && customMetadata['cwd']) || - (typeof summaryMetadata['cwd'] === 'string' && summaryMetadata['cwd']) || - summary.workDir; - - const { goal: _dropSummaryGoal, ...summaryWithoutGoal } = summaryMetadata; - const { goal: _dropCustomGoal, ...customWithoutGoal } = customMetadata; - - const mergedMetadata: Session['metadata'] = { - ...summaryWithoutGoal, - ...customWithoutGoal, - cwd, - }; - - const title = meta?.title ?? summary.title ?? ''; - const workspaceId = encodeWorkDirKey(summary.workDir); - - return { - id: summary.id, - workspace_id: workspaceId, - title, - created_at: new Date(summary.createdAt).toISOString(), - updated_at: new Date(summary.updatedAt).toISOString(), - status: 'idle', - archived: summary.archived === true, - metadata: mergedMetadata, - agent_config: { - model: '', - }, - usage: emptySessionUsage(), - permission_rules: [], - message_count: 0, - last_seq: 0, - }; -} diff --git a/packages/agent-core/src/services/skill/skill.ts b/packages/agent-core/src/services/skill/skill.ts index 06adbdf33..55c9a4180 100644 --- a/packages/agent-core/src/services/skill/skill.ts +++ b/packages/agent-core/src/services/skill/skill.ts @@ -1,7 +1,7 @@ /** * `ISkillService` — daemon-facing skill surface. * - * Wraps `ICoreProcessService.rpc.{listSkills, activateSkill}` and adapts the + * Wraps `ICoreRuntime.rpc.{listSkills, activateSkill}` and adapts the * agent-core `SkillSummary` shape (camelCase) into the wire `SkillDescriptor` * (snake_case). The adapter helper (`toProtocolSkill`) is co-located here. * @@ -31,7 +31,7 @@ * `createDecorator` value and the `SkillSummary` type. */ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { SkillSummary as AgentCoreSkillSummary } from '../../rpc'; import type { SkillDescriptor } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/skill/skillService.ts b/packages/agent-core/src/services/skill/skillService.ts index cc83dc798..3967cb850 100644 --- a/packages/agent-core/src/services/skill/skillService.ts +++ b/packages/agent-core/src/services/skill/skillService.ts @@ -2,12 +2,13 @@ * `SkillService` — implementation of `ISkillService`. */ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import { ErrorCodes, KimiError } from '../../errors'; import type { SkillDescriptor } from '@moonshot-ai/protocol'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; -import { SessionNotFoundError } from '../session/session'; +import type { CoreRPC } from '../../rpc'; +import { ICoreRuntime } from '#/coreProcess'; +import { SessionNotFoundError } from '#/session'; import { ISkillService, SkillNotActivatableError, @@ -18,23 +19,33 @@ import { /** Matches the convention used elsewhere in services (prompt-service uses 'main'). */ const MAIN_AGENT_ID = 'main'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class SkillService extends Disposable implements ISkillService { readonly _serviceBrand: undefined; - constructor(@ICoreProcessService private readonly core: ICoreProcessService) { + constructor(@ICoreRuntime private readonly core: ICoreRuntime) { super(); } async list(sessionId: string): Promise { await this._requireLoadedSession(sessionId); - const raw = await this.core.rpc.listSkills({ sessionId }); + const raw = await this.coreApi().listSkills({ sessionId }); return raw.map(toProtocolSkill); } async activate(sessionId: string, skillName: string, args?: string): Promise { await this._requireLoadedSession(sessionId); try { - await this.core.rpc.activateSkill({ + await this.coreApi().activateSkill({ sessionId, agentId: MAIN_AGENT_ID, name: skillName, @@ -60,11 +71,23 @@ export class SkillService extends Disposable implements ISkillService { * `PromptService.submit` / `SessionService.undo`. */ private async _requireLoadedSession(sessionId: string): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); if (!all.some((s) => s.id === sessionId)) { throw new SessionNotFoundError(sessionId); } - await this.core.rpc.resumeSession({ sessionId }); + await this.coreApi().resumeSession({ sessionId }); + } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); } } diff --git a/packages/agent-core/src/services/task/task.ts b/packages/agent-core/src/services/task/task.ts index 1eca2b2de..a94983f2a 100644 --- a/packages/agent-core/src/services/task/task.ts +++ b/packages/agent-core/src/services/task/task.ts @@ -1,7 +1,7 @@ /** * `ITaskService` — daemon-facing background task surface. * - * Wraps `ICoreProcessService.rpc.{getBackground, stopBackground}` and adapts + * Wraps `ICoreRuntime.rpc.{getBackground, stopBackground}` and adapts * `BackgroundTaskInfo` (camelCase + ms timestamps + agent-core literal sets) * into SCHEMAS §7 `BackgroundTask` (snake_case + ISO + spec literal sets). * @@ -38,7 +38,7 @@ * lost → failed (lossy) */ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { BackgroundTaskInfo } from '../../agent/background'; import type { BackgroundTask, BackgroundTaskKind, BackgroundTaskStatus } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/task/taskService.ts b/packages/agent-core/src/services/task/taskService.ts index 8e0db7c27..ed30c44fb 100644 --- a/packages/agent-core/src/services/task/taskService.ts +++ b/packages/agent-core/src/services/task/taskService.ts @@ -2,11 +2,12 @@ * `TaskService` — implementation of `ITaskService`. */ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { BackgroundTask } from '@moonshot-ai/protocol'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; -import { SessionNotFoundError } from '../session/session'; +import type { CoreRPC } from '../../rpc'; +import { ICoreRuntime } from '#/coreProcess'; +import { SessionNotFoundError } from '#/session'; import { ITaskService, TaskNotFoundError, @@ -20,10 +21,20 @@ import { const MAIN_AGENT_ID = 'main'; const DEFAULT_TASK_OUTPUT_PREVIEW_BYTES = 32 * 1024; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class TaskService extends Disposable implements ITaskService { readonly _serviceBrand: undefined; - constructor(@ICoreProcessService private readonly core: ICoreProcessService) { + constructor(@ICoreRuntime private readonly core: ICoreRuntime) { super(); } @@ -53,7 +64,7 @@ export class TaskService extends Disposable implements ITaskService { if (options?.withOutput) { const tailBytes = options.outputBytes ?? DEFAULT_TASK_OUTPUT_PREVIEW_BYTES; try { - const preview = await this.core.rpc.getBackgroundOutput({ + const preview = await this.coreApi().getBackgroundOutput({ sessionId, agentId: MAIN_AGENT_ID, taskId, @@ -84,7 +95,7 @@ export class TaskService extends Disposable implements ITaskService { if (isTerminalStatus(wireStatus)) { throw new TaskAlreadyFinishedError(sessionId, taskId, wireStatus); } - await this.core.rpc.stopBackground({ + await this.coreApi().stopBackground({ sessionId, agentId: MAIN_AGENT_ID, taskId, @@ -95,7 +106,7 @@ export class TaskService extends Disposable implements ITaskService { // --- internals ------------------------------------------------------------ private async _requireSession(sessionId: string): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); if (!all.some((s) => s.id === sessionId)) { throw new SessionNotFoundError(sessionId); } @@ -105,7 +116,7 @@ export class TaskService extends Disposable implements ITaskService { sessionId: string, ): Promise>[number]>> { try { - return await this.core.rpc.getBackground({ + return await this.coreApi().getBackground({ sessionId, agentId: MAIN_AGENT_ID, }); @@ -114,6 +125,18 @@ export class TaskService extends Disposable implements ITaskService { return []; } } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } } // Self-register under the global singleton registry. All ctor deps are diff --git a/packages/agent-core/src/services/terminal/terminal.ts b/packages/agent-core/src/services/terminal/terminal.ts index 938110471..81dd701e3 100644 --- a/packages/agent-core/src/services/terminal/terminal.ts +++ b/packages/agent-core/src/services/terminal/terminal.ts @@ -1,6 +1,6 @@ -import { createDecorator } from '../../di'; -import type { IDisposable } from '../../di'; -import type { Event } from '../../base/common/event'; +import { createDecorator } from '../../_base/di'; +import type { IDisposable } from '../../_base/di'; +import type { Event } from '../../_base/event'; import type { CreateTerminalRequest, Terminal, diff --git a/packages/agent-core/src/services/terminal/terminalService.ts b/packages/agent-core/src/services/terminal/terminalService.ts index def013355..dc0759939 100644 --- a/packages/agent-core/src/services/terminal/terminalService.ts +++ b/packages/agent-core/src/services/terminal/terminalService.ts @@ -1,8 +1,8 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; -import { Disposable, registerSingleton, SyncDescriptor } from '../../di'; -import type { IDisposable } from '../../di'; +import { Disposable, registerSingleton, SyncDescriptor } from '../../_base/di'; +import type { IDisposable } from '../../_base/di'; import type { CreateTerminalRequest, Terminal, @@ -12,7 +12,7 @@ import type { import { ulid } from 'ulid'; import { resolveSafePath } from '../fs/fsPathSafety'; -import { ISessionService } from '../session/session'; +import { ISessionService } from '#/session'; import { disposeAll, ITerminalService, diff --git a/packages/agent-core/src/services/tool/tool.ts b/packages/agent-core/src/services/tool/tool.ts index 7847c0e6a..caa345d5d 100644 --- a/packages/agent-core/src/services/tool/tool.ts +++ b/packages/agent-core/src/services/tool/tool.ts @@ -1,7 +1,7 @@ /** * `IToolService` — daemon-facing read-only tool surface. * - * Wraps `ICoreProcessService.rpc.getTools` and translates agent-core's `ToolInfo` + * Wraps `ICoreRuntime.rpc.getTools` and translates agent-core's `ToolInfo` * (camelCase, includes `'user'` source literal) into SCHEMAS §8 `ToolDescriptor` * (snake_case, `'skill'` literal). Adapter helpers (`toProtocolTool`, * `AgentCoreToolInfoLike`) are co-located here. @@ -18,7 +18,7 @@ * `createDecorator` value. */ -import { createDecorator } from '../../di'; +import { createDecorator } from '../../_base/di'; import type { ToolDescriptor, ToolSource } from '@moonshot-ai/protocol'; // --------------------------------------------------------------------------- diff --git a/packages/agent-core/src/services/tool/toolService.ts b/packages/agent-core/src/services/tool/toolService.ts index dbfa4cb8d..ed2eac7a5 100644 --- a/packages/agent-core/src/services/tool/toolService.ts +++ b/packages/agent-core/src/services/tool/toolService.ts @@ -2,18 +2,29 @@ * `ToolService` — implementation of `IToolService`. */ -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; +import type { CoreRPC } from '../../rpc'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; +import { ICoreRuntime } from '#/coreProcess'; import { IToolService, toProtocolTool, type AgentCoreToolInfoLike } from './tool'; /** Matches the convention used elsewhere in services (message-service uses 'main'). */ const MAIN_AGENT_ID = 'main'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + export class ToolService extends Disposable implements IToolService { readonly _serviceBrand: undefined; - constructor(@ICoreProcessService private readonly core: ICoreProcessService) { + constructor(@ICoreRuntime private readonly core: ICoreRuntime) { super(); } @@ -22,7 +33,7 @@ export class ToolService extends Disposable implements IToolService { if (resolvedSid === undefined) return []; let raw: readonly unknown[]; try { - raw = await this.core.rpc.getTools({ + raw = await this.coreApi().getTools({ sessionId: resolvedSid, agentId: MAIN_AGENT_ID, }); @@ -39,11 +50,23 @@ export class ToolService extends Disposable implements IToolService { * most recently created session id, or `undefined` when no sessions exist. */ private async _anyKnownSessionId(): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); if (all.length === 0) return undefined; const sorted = [...all].sort((a, b) => b.createdAt - a.createdAt); return sorted[0]?.id; } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site above reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } } // Self-register under the global singleton registry. All ctor deps are diff --git a/packages/agent-core/src/services/workspace/index.ts b/packages/agent-core/src/services/workspace/index.ts index 0da7cfa79..02f99c9fa 100644 --- a/packages/agent-core/src/services/workspace/index.ts +++ b/packages/agent-core/src/services/workspace/index.ts @@ -13,3 +13,5 @@ export { RECENT_ROOTS_LIMIT, } from './workspaceFs'; export { WorkspaceFsService } from './workspaceFsService'; +export { IWorkspaceService } from './workspace'; +export { WorkspaceService } from './workspaceService'; diff --git a/packages/agent-core/src/services/workspace/workspace.ts b/packages/agent-core/src/services/workspace/workspace.ts new file mode 100644 index 000000000..2df157501 --- /dev/null +++ b/packages/agent-core/src/services/workspace/workspace.ts @@ -0,0 +1,73 @@ +import { createDecorator } from '../../_base/di'; + +import type { + FsBrowseResponse, + FsHomeResponse, + Workspace, +} from '@moonshot-ai/protocol'; + +import type { WorkspacePatch } from './workspaceRegistry'; + +/** + * Unified facade over the workspace domain. + * + * `IWorkspaceService` is the single entry point that absorbs the workspace + * **registry** + **root resolution** + **recent** + **browse** surfaces that + * were previously split across {@link IWorkspaceRegistry} and + * {@link IWorkspaceFsService}. It is a pure facade: every method delegates to + * one of those underlying services — no workspace logic is duplicated here. + * + * The legacy {@link IWorkspaceRegistry} and {@link IWorkspaceFsService} + * contracts (and their implementations) remain in place for existing + * consumers; consolidating / removing those call sites is a later step. New + * code SHOULD depend on `IWorkspaceService` instead. + */ +export interface IWorkspaceService { + readonly _serviceBrand: undefined; + + // ── registry (mirrors IWorkspaceRegistry) ────────────────────────────── + + /** List every registered workspace (recency-ordered, newest first). */ + list(): Promise; + + /** Look up a single workspace by id. Throws if unknown. */ + get(workspaceId: string): Promise; + + /** Register `root` (idempotent — touching updates `last_opened_at`). */ + createOrTouch(root: string, name?: string): Promise; + + /** Patch mutable fields (currently the display `name`). */ + update(workspaceId: string, patch: WorkspacePatch): Promise; + + /** Unregister a workspace (does not remove on-disk content). */ + delete(workspaceId: string): Promise; + + // ── root resolution ──────────────────────────────────────────────────── + + /** Resolve a `workspace_id` to its absolute working directory (root). */ + resolveRoot(workspaceId: string): Promise; + + // ── recent ───────────────────────────────────────────────────────────── + + /** + * Recent workspaces, ordered by `last_opened_at` descending and capped at + * `RECENT_ROOTS_LIMIT`. + * + * This is a derived view over the registry's existing recency ordering — + * there is NO separate recents persistence. The same source backs + * `IWorkspaceFsService.home().recent_roots` (which exposes the roots of + * this exact set). + */ + listRecent(): Promise; + + // ── browse (mirrors IWorkspaceFsService) ─────────────────────────────── + + /** Browse a directory (defaults to the user's home when `absPath` omitted). */ + browse(absPath?: string): Promise; + + /** Home directory + recent roots (derived from the registry). */ + home(): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IWorkspaceService = createDecorator('workspaceService'); diff --git a/packages/agent-core/src/services/workspace/workspaceFs.ts b/packages/agent-core/src/services/workspace/workspaceFs.ts index d397f064e..d428c7151 100644 --- a/packages/agent-core/src/services/workspace/workspaceFs.ts +++ b/packages/agent-core/src/services/workspace/workspaceFs.ts @@ -1,6 +1,6 @@ -import { createDecorator, Disposable } from '../../di'; +import { createDecorator, Disposable } from '../../_base/di'; import type { FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/workspace/workspaceFsService.ts b/packages/agent-core/src/services/workspace/workspaceFsService.ts index e12d6b9df..a424abbc1 100644 --- a/packages/agent-core/src/services/workspace/workspaceFsService.ts +++ b/packages/agent-core/src/services/workspace/workspaceFsService.ts @@ -4,7 +4,7 @@ import { promises as fsp } from 'node:fs'; import os from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import type { FsBrowseEntry, FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/workspace/workspaceRegistry.ts b/packages/agent-core/src/services/workspace/workspaceRegistry.ts index c48a7fa9e..650b356c4 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistry.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistry.ts @@ -1,6 +1,6 @@ -import { Disposable, createDecorator } from '../../di'; +import { Disposable, createDecorator } from '../../_base/di'; import type { Workspace } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index 26d26aebe..a295a3f49 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -5,10 +5,10 @@ import os from 'node:os'; import { basename, dirname, join } from 'node:path'; import type { Stats } from 'node:fs'; -import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; import { encodeWorkDirKey } from '../../session/store'; import { IEnvironmentService } from '../environment/environment'; -import { IEventService } from '../event/event'; +import { IEventService } from '#/event'; import type { Workspace } from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/src/services/workspace/workspaceService.ts b/packages/agent-core/src/services/workspace/workspaceService.ts new file mode 100644 index 000000000..8e6cf4528 --- /dev/null +++ b/packages/agent-core/src/services/workspace/workspaceService.ts @@ -0,0 +1,74 @@ +import { Disposable, InstantiationType, registerSingleton } from '../../_base/di'; + +import type { + FsBrowseResponse, + FsHomeResponse, + Workspace, +} from '@moonshot-ai/protocol'; + +import { IWorkspaceService } from './workspace'; +import { IWorkspaceFsService, RECENT_ROOTS_LIMIT } from './workspaceFs'; +import { IWorkspaceRegistry, type WorkspacePatch } from './workspaceRegistry'; + +/** + * Unified workspace facade — see {@link IWorkspaceService}. + * + * Delegates every method to the injected {@link IWorkspaceRegistry} (registry + * + root resolution + recent) and {@link IWorkspaceFsService} (browse + home). + * Holds no workspace state of its own and performs no filesystem access beyond + * what the delegated services already do. + */ +export class WorkspaceService extends Disposable implements IWorkspaceService { + readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceRegistry private readonly registry: IWorkspaceRegistry, + @IWorkspaceFsService private readonly fs: IWorkspaceFsService, + ) { + super(); + } + + list(): Promise { + return this.registry.list(); + } + + get(workspaceId: string): Promise { + return this.registry.get(workspaceId); + } + + createOrTouch(root: string, name?: string): Promise { + return this.registry.createOrTouch(root, name); + } + + update(workspaceId: string, patch: WorkspacePatch): Promise { + return this.registry.update(workspaceId, patch); + } + + delete(workspaceId: string): Promise { + return this.registry.delete(workspaceId); + } + + resolveRoot(workspaceId: string): Promise { + return this.registry.resolveRoot(workspaceId); + } + + async listRecent(): Promise { + const all = await this.registry.list(); + return all.slice(0, RECENT_ROOTS_LIMIT); + } + + browse(absPath?: string): Promise { + return this.fs.browse(absPath); + } + + home(): Promise { + return this.fs.home(); + } + + override dispose(): void { + if (this._store.isDisposed) return; + super.dispose(); + } +} + +registerSingleton(IWorkspaceService, WorkspaceService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/session/export/index.ts b/packages/agent-core/src/session/export/index.ts index baaa03182..fceb54b30 100644 --- a/packages/agent-core/src/session/export/index.ts +++ b/packages/agent-core/src/session/export/index.ts @@ -1,4 +1,4 @@ -export * from '#/session/export/manifest'; -export * from '#/session/export/session-export'; -export * from '#/session/export/wire-scan'; -export * from '#/session/export/zip'; +export * from './manifest'; +export * from './session-export'; +export * from './wire-scan'; +export * from './zip'; diff --git a/packages/agent-core/src/session/export/manifest.ts b/packages/agent-core/src/session/export/manifest.ts index 2e1c1809c..6a657ff1d 100644 --- a/packages/agent-core/src/session/export/manifest.ts +++ b/packages/agent-core/src/session/export/manifest.ts @@ -1,5 +1,5 @@ import { AGENT_WIRE_PROTOCOL_VERSION } from '../../agent/records'; -import type { SessionWireScan } from '#/session/export/wire-scan'; +import type { SessionWireScan } from './wire-scan'; import type { ExportSessionManifest, ShellEnvironment, SessionSummary } from '#/rpc/core-api'; export const WIRE_PROTOCOL_VERSION = AGENT_WIRE_PROTOCOL_VERSION; diff --git a/packages/agent-core/src/session/export/session-export.ts b/packages/agent-core/src/session/export/session-export.ts index e4e625722..732887ed9 100644 --- a/packages/agent-core/src/session/export/session-export.ts +++ b/packages/agent-core/src/session/export/session-export.ts @@ -2,14 +2,14 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; -import { resolveGlobalLogPath } from '#/logging/logger'; -import { buildExportManifest } from '#/session/export/manifest'; -import { scanSessionWire } from '#/session/export/wire-scan'; +import { resolveGlobalLogPath } from '#/_base/logging'; +import { buildExportManifest } from './manifest'; +import { scanSessionWire } from './wire-scan'; import { type ExtraZipEntry, collectFilesRecursive, writeExportZip, -} from '#/session/export/zip'; +} from './zip'; import type { ExportSessionPayload, ExportSessionResult, SessionSummary } from '#/rpc/core-api'; const SESSION_LOG_REL = 'logs/kimi-code.log'; diff --git a/packages/agent-core/src/session/hooks/engine.ts b/packages/agent-core/src/session/hooks/engine.ts index 832ecd620..d51274982 100644 --- a/packages/agent-core/src/session/hooks/engine.ts +++ b/packages/agent-core/src/session/hooks/engine.ts @@ -1,3 +1,4 @@ +import { createDecorator } from '../../_base/di'; import { runHook } from './runner'; import type { HookBlockDecision, @@ -181,6 +182,21 @@ function toHookInputData(input: Record): Record { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw engine; do not use in new code. */ + unwrap(): HookEngine; +} + +export const IHookService = createDecorator('hookService'); + +export class HookService extends HookEngine implements IHookService { + readonly _serviceBrand: undefined; + unwrap(): HookEngine { + return this; + } +} + function camelToSnake(value: string): string { return value.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ca8c531a9..e56efd6d0 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -1,46 +1,51 @@ import { homedir } from 'node:os'; -import { join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; import { ErrorCodes, KimiError } from '#/errors'; -import { getRootLogger, log } from '#/logging/logger'; -import type { Logger, SessionLogHandle } from '#/logging/types'; +import { getRootLogger, log } from '#/_base/logging'; +import type { Logger, SessionLogHandle } from '#/_base/logging'; import type { KimiConfig, SDKSessionRPC } from '#/rpc'; -import { proxyWithExtraPayload } from '#/rpc/types'; -import { Agent, type AgentOptions, type AgentType } from '../agent'; -import { HookEngine, type HookDef } from './hooks'; -import type { PermissionManagerOptions, PermissionRule } from '../agent/permission'; -import { parseBooleanEnv, resolveConfigValue, type BackgroundConfig } from '../config'; +import type { Agent, AgentOptions, AgentType } from '../agent'; +import { ILifecycleService, LifecycleService } from '../agent/lifecycle'; +import { HookService, IHookService, type HookDef } from './hooks'; +import { SessionRepository } from './sessionRepository'; +import type { PermissionRule } from '../agent/permission'; +import { type BackgroundConfig } from '../config'; import { makeErrorPayload } from '../errors'; import { - McpConnectionManager, + McpConnectionService, McpOAuthService, + IMcpConnectionService, type McpServerEntry, type SessionMcpConfig, } from '../mcp'; import type { EnabledPluginSessionStart } from '../plugin'; import { - DEFAULT_AGENT_PROFILES, DEFAULT_INIT_PROMPT, loadAgentsMd, - prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; -import type { ProviderManager } from './provider-manager'; +import type { IProviderService } from './provider-manager'; import { registerBuiltinSkills, - SessionSkillRegistry, + SkillRegistryService, resolveSkillRoots, summarizeSkill, + ISkillRegistryService, type SkillRoot, type SkillSummary, } from '../skill'; import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; -import { SessionSubagentHost } from './subagent-host'; +import { SessionHost, type AgentEntry } from './session-host'; import type { ToolServices } from '../tools/support/services'; import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; -import { abortError } from '../utils/abort'; +import { + InstantiationService, + ServiceCollection, + SyncDescriptor, + type IInstantiationService, +} from '../_base/di'; export interface SessionOptions { readonly kaos: Kaos; @@ -52,7 +57,7 @@ export interface SessionOptions { readonly rpc: SDKSessionRPC; readonly toolServices?: ToolServices; readonly initializeMainAgent?: boolean | undefined; - readonly providerManager?: ProviderManager | undefined; + readonly providerManager?: IProviderService | undefined; readonly background?: BackgroundConfig | undefined; readonly hooks?: readonly HookDef[]; readonly permissionRules?: readonly PermissionRule[]; @@ -62,6 +67,7 @@ export interface SessionOptions { readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly appVersion?: string; readonly experimentalFlags?: ExperimentalFlagResolver; + readonly instantiationService?: IInstantiationService | undefined; } export interface SessionSkillConfig { @@ -82,13 +88,6 @@ export interface AgentMeta { readonly swarmItem?: string; } -interface ResumedAgent { - readonly agent: Agent; - readonly warning?: string; -} - -type AgentEntry = Agent | Promise; - export interface CreateAgentOptions { readonly profile?: ResolvedAgentProfile; readonly parentAgentId?: string; @@ -107,48 +106,26 @@ export interface SessionMeta { custom: Record; } -const BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; -const ACTIVE_TURN_CLOSE_TIMEOUT_MS = 8_000; - -async function waitForSettlementOrTimeout( - promise: Promise, - timeoutMs: number, -): Promise { - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - promise.then( - () => true, - () => true, - ), - new Promise((resolve) => { - timeout = setTimeout(() => { - resolve(false); - }, timeoutMs); - timeout.unref?.(); - }), - ]); - } finally { - if (timeout !== undefined) { - clearTimeout(timeout); - } - } -} - export class Session { readonly rpc: SDKSessionRPC; readonly telemetry: TelemetryClient; - readonly skills: SessionSkillRegistry; - readonly agents: Map = new Map(); - readonly mcp: McpConnectionManager; + readonly skills: ISkillRegistryService; + private readonly scope: IInstantiationService; + readonly mcp: IMcpConnectionService; + readonly lifecycle: ILifecycleService; readonly log: Logger; private readonly logHandle: SessionLogHandle | undefined; - readonly hookEngine: HookEngine; + readonly hookEngine: IHookService; readonly experimentalFlags: ExperimentalFlagResolver; - private toolKaos: Kaos; private persistenceKaos: Kaos; - private agentIdCounter = 0; - private readonly skillsReady: Promise; + private readonly sessionRepository: SessionRepository; + private skillsReadyPromise: Promise | undefined; + /** + * Owns the agent registry + agent lifecycle for this session. Exposed so a + * future `KimiCore.sessions` switch (M1.7b) can hold the host directly; for + * now `KimiCore.sessions` still holds `Session` and delegates here. + */ + readonly host: SessionHost; metadata: SessionMeta = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -157,12 +134,12 @@ export class Session { agents: {}, custom: {}, }; - private writeMetadataPromise = Promise.resolve(); constructor(public readonly options: SessionOptions) { - // Attach the per-session log sink up front so the constructor's - // fire-and-forget `loadSkills` / `loadMcpServers` failures (and - // anything else that races) land in the session log, not just global. + // Attach the per-session log sink up front so the fire-and-forget + // `loadSkills` / `loadMcpServers` failures (triggered by + // `onSessionWillStart` or a lazy gate, and anything else that races) + // land in the session log, not just global. this.logHandle = options.id === undefined ? undefined @@ -175,42 +152,99 @@ export class Session { (options.id === undefined ? log : log.createChild({ sessionId: options.id })); this.rpc = options.rpc; this.experimentalFlags = options.experimentalFlags ?? new FlagResolver(); - this.hookEngine = new HookEngine(options.hooks, { - cwd: options.kaos.getcwd(), - sessionId: options.id, - }); + const sessionServices = new ServiceCollection(); + sessionServices.set( + IHookService, + new SyncDescriptor(HookService, [ + options.hooks, + { cwd: options.kaos.getcwd(), sessionId: options.id }, + ]), + ); + sessionServices.set( + ISkillRegistryService, + new SyncDescriptor(SkillRegistryService, [{ sessionId: options.id }]), + ); + sessionServices.set( + IMcpConnectionService, + new SyncDescriptor(McpConnectionService, [ + { + oauthService: new McpOAuthService({ kimiHomeDir: options.kimiHomeDir }), + log: this.log, + }, + ]), + ); + sessionServices.set(ILifecycleService, new SyncDescriptor(LifecycleService, [])); + this.scope = (options.instantiationService ?? new InstantiationService(undefined, true)).createChild( + sessionServices, + ); + this.hookEngine = this.scope.invokeFunction((accessor) => accessor.get(IHookService)); this.telemetry = options.telemetry ?? noopTelemetryClient; - this.toolKaos = options.kaos; this.persistenceKaos = options.persistenceKaos ?? options.kaos; - this.skills = new SessionSkillRegistry({ - sessionId: options.id, - }); - this.mcp = new McpConnectionManager({ - oauthService: new McpOAuthService({ kimiHomeDir: options.kimiHomeDir }), - log: this.log, - }); + this.sessionRepository = new SessionRepository(options.homedir, this.persistenceKaos); + this.skills = this.scope.invokeFunction((accessor) => accessor.get(ISkillRegistryService)); + this.mcp = this.scope.invokeFunction((accessor) => accessor.get(IMcpConnectionService)); + this.lifecycle = this.scope.invokeFunction((accessor) => accessor.get(ILifecycleService)); this.mcp.onStatusChange((entry) => { this.onMcpServerStatusChange(entry); }); - this.skillsReady = this.loadSkills() + // Lazily trigger the skill + MCP runtime loads on the first MCP + // initial-load gate so sessions that never fire `onSessionWillStart` + // (ephemeral sessions, bare `mcp.waitForInitialLoad()` callers) still + // connect their servers — matching the prior constructor-eager timing. + const waitForInitialLoad = this.mcp.waitForInitialLoad.bind(this.mcp); + this.mcp.waitForInitialLoad = (signal) => { + this.startRuntimeLoads(); + return waitForInitialLoad(signal); + }; + // Trigger the loads on session start (createMain / resume) so skills are + // loaded before `createAgent` renders the main agent's system prompt. The + // handler only starts the promises; `createAgent` still awaits + // `skillsReady`, so the hook order is unchanged (willStart fires before + // createAgent, before didStart). + this.lifecycle.onSessionWillStart(() => { + this.startRuntimeLoads(); + }); + this.host = new SessionHost({ + session: this, + scope: this.scope, + logHandle: this.logHandle, + skillsReady: () => this.skillsReady, + }); + } + + /** + * Starts the session's skill + MCP runtime loads exactly once. Triggered + * eagerly by `onSessionWillStart` (so skills load before `createAgent` + * renders the main agent's system prompt) and lazily by the `skillsReady` / + * `mcp.waitForInitialLoad()` gates for paths that don't fire willStart + * (ephemeral sessions, direct `createAgent`, bare MCP gate callers). + */ + private startRuntimeLoads(): void { + if (this.skillsReadyPromise !== undefined) return; + this.skillsReadyPromise = this.loadSkills() .catch((error: unknown) => { this.log.error('skills load failed', error); }) .then(() => { - this.refreshAgentBuiltinTools(); + this.host.refreshAgentBuiltinTools(); }); void this.loadMcpServers().catch((error: unknown) => { this.emitInitialMcpLoadError(error); }); } + private get skillsReady(): Promise { + this.startRuntimeLoads(); + return this.skillsReadyPromise!; + } - setToolKaos(kaos: Kaos) { - this.toolKaos = kaos; - for (const agent of this.readyAgents()) { - agent.setKaos(kaos.withCwd(agent.config.cwd)); - } - this.refreshAgentBuiltinTools(); + + get agents(): Map { + return this.host.agents; + } + + setToolKaos(kaos: Kaos): void { + this.host.setToolKaos(kaos); } /** @@ -224,196 +258,50 @@ export class Session { return this.persistenceKaos.withCwd(cwd); } - async createMain() { - const { agent } = await this.createAgent({ type: 'main' }, { - profile: DEFAULT_AGENT_PROFILES['agent'], - }); - await this.triggerSessionStart('startup'); - return agent; - } - - async resume(): Promise<{ warning?: string }> { - await this.skillsReady; - this.log.info('session resume', { app_version: this.options.appVersion }); - const { agents } = await this.readMetadata(); - this.agents.clear(); - // Only the main agent is needed to reopen the session; subagents replay - // lazily when an RPC or Agent(resume=...) call asks for their state. - const { warning } = - agents['main'] === undefined ? { warning: undefined } : await this.resumeAgent('main'); - // A session migrated from an external tool ships a wire without the - // `config.update` bootstrap events a natively-created agent writes, so the - // main agent comes back with an empty system prompt and no tools. Apply the - // default profile so the resumed session is usable. Native sessions always - // replay a non-empty system prompt and never enter this branch. - const main = this.getReadyAgent('main'); - const profile = DEFAULT_AGENT_PROFILES['agent']; - if (main !== undefined && profile !== undefined && main.config.systemPrompt === '') { - await this.bootstrapAgentProfile(main, profile); - } - await this.triggerSessionStart('resume'); - return { warning }; - } - - async close(): Promise { - try { - await Promise.allSettled( - Array.from(this.readyAgents(), async (agent) => agent.cron?.stop()), - ); - await this.cancelActiveTurnsOnClose(); - await this.stopBackgroundTasksOnExit(); - await this.flushMetadata(); - await this.triggerSessionEnd('exit'); - } finally { - try { - await this.mcp.shutdown(); - } finally { - await this.logHandle?.close(); - } - } - } - - async closeForReload(): Promise { - try { - await Promise.allSettled( - Array.from(this.readyAgents(), async (agent) => agent.cron?.stop()), - ); - await this.flushMetadata(); - } finally { - try { - await this.mcp.shutdown(); - } finally { - await this.logHandle?.close(); - } - } - } - - private async cancelActiveTurnsOnClose(): Promise { - const backgroundAgentIds = this.activeBackgroundAgentIds(); - const cancellations: Array> = []; - for (const [agentId, entry] of this.agents) { - if (!(entry instanceof Agent) || backgroundAgentIds.has(agentId)) continue; - cancellations.push(this.cancelAgentTurnOnClose(entry)); - } - await Promise.allSettled(cancellations); + /** + * Agent registry + lifecycle are owned by the internal {@link SessionHost}. + * The methods below preserve `Session`'s public surface and forward to the + * host so all call sites (`KimiCore`, `SessionAPIImpl`, subagent host, tests) + * keep behaving identically. + */ + createMain(): Promise { + return this.host.createMain(); } - private activeBackgroundAgentIds(): Set { - const agentIds = new Set(); - for (const agent of this.readyAgents()) { - for (const task of agent.background.list(true)) { - if (task.kind === 'agent' && task.agentId !== undefined) { - agentIds.add(task.agentId); - } - } - } - return agentIds; + resume(): Promise<{ warning?: string }> { + return this.host.resume(); } - private async cancelAgentTurnOnClose(agent: Agent): Promise { - if (!agent.turn.hasActiveTurn) return; - - let waitForTurn: Promise; - try { - waitForTurn = agent.turn.waitForCurrentTurn(); - } catch (error: unknown) { - this.log.debug('active turn wait unavailable during session close', { - agentType: agent.type, - agentHomedir: agent.homedir, - error, - }); - return; - } - - agent.turn.cancel(undefined, abortError('Session closed')); - const settled = await waitForSettlementOrTimeout(waitForTurn, ACTIVE_TURN_CLOSE_TIMEOUT_MS); - if (!settled) { - this.log.warn('timed out waiting for active turn to cancel during session close', { - agentType: agent.type, - agentHomedir: agent.homedir, - timeoutMs: ACTIVE_TURN_CLOSE_TIMEOUT_MS, - }); - } + close(): Promise { + return this.host.close(); } - private async stopBackgroundTasksOnExit(): Promise { - const keepAliveOnExit = resolveConfigValue({ - env: process.env, - envKey: BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV, - configValue: this.options.background?.keepAliveOnExit, - defaultValue: false, - parseEnv: parseBooleanEnv, - }); - if (keepAliveOnExit) return; - await Promise.all( - Array.from(this.readyAgents(), async (agent) => { - const activeTasks = agent.background.list(true); - await Promise.all( - activeTasks.map((task) => - agent.background.suppressTerminalNotification(task.taskId), - ), - ); - await agent.background.stopAll('Session closed'); - }), - ); + closeForReload(): Promise { + return this.host.closeForReload(); } - async createAgent( + createAgent( config: Partial, options: CreateAgentOptions = {}, ): Promise<{ readonly id: string; readonly agent: Agent }> { - await this.skillsReady; - const type = config.type ?? 'main'; - const id = type === 'main' ? 'main' : this.nextGeneratedAgentId(); - const homedir = config.homedir ?? join(this.options.homedir, 'agents', id); - const parentAgentId = options.parentAgentId ?? null; - const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId); - if (options.profile) { - await this.bootstrapAgentProfile(agent, options.profile); - } - - this.agents.set(id, agent); - if (options.persistMetadata !== false) { - this.metadata.agents[id] = { - homedir, - type, - parentAgentId, - swarmItem: options.swarmItem, - }; - void this.writeMetadata(); - } + return this.host.createAgent(config, options); + } - return { id, agent }; + ensureAgentResumed(id: string): Promise { + return this.host.ensureAgentResumed(id); } - async ensureAgentResumed(id: string): Promise { - const entry = this.agents.get(id); - if (entry !== undefined) return (await this.resolveAgentEntry(entry)).agent; - if (this.metadata.agents[id] === undefined) { - throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, `Agent "${id}" was not found`); - } - return (await this.resumeAgent(id)).agent; + getReadyAgent(id: string): Agent | undefined { + return this.host.getReadyAgent(id); } - /** - * Applies a profile's derived config — cwd, system prompt, active tools — to - * an agent. Fresh creation and resume-of-an-incomplete-wire both route - * through here so the two paths cannot drift apart. - */ - private async bootstrapAgentProfile( - agent: Agent, - profile: ResolvedAgentProfile, - ): Promise { - const context = await prepareSystemPromptContext( - this.systemContextKaos(agent.kaos.getcwd()), - this.options.kimiHomeDir, - ); - agent.useProfile(profile, context); + readyAgents(): Iterable { + return this.host.readyAgents(); } async generateAgentsMd(): Promise { await this.skillsReady; - const mainAgent = this.requireMainAgent(); + const mainAgent = this.host.requireMainAgent(); try { const handle = await mainAgent.subagentHost!.spawn({ @@ -448,29 +336,18 @@ export class Session { return false; } - protected get metadataPath() { - return join(this.options.homedir, 'state.json'); - } - writeMetadata() { - const text = JSON.stringify(this.metadata, null, 2); - const write = async () => { - await this.persistenceKaos.mkdir(this.options.homedir, { parents: true, existOk: true }); - await this.persistenceKaos.writeText(this.metadataPath, text); - }; - this.writeMetadataPromise = this.writeMetadataPromise.then(write, write); - return this.writeMetadataPromise; + return this.sessionRepository.write(this.metadata); } async readMetadata() { - const text = await this.persistenceKaos.readText(this.metadataPath); - this.metadata = JSON.parse(text); + this.metadata = await this.sessionRepository.read(); return this.metadata; } async flushMetadata() { await this.skillsReady; - await this.writeMetadataPromise; + await this.sessionRepository.flush(); await Promise.all(Array.from(this.readyAgents()).map((agent) => agent.records.flush())); } @@ -546,159 +423,35 @@ export class Session { }, }); } - - private refreshAgentBuiltinTools(): void { - for (const agent of this.readyAgents()) { - if (!agent.config.hasProvider) continue; - agent.tools.initializeBuiltinTools(); - } - } - - private instantiateAgent( - id: string, - homedir: string, - type: AgentType, - config: Partial = {}, - parentAgentId: string | null = null, - ): Agent { - const parentAgent = parentAgentId !== null ? this.getReadyAgent(parentAgentId) : undefined; - const cwd = parentAgent?.config.cwd ?? this.toolKaos.getcwd(); - return new Agent({ - ...config, - type, - kaos: this.toolKaos.withCwd(cwd), - toolServices: this.options.toolServices, - config: this.options.config, - homedir, - skills: this.skills, - rpc: proxyWithExtraPayload(this.rpc, { agentId: id }), - modelProvider: this.options.providerManager, - hookEngine: config.hookEngine ?? this.hookEngine, - subagentHost: config.subagentHost ?? new SessionSubagentHost(this, id), - mcp: this.mcp, - permission: this.permissionOptions(parentAgentId, config.permission), - telemetry: this.telemetry, - log: this.log.createChild({ agentId: id }), - pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined, - experimentalFlags: this.experimentalFlags, - }); - } - - private permissionOptions( - parentAgentId: string | null, - input?: PermissionManagerOptions | undefined, - ): PermissionManagerOptions { - if (parentAgentId === null) { - return { - ...input, - initialRules: input?.initialRules ?? this.options.permissionRules, - }; - } - return { - ...input, - parent: input?.parent ?? this.getReadyAgent(parentAgentId)?.permission, - }; - } - - getReadyAgent(id: string): Agent | undefined { - const entry = this.agents.get(id); - return entry instanceof Agent ? entry : undefined; - } - - *readyAgents(): Iterable { - for (const entry of this.agents.values()) { - if (entry instanceof Agent) yield entry; - } - } - - private async resolveAgentEntry(entry: AgentEntry): Promise { - if (entry instanceof Agent) return { agent: entry }; - return entry; - } - - private resumeAgent( - id: string, - stack: readonly string[] = [], - ): Promise { - if (stack.includes(id)) { - throw new KimiError( - ErrorCodes.SESSION_STATE_INVALID, - `Session agent parent chain contains a cycle: ${[...stack, id].join(' -> ')}`, - ); - } - - const entry = this.agents.get(id); - if (entry !== undefined) return this.resolveAgentEntry(entry); - - const promise = this.resumePersistedAgent(id, stack); - this.agents.set(id, promise); - return promise; - } - - private async resumePersistedAgent( - id: string, - stack: readonly string[] = [], - ): Promise { - await this.skillsReady; - const meta = this.metadata.agents[id]; - if (meta === undefined) { - throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, `Session agent "${id}" is missing`); - } - - const parentAgentId = meta.parentAgentId ?? null; - const parent = - parentAgentId === null - ? undefined - : await this.resumeAgent(parentAgentId, [...stack, id]); - - try { - const agent = this.instantiateAgent(id, meta.homedir, meta.type, {}, parentAgentId); - const result = await agent.resume(); - this.agents.set(id, agent); - return { agent, warning: parent?.warning ?? result.warning }; - } catch (error) { - const entry = this.agents.get(id); - if (entry instanceof Promise) { - this.agents.delete(id); - } - throw error; - } - } - - private nextGeneratedAgentId(): string { - while (true) { - const id = `agent-${this.agentIdCounter++}`; - if (this.agents.has(id)) continue; - if (this.metadata.agents[id] !== undefined) continue; - return id; - } - } - - private requireMainAgent(): Agent { - const agent = this.getReadyAgent('main'); - if (agent === undefined) { - throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); - } - return agent; - } - - private async triggerSessionStart(source: 'startup' | 'resume'): Promise { - await this.hookEngine.trigger('SessionStart', { - matcherValue: source, - inputData: { source }, - }); - } - - private async triggerSessionEnd(reason: 'exit'): Promise { - await this.hookEngine.trigger('SessionEnd', { - matcherValue: reason, - inputData: { reason }, - }); - } } export * from './subagent-host'; +// ─── Session service domain (migrated from the legacy services session dir) ─ +// Contract (decorators + types + errors + translator) and the three DI +// singleton implementations plus the read-model index. Re-exporting the impl +// modules here triggers their top-level `registerSingleton(...)` side effects, +// keeping the DI registry populated (previously done via `services/index.ts`). +export { + ISessionService, + ISessionQueryService, + ISessionRuntimeService, + SessionNotFoundError, + SessionUndoUnavailableError, + toProtocolSession, +} from './session'; +export type { + SessionClientTelemetry, + SessionCreateOptions, + SessionIndexListOpts, + SessionListQuery, + SessionQueryScope, +} from './session'; +export { SessionIndex } from './sessionIndex'; +export { SessionService } from './sessionService'; +export { SessionQueryService } from './sessionQueryService'; +export { SessionRuntimeService } from './sessionRuntimeService'; + function initCompletionReminder(agentsMd: string): string { const latest = agentsMd.trim().length === 0 diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 34dc82091..5ac12f42c 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,7 +1,8 @@ -import type { Logger } from '#/logging/types'; +import type { Logger } from '#/_base/logging'; import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '../config'; +import { createDecorator } from '../_base/di'; import { ErrorCodes, isKimiError, KimiError } from '../errors'; export interface BearerTokenProvider { @@ -341,6 +342,21 @@ function vertexAILocation(provider: ProviderConfig): string | undefined { ); } +export interface IProviderService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw manager; do not use in new code. */ + unwrap(): ProviderManager; +} + +export const IProviderService = createDecorator('providerService'); + +export class ProviderService extends ProviderManager implements IProviderService { + readonly _serviceBrand: undefined; + unwrap(): ProviderManager { + return this; + } +} + function providerValue( configured: string | undefined, env: Record | undefined, diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index fe81014dc..725ab2751 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -28,7 +28,7 @@ import type { UnregisterToolPayload, UpdateSessionMetadataPayload, } from '#/rpc'; -import type { PromisableMethods } from '#/utils/types'; +import type { PromisableMethods } from '#/_utils/types'; import type { Session, SessionMeta } from '.'; import { diff --git a/packages/agent-core/src/session/session-host.ts b/packages/agent-core/src/session/session-host.ts new file mode 100644 index 000000000..16bc5b833 --- /dev/null +++ b/packages/agent-core/src/session/session-host.ts @@ -0,0 +1,505 @@ +import { join } from 'pathe'; +import type { Kaos } from '@moonshot-ai/kaos'; + +import { ErrorCodes, KimiError } from '#/errors'; +import type { SessionLogHandle } from '#/_base/logging'; +import { proxyWithExtraPayload } from '#/rpc/types'; + +import { Agent, type AgentOptions, type AgentType } from '../agent'; +import type { SessionHookCtx } from '../agent/lifecycle'; +import type { PermissionManagerOptions } from '../agent/permission'; +import { parseBooleanEnv, resolveConfigValue } from '../config'; +import type { IInstantiationService } from '../_base/di'; +import { + DEFAULT_AGENT_PROFILES, + prepareSystemPromptContext, + type ResolvedAgentProfile, +} from '../profile'; +import { abortError } from '#/_utils/abort'; + +import type { CreateAgentOptions, Session } from './index'; +import { SubagentHostService } from './subagent-host'; + +const BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; +const ACTIVE_TURN_CLOSE_TIMEOUT_MS = 8_000; + +async function waitForSettlementOrTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(false); + }, timeoutMs); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +export interface ResumedAgent { + readonly agent: Agent; + readonly warning?: string; +} + +export type AgentEntry = Agent | Promise; + +/** + * Dependencies a {@link SessionHost} needs from its owning {@link Session}. + * + * The host owns the agent registry + agent lifecycle but still reaches back to + * the session for session-level concerns (metadata persistence, MCP shutdown, + * the session log sink, the DI scope used to construct per-agent services, and + * the `skillsReady` gate). Keeping these as explicit deps (rather than + * duplicating them) is what lets this extraction stay behavior-identical while + * `KimiCore.sessions` continues to hold `Session` (the switch to + * `Map` is deferred to M1.7b). + */ +export interface SessionHostDeps { + readonly session: Session; + readonly scope: IInstantiationService; + readonly logHandle: SessionLogHandle | undefined; + readonly skillsReady: () => Promise; +} + +/** + * Owns a session's agent registry and the lifecycle of the agents within it + * (create / resume / close / closeForReload). {@link Session} keeps its + * identity (id, options, lifecycle service, MCP, persistence, metadata) and + * delegates agent management to an internal `SessionHost` instance. + */ +export class SessionHost { + readonly agents = new Map(); + private agentIdCounter = 0; + private toolKaos: Kaos; + private cronsStopped = false; + + constructor(private readonly deps: SessionHostDeps) { + this.toolKaos = deps.session.options.kaos; + // Stop each agent's cron scheduler when the session closes. Routed through + // the session-scoped `onSessionWillClose` hook so both `close()` and + // `closeForReload()` share one code path (cron stop is reload-safe, unlike + // background-task teardown). An idempotent direct-call fallback below + // covers ephemeral sessions, whose hooks `fireSessionHook` skips. + this.deps.session.lifecycle.onSessionWillClose(() => this.stopCronsOnce()); + } + + /** + * Back-ref to the owning {@link Session}. Exposed so that `KimiCore.sessions` + * (which now holds `SessionHost`) can reach session-owned concerns + * (metadata, log, MCP, lifecycle services) without maintaining a parallel + * `Map`. + */ + get session(): Session { + return this.deps.session; + } + + setToolKaos(kaos: Kaos): void { + this.toolKaos = kaos; + for (const agent of this.readyAgents()) { + agent.setKaos(kaos.withCwd(agent.config.cwd)); + } + this.refreshAgentBuiltinTools(); + } + + /** + * Fires a session-scoped lifecycle hook, but only when the owning session + * has a stable id. Ephemeral sessions (`options.id === undefined`) have no + * id to identify them in the hook ctx, so their session hooks are skipped. + */ + private async fireSessionHook( + fire: (ctx: SessionHookCtx) => Promise, + ): Promise { + const sessionId = this.session.options.id; + if (sessionId === undefined) return; + await fire({ sessionId }); + } + + async createMain(): Promise { + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionWillStart(ctx)); + const { agent } = await this.createAgent({ type: 'main' }, { + profile: DEFAULT_AGENT_PROFILES['agent'], + }); + await this.triggerSessionStart('startup'); + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionDidStart(ctx)); + return agent; + } + + async resume(): Promise<{ warning?: string }> { + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionWillStart(ctx)); + await this.deps.skillsReady(); + this.session.log.info('session resume', { app_version: this.session.options.appVersion }); + const { agents } = await this.session.readMetadata(); + this.agents.clear(); + // Only the main agent is needed to reopen the session; subagents replay + // lazily when an RPC or Agent(resume=...) call asks for their state. + const { warning } = + agents['main'] === undefined ? { warning: undefined } : await this.resumeAgent('main'); + // A session migrated from an external tool ships a wire without the + // `config.update` bootstrap events a natively-created agent writes, so the + // main agent comes back with an empty system prompt and no tools. Apply the + // default profile so the resumed session is usable. Native sessions always + // replay a non-empty system prompt and never enter this branch. + const main = this.getReadyAgent('main'); + const profile = DEFAULT_AGENT_PROFILES['agent']; + if (main !== undefined && profile !== undefined && main.config.systemPrompt === '') { + await this.bootstrapAgentProfile(main, profile); + } + await this.triggerSessionStart('resume'); + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionDidStart(ctx)); + return { warning }; + } + + async close(): Promise { + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionWillClose(ctx)); + try { + await Promise.allSettled( + Array.from(this.readyAgents(), async (agent) => { + await agent.dispose(); + }), + ); + // Idempotent fallback for ephemeral sessions, whose `onSessionWillClose` + // hook `fireSessionHook` skipped. For id'd sessions the hook already + // stopped crons, so this is a no-op. + await this.stopCronsOnce(); + await this.cancelActiveTurnsOnClose(); + await this.stopBackgroundTasksOnExit(); + await this.session.flushMetadata(); + await this.triggerSessionEnd('exit'); + } finally { + try { + await this.session.mcp.shutdown(); + } finally { + await this.deps.logHandle?.close(); + } + } + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionDidClose(ctx)); + } + + async closeForReload(): Promise { + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionWillClose(ctx)); + try { + await Promise.allSettled( + Array.from(this.readyAgents(), async (agent) => { + await agent.dispose(); + }), + ); + // Idempotent fallback for ephemeral sessions (see `close()`). Cron stop + // is reload-safe; background-task teardown intentionally stays out of + // this path so reload keeps background tasks alive. + await this.stopCronsOnce(); + await this.session.flushMetadata(); + } finally { + try { + await this.session.mcp.shutdown(); + } finally { + await this.deps.logHandle?.close(); + } + } + await this.fireSessionHook((ctx) => this.session.lifecycle.fireSessionDidClose(ctx)); + } + + private async cancelActiveTurnsOnClose(): Promise { + const backgroundAgentIds = this.activeBackgroundAgentIds(); + const cancellations: Array> = []; + for (const [agentId, entry] of this.agents) { + if (!(entry instanceof Agent) || backgroundAgentIds.has(agentId)) continue; + cancellations.push(this.cancelAgentTurnOnClose(entry)); + } + await Promise.allSettled(cancellations); + } + + private activeBackgroundAgentIds(): Set { + const agentIds = new Set(); + for (const agent of this.readyAgents()) { + for (const task of agent.background.list(true)) { + if (task.kind === 'agent' && task.agentId !== undefined) { + agentIds.add(task.agentId); + } + } + } + return agentIds; + } + + private async cancelAgentTurnOnClose(agent: Agent): Promise { + if (!agent.turn.hasActiveTurn) return; + + let waitForTurn: Promise; + try { + waitForTurn = agent.turn.waitForCurrentTurn(); + } catch (error: unknown) { + this.session.log.debug('active turn wait unavailable during session close', { + agentType: agent.type, + agentHomedir: agent.homedir, + error, + }); + return; + } + + agent.turn.cancel(undefined, abortError('Session closed')); + const settled = await waitForSettlementOrTimeout(waitForTurn, ACTIVE_TURN_CLOSE_TIMEOUT_MS); + if (!settled) { + this.session.log.warn('timed out waiting for active turn to cancel during session close', { + agentType: agent.type, + agentHomedir: agent.homedir, + timeoutMs: ACTIVE_TURN_CLOSE_TIMEOUT_MS, + }); + } + } + + private async stopCronsOnce(): Promise { + if (this.cronsStopped) return; + this.cronsStopped = true; + await Promise.allSettled( + Array.from(this.readyAgents(), async (agent) => { + await agent.cron?.stop(); + }), + ); + } + + private async stopBackgroundTasksOnExit(): Promise { + const keepAliveOnExit = resolveConfigValue({ + env: process.env, + envKey: BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV, + configValue: this.session.options.background?.keepAliveOnExit, + defaultValue: false, + parseEnv: parseBooleanEnv, + }); + if (keepAliveOnExit) return; + await Promise.all( + Array.from(this.readyAgents(), async (agent) => { + const activeTasks = agent.background.list(true); + await Promise.all( + activeTasks.map((task) => + agent.background.suppressTerminalNotification(task.taskId), + ), + ); + await agent.background.stopAll('Session closed'); + }), + ); + } + + async createAgent( + config: Partial, + options: CreateAgentOptions = {}, + ): Promise<{ readonly id: string; readonly agent: Agent }> { + await this.deps.skillsReady(); + const type = config.type ?? 'main'; + const id = type === 'main' ? 'main' : this.nextGeneratedAgentId(); + const homedir = config.homedir ?? join(this.session.options.homedir, 'agents', id); + const parentAgentId = options.parentAgentId ?? null; + const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId); + if (options.profile) { + await this.bootstrapAgentProfile(agent, options.profile); + } + + this.agents.set(id, agent); + if (options.persistMetadata !== false) { + this.session.metadata.agents[id] = { + homedir, + type, + parentAgentId, + swarmItem: options.swarmItem, + }; + void this.session.writeMetadata(); + } + + return { id, agent }; + } + + async ensureAgentResumed(id: string): Promise { + const entry = this.agents.get(id); + if (entry !== undefined) return (await this.resolveAgentEntry(entry)).agent; + if (this.session.metadata.agents[id] === undefined) { + throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, `Agent "${id}" was not found`); + } + return (await this.resumeAgent(id)).agent; + } + + /** + * Applies a profile's derived config — cwd, system prompt, active tools — to + * an agent. Fresh creation and resume-of-an-incomplete-wire both route + * through here so the two paths cannot drift apart. + */ + private async bootstrapAgentProfile( + agent: Agent, + profile: ResolvedAgentProfile, + ): Promise { + const context = await prepareSystemPromptContext( + this.session.systemContextKaos(agent.kaos.getcwd()), + this.session.options.kimiHomeDir, + ); + agent.useProfile(profile, context); + } + + get hasActiveTurn(): boolean { + for (const agent of this.readyAgents()) { + if (agent.turn.hasActiveTurn) return true; + } + return false; + } + + refreshAgentBuiltinTools(): void { + for (const agent of this.readyAgents()) { + if (!agent.config.hasProvider) continue; + agent.tools.initializeBuiltinTools(); + } + } + + private instantiateAgent( + id: string, + homedir: string, + type: AgentType, + config: Partial = {}, + parentAgentId: string | null = null, + ): Agent { + const parentAgent = parentAgentId !== null ? this.getReadyAgent(parentAgentId) : undefined; + const cwd = parentAgent?.config.cwd ?? this.toolKaos.getcwd(); + return new Agent({ + ...config, + id, + type, + kaos: this.toolKaos.withCwd(cwd), + toolServices: this.session.options.toolServices, + config: this.session.options.config, + homedir, + skills: this.session.skills, + rpc: proxyWithExtraPayload(this.session.rpc, { agentId: id }), + modelProvider: this.session.options.providerManager, + hookEngine: config.hookEngine ?? this.session.hookEngine, + subagentHost: + config.subagentHost ?? + this.deps.scope.createInstance(SubagentHostService, this.session, id), + mcp: this.session.mcp, + permission: this.permissionOptions(parentAgentId, config.permission), + telemetry: this.session.telemetry, + log: this.session.log.createChild({ agentId: id }), + pluginSessionStarts: type === 'main' ? this.session.options.pluginSessionStarts : undefined, + experimentalFlags: this.session.experimentalFlags, + instantiationService: this.deps.scope, + }); + } + + private permissionOptions( + parentAgentId: string | null, + input?: PermissionManagerOptions | undefined, + ): PermissionManagerOptions { + if (parentAgentId === null) { + return { + ...input, + initialRules: input?.initialRules ?? this.session.options.permissionRules, + }; + } + return { + ...input, + parent: input?.parent ?? this.getReadyAgent(parentAgentId)?.permission?.unwrap(), + }; + } + + getReadyAgent(id: string): Agent | undefined { + const entry = this.agents.get(id); + return entry instanceof Agent ? entry : undefined; + } + + *readyAgents(): Iterable { + for (const entry of this.agents.values()) { + if (entry instanceof Agent) yield entry; + } + } + + private async resolveAgentEntry(entry: AgentEntry): Promise { + if (entry instanceof Agent) return { agent: entry }; + return entry; + } + + private resumeAgent( + id: string, + stack: readonly string[] = [], + ): Promise { + if (stack.includes(id)) { + throw new KimiError( + ErrorCodes.SESSION_STATE_INVALID, + `Session agent parent chain contains a cycle: ${[...stack, id].join(' -> ')}`, + ); + } + + const entry = this.agents.get(id); + if (entry !== undefined) return this.resolveAgentEntry(entry); + + const promise = this.resumePersistedAgent(id, stack); + this.agents.set(id, promise); + return promise; + } + + private async resumePersistedAgent( + id: string, + stack: readonly string[] = [], + ): Promise { + await this.deps.skillsReady(); + const meta = this.session.metadata.agents[id]; + if (meta === undefined) { + throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, `Session agent "${id}" is missing`); + } + + const parentAgentId = meta.parentAgentId ?? null; + const parent = + parentAgentId === null + ? undefined + : await this.resumeAgent(parentAgentId, [...stack, id]); + + try { + const agent = this.instantiateAgent(id, meta.homedir, meta.type, {}, parentAgentId); + const result = await agent.resume(); + this.agents.set(id, agent); + return { agent, warning: parent?.warning ?? result.warning }; + } catch (error) { + const entry = this.agents.get(id); + if (entry instanceof Promise) { + this.agents.delete(id); + } + throw error; + } + } + + private nextGeneratedAgentId(): string { + while (true) { + const id = `agent-${this.agentIdCounter++}`; + if (this.agents.has(id)) continue; + if (this.session.metadata.agents[id] !== undefined) continue; + return id; + } + } + + requireMainAgent(): Agent { + const agent = this.getReadyAgent('main'); + if (agent === undefined) { + throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); + } + return agent; + } + + private async triggerSessionStart(source: 'startup' | 'resume'): Promise { + await this.session.hookEngine.trigger('SessionStart', { + matcherValue: source, + inputData: { source }, + }); + } + + private async triggerSessionEnd(reason: 'exit'): Promise { + await this.session.hookEngine.trigger('SessionEnd', { + matcherValue: reason, + inputData: { reason }, + }); + } +} diff --git a/packages/agent-core/src/session/session.ts b/packages/agent-core/src/session/session.ts new file mode 100644 index 000000000..150fe386e --- /dev/null +++ b/packages/agent-core/src/session/session.ts @@ -0,0 +1,290 @@ +import { createDecorator } from '#/_base/di'; +import { encodeWorkDirKey } from './store'; +import type { Event } from '#/_base/event'; +import type { SessionSummary } from '#/rpc'; +import type { SessionMeta } from '.'; +import type { AgentStateSnapshot } from '#/prompt'; +import { + emptySessionUsage, + type CompactSessionRequest, + type CompactSessionResponse, + type CursorQuery, + type PageResponse, + type Session, + type SessionChildCreate, + type SessionCreate, + type SessionFork, + type SessionStatus, + type SessionStatusResponse, + type SessionUpdate, + type UndoSessionRequest, + type UndoSessionResponse, +} from '@moonshot-ai/protocol'; + +export interface SessionListQuery extends CursorQuery { + status?: import('@moonshot-ai/protocol').SessionStatus; + workDir?: string; + includeArchive?: boolean; +} + +/** + * Scope over which the `SessionIndex` read model lists / counts / searches. + * + * These mirror the four list surfaces the RPC boundary exposes today + * (global list, per-workspace, per-workDir, children-of-parent) so the + * upcoming `SessionQueryService` (M1.3) can route each through one index. + * + * Field names line up with what `SessionSummary` actually carries: + * - `workspace.workspaceId` matches `encodeWorkDirKey(summary.workDir)` + * (the workspace id is derived from `workDir`, not stored on the summary). + * - `workDir.workDir` matches `summary.workDir` directly. + * - `children.parentId` matches `summary.metadata['parent_session_id']` + * (paired with `metadata['child_session_kind'] === 'child'`), mirroring + * `SessionService.listChildren`. + */ +export type SessionQueryScope = + | { readonly kind: 'global' } + | { readonly kind: 'workspace'; readonly workspaceId: string } + | { readonly kind: 'workDir'; readonly workDir: string } + | { readonly kind: 'children'; readonly parentId: string }; + +/** Archived-row visibility for index reads. Defaults to `'exclude'`. */ +export type SessionIndexArchiveVisibility = 'exclude' | 'include' | 'only'; + +/** Sortable summary fields. All exist on `SessionSummary`. */ +export type SessionIndexOrderBy = 'updatedAt' | 'createdAt' | 'title'; + +export type SessionIndexOrderDirection = 'asc' | 'desc'; + +/** + * Read options shared by `ISessionIndex.list` / `count` / `search`. + * + * Archive visibility, ordering, and pagination are implemented in exactly + * one place (the index); callers only describe what they want. + * + * `cursor` / `limit` mirror the protocol `CursorQuery` shape + * (`before_id` / `after_id` / `page_size`): `cursor` is the exclusive + * after-id of the last item on the previous page, and `limit` caps the + * number of rows returned. + */ +export interface SessionIndexListOpts { + readonly archived?: SessionIndexArchiveVisibility; + readonly orderBy?: SessionIndexOrderBy; + readonly orderDirection?: SessionIndexOrderDirection; + /** Exclusive after-id cursor: return rows sorted after this id. */ + readonly cursor?: string; + /** Maximum number of rows to return. */ + readonly limit?: number; +} + +/** + * Read-model summary index for sessions. + * + * Holds one `SessionSummary` row per session id and serves list / count / + * search across the four `SessionQueryScope`s. It is a read model, not + * truth: writers keep it in sync via `upsert` / `remove`. It is NOT a + * `*Service` DI singleton — the `SessionQueryService` facade (M1.3) owns + * and populates the instance. + */ +export interface ISessionIndex { + /** Insert or replace the summary row for `summary.id`. */ + upsert(summary: SessionSummary): void; + + /** Drop the summary row for `id`. No-op when absent. */ + remove(id: string): void; + + /** Look up a single summary by id. */ + get(id: string): SessionSummary | undefined; + + /** List summaries in `scope`, applying visibility / ordering / pagination. */ + list(scope: SessionQueryScope, opts: SessionIndexListOpts): SessionSummary[]; + + /** Count summaries in `scope` (visibility applies; pagination does not). */ + count(scope: SessionQueryScope, opts?: SessionIndexListOpts): number; + + /** Search summaries in `scope` by title (case-insensitive substring). */ + search(scope: SessionQueryScope, query: string, opts?: SessionIndexListOpts): SessionSummary[]; +} + +export interface SessionClientTelemetry { + id?: string; + name?: string; + version?: string; + uiMode?: string; +} + +export interface SessionCreateOptions { + client?: SessionClientTelemetry; +} + +export interface ISessionService { + readonly _serviceBrand: undefined; + + create(input: SessionCreate, options?: SessionCreateOptions): Promise; + + get(id: string): Promise; + + update(id: string, input: SessionUpdate): Promise; + + fork(id: string, input: SessionFork): Promise; + + createChild(id: string, input: SessionChildCreate): Promise; + + compact(id: string, input: CompactSessionRequest): Promise; + + undo(id: string, input: UndoSessionRequest): Promise; + + archive(id: string): Promise<{ archived: true }>; + + readonly onDidCreate: Event<{ session: Session }>; + + readonly onDidClose: Event<{ sessionId: string }>; +} + +export interface SessionSearchQuery extends SessionListQuery { + /** Case-insensitive substring matched against the session title. */ + readonly q: string; +} + +/** + * Read-model facade for sessions. + * + * Serves list / count / search across the `SessionQueryScope`s from the + * `SessionIndex` (M1.2) read model. It is a pure query surface: it never + * mutates session state and never resumes an agent — hydration is limited to + * cold reads (`listSessions` + per-row `getSessionMetadata`). The command + * path (`ISessionService`) delegates its list surfaces here (M1.3) and will + * drop them entirely in M7.1. + */ +export interface ISessionQueryService { + readonly _serviceBrand: undefined; + + /** List sessions, honoring `workDir` / `includeArchive` / cursor / status. */ + list(query: SessionListQuery): Promise>; + + /** List direct children of a parent session. */ + listChildren(id: string, query: SessionListQuery): Promise>; + + /** List every session regardless of workDir (global scope). */ + listGlobal(query: SessionListQuery): Promise>; + + /** List sessions whose workDir maps to `workspaceId`. */ + listByWorkspace(workspaceId: string, query: SessionListQuery): Promise>; + + /** Count sessions in `scope` (defaults to global). */ + count(scope?: SessionQueryScope): Promise; + + /** Search sessions by title within the scope implied by the query. */ + search(query: SessionSearchQuery): Promise>; +} + +export const ISessionService = createDecorator('sessionService'); + +export const ISessionQueryService = createDecorator('sessionQueryService'); + +export const ISessionRuntimeService = + createDecorator('sessionRuntimeService'); + +/** + * In-process payload fired by `ISessionRuntimeService.onDidChangeStatus` when a + * session's computed lifecycle status changes. The wire-level + * `event.session.status_changed` event is still published unchanged; this is + * the camelCase in-process projection carrying the session id explicitly. + */ +export interface SessionStatusChanged { + readonly sessionId: string; + readonly status: SessionStatus; + readonly previousStatus: SessionStatus; + readonly currentPromptId?: string; +} + +/** + * Cold/live runtime projection for a session. `{ live: false }` is returned + * when no agent is currently loaded (no `AgentStateSnapshot` is available from + * `IPromptService`); otherwise `agentState` carries the live snapshot. The + * runtime service never resumes an agent to answer this. + */ +export type SessionLiveState = + | { readonly live: false } + | { readonly live: true; readonly agentState: AgentStateSnapshot }; + +/** + * Runtime facade for sessions (runtime role). + * + * Event-driven live-state projection: per-id status (`getStatus`), a cold/live + * indicator (`getLiveState`), and a status-change subscription + * (`onDidChangeStatus`). Driven by the global `IEventService` stream — a + * projection, not truth; it never writes status back to the store. + */ +export interface ISessionRuntimeService { + readonly _serviceBrand: undefined; + + /** Full status snapshot for a session (protocol `SessionStatusResponse`). */ + getStatus(id: string): Promise; + + /** Cold/live indicator; never resumes an agent. */ + getLiveState(id: string): Promise; + + /** Fires when a session's computed status changes. */ + readonly onDidChangeStatus: Event; +} + +export class SessionUndoUnavailableError extends Error { + readonly sessionId: string; + constructor(sessionId: string, message = 'Nothing to undo in the active context.') { + super(message); + this.name = 'SessionUndoUnavailableError'; + this.sessionId = sessionId; + } +} + +export class SessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'SessionNotFoundError'; + this.sessionId = sessionId; + } +} + +export function toProtocolSession( + summary: SessionSummary, + meta?: SessionMeta | undefined, +): Session { + const summaryMetadata = (summary.metadata ?? {}) as Record; + const customMetadata = (meta?.custom ?? {}) as Record; + const cwd = + (typeof customMetadata['cwd'] === 'string' && customMetadata['cwd']) || + (typeof summaryMetadata['cwd'] === 'string' && summaryMetadata['cwd']) || + summary.workDir; + + const { goal: _dropSummaryGoal, ...summaryWithoutGoal } = summaryMetadata; + const { goal: _dropCustomGoal, ...customWithoutGoal } = customMetadata; + + const mergedMetadata: Session['metadata'] = { + ...summaryWithoutGoal, + ...customWithoutGoal, + cwd, + }; + + const title = meta?.title ?? summary.title ?? ''; + const workspaceId = encodeWorkDirKey(summary.workDir); + + return { + id: summary.id, + workspace_id: workspaceId, + title, + created_at: new Date(summary.createdAt).toISOString(), + updated_at: new Date(summary.updatedAt).toISOString(), + status: 'idle', + archived: summary.archived === true, + metadata: mergedMetadata, + agent_config: { + model: '', + }, + usage: emptySessionUsage(), + permission_rules: [], + message_count: 0, + last_seq: 0, + }; +} diff --git a/packages/agent-core/src/session/sessionIndex.ts b/packages/agent-core/src/session/sessionIndex.ts new file mode 100644 index 000000000..a6705a1d6 --- /dev/null +++ b/packages/agent-core/src/session/sessionIndex.ts @@ -0,0 +1,180 @@ +import type { SessionSummary } from '#/rpc'; +import { encodeWorkDirKey } from './store'; +import type { + ISessionIndex, + SessionIndexArchiveVisibility, + SessionIndexListOpts, + SessionIndexOrderBy, + SessionIndexOrderDirection, + SessionQueryScope, +} from './session'; + +/** + * Mirrors `CHILD_SESSION_KIND` in `sessionService.ts`. A child session is + * identified by `metadata.parent_session_id` + this kind tag; the index + * uses the same derivation so the `children` scope lines up with + * `SessionService.listChildren` once M1.3 routes through it. + */ +const CHILD_SESSION_KIND = 'child'; + +/** + * In-memory read-model index over `SessionSummary` rows. + * + * Owns the single implementation of archive visibility, ordering, and + * pagination for session list / count / search. It is a plain class (not + * a `*Service` DI singleton); the upcoming `SessionQueryService` facade + * (M1.3) will construct, populate, and consume it. + */ +export class SessionIndex implements ISessionIndex { + private readonly summaries = new Map(); + + upsert(summary: SessionSummary): void { + this.summaries.set(summary.id, summary); + } + + remove(id: string): void { + this.summaries.delete(id); + } + + get(id: string): SessionSummary | undefined { + return this.summaries.get(id); + } + + list(scope: SessionQueryScope, opts: SessionIndexListOpts): SessionSummary[] { + const rows = this.collect(scope, opts); + return applyPagination(rows, opts.cursor, opts.limit); + } + + count(scope: SessionQueryScope, opts?: SessionIndexListOpts): number { + const visibility = opts?.archived ?? 'exclude'; + let total = 0; + for (const summary of this.summaries.values()) { + if (!inScope(summary, scope)) continue; + if (!matchesArchive(summary, visibility)) continue; + total += 1; + } + return total; + } + + search( + scope: SessionQueryScope, + query: string, + opts?: SessionIndexListOpts, + ): SessionSummary[] { + const needle = query.toLowerCase(); + const rows = this.collect(scope, opts, (summary) => + (summary.title ?? '').toLowerCase().includes(needle), + ); + return applyPagination(rows, opts?.cursor, opts?.limit); + } + + /** + * Shared scope + visibility (+ optional search predicate) filter and the + * single sort site. Pagination is layered on by the callers that need it + * (`list` / `search`); `count` skips this helper to avoid sorting rows it + * only measures. + */ + private collect( + scope: SessionQueryScope, + opts: SessionIndexListOpts | undefined, + predicate?: (summary: SessionSummary) => boolean, + ): SessionSummary[] { + const visibility = opts?.archived ?? 'exclude'; + const orderBy = opts?.orderBy ?? 'updatedAt'; + const direction = opts?.orderDirection ?? 'desc'; + const rows: SessionSummary[] = []; + for (const summary of this.summaries.values()) { + if (!inScope(summary, scope)) continue; + if (!matchesArchive(summary, visibility)) continue; + if (predicate !== undefined && !predicate(summary)) continue; + rows.push(summary); + } + rows.sort((a, b) => compareSummaries(a, b, orderBy, direction)); + return rows; + } +} + +function inScope(summary: SessionSummary, scope: SessionQueryScope): boolean { + switch (scope.kind) { + case 'global': + return true; + case 'workspace': + // The workspace id is derived from `workDir` (see + // `toProtocolSession`); it is not stored on the summary itself. + return encodeWorkDirKey(summary.workDir) === scope.workspaceId; + case 'workDir': + return summary.workDir === scope.workDir; + case 'children': { + const meta = summary.metadata; + return ( + meta?.['parent_session_id'] === scope.parentId && + meta?.['child_session_kind'] === CHILD_SESSION_KIND + ); + } + } +} + +function matchesArchive( + summary: SessionSummary, + visibility: SessionIndexArchiveVisibility, +): boolean { + const archived = summary.archived === true; + switch (visibility) { + case 'exclude': + return !archived; + case 'include': + return true; + case 'only': + return archived; + } +} + +function compareSummaries( + a: SessionSummary, + b: SessionSummary, + orderBy: SessionIndexOrderBy, + direction: SessionIndexOrderDirection, +): number { + let cmp: number; + switch (orderBy) { + case 'updatedAt': + cmp = a.updatedAt - b.updatedAt; + break; + case 'createdAt': + cmp = a.createdAt - b.createdAt; + break; + case 'title': + cmp = (a.title ?? '').localeCompare(b.title ?? ''); + break; + } + if (direction === 'desc') { + cmp = -cmp; + } + if (cmp !== 0) { + return cmp; + } + // Deterministic tie-break so equal keys still produce a stable order. + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +function applyPagination( + rows: readonly SessionSummary[], + cursor: string | undefined, + limit: number | undefined, +): SessionSummary[] { + let start = 0; + if (cursor !== undefined) { + const idx = rows.findIndex((s) => s.id === cursor); + // Cursor miss falls through to the full list, mirroring the pivot-miss + // behavior of `SessionService.list` / `listChildren`. + if (idx >= 0) { + start = idx + 1; + } + } + const afterCursor = rows.slice(start); + if (limit === undefined) { + return afterCursor; + } + const size = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0; + return afterCursor.slice(0, size); +} diff --git a/packages/agent-core/src/session/sessionQueryService.ts b/packages/agent-core/src/session/sessionQueryService.ts new file mode 100644 index 000000000..1362dc1b2 --- /dev/null +++ b/packages/agent-core/src/session/sessionQueryService.ts @@ -0,0 +1,239 @@ +import { Disposable, IInstantiationService, InstantiationType, registerSingleton } from '#/_base/di'; +import type { PageResponse, Session } from '@moonshot-ai/protocol'; +import type { CoreRPC, SessionSummary } from '#/rpc'; + +import { IApprovalService } from '#/approval'; +import { ICoreRuntime } from '#/coreProcess'; +import { IEventService } from '#/event'; +import { IPromptService } from '#/prompt'; +import { IQuestionService } from '#/question'; +import { + ISessionQueryService, + SessionNotFoundError, + toProtocolSession, + type SessionIndexArchiveVisibility, + type SessionListQuery, + type SessionQueryScope, + type SessionSearchQuery, +} from './session'; +import { SessionIndex } from './sessionIndex'; +import { + applySessionTurnEvent, + computeSessionStatus, + tryGetSessionMeta, +} from './sessionStatus'; + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + +/** + * Reproduces the `compareSessionSummary` ordering used by + * `core.rpc.listSessions` (updatedAt desc → createdAt desc → id asc). + * + * Today's `SessionService.list` returns rows in this order (the RPC already + * sorts this way and the service's stable `toSorted` by `updatedAt` preserves + * it), so the query service re-sorts with the same comparator to stay + * byte-for-byte identical at the protocol level instead of relying on the + * index's simpler updatedAt/id tie-break. + */ +function compareSessionSummaries(a: SessionSummary, b: SessionSummary): number { + if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; + if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; +} + +/** + * Read-model facade for sessions (query role). + * + * Serves list / count / search from the `SessionIndex` read model. Each call + * re-seeds a fresh index from the in-process `listSessions` accessor + * (`{ includeArchive: true }`) so freshness matches today's + * `SessionService.list` (which reads the store on every call); M1.5 will + * replace this with a persistent index kept in sync by the command path. + * + * The query path is cold: it only reads `listSessions` + per-row + * `getSessionMetadata` and never calls `resumeSession` / `getReadyAgent`, so a + * plain list cannot resume an agent. Live status is derived via the shared + * `computeSessionStatus` helper fed by this service's own turn-tracking sets + * (driven by the same event-bus events `SessionService` consumes), keeping the + * read path's status byte-identical without reaching into `SessionService`. + */ +export class SessionQueryService extends Disposable implements ISessionQueryService { + readonly _serviceBrand: undefined; + + private readonly _activeTurns = new Set(); + private readonly _abortedTurns = new Set(); + private _promptService: IPromptService | undefined; + + constructor( + @ICoreRuntime private readonly core: ICoreRuntime, + @IEventService private readonly eventService: IEventService, + @IInstantiationService private readonly instantiation: IInstantiationService, + @IApprovalService private readonly approvalService: IApprovalService, + @IQuestionService private readonly questionService: IQuestionService, + ) { + super(); + this._register( + this.eventService.onDidPublish((event) => { + applySessionTurnEvent( + { activeTurns: this._activeTurns, abortedTurns: this._abortedTurns }, + event, + ); + }), + ); + } + + private get promptService(): IPromptService { + return (this._promptService ??= this.instantiation.invokeFunction((a) => a.get(IPromptService))); + } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site below reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } + + private computeStatus(sessionId: string) { + return computeSessionStatus({ + awaitingApproval: this.approvalService.listPending(sessionId).length > 0, + awaitingQuestion: this.questionService.listPending(sessionId).length > 0, + hasActivePrompt: this.promptService.getCurrentPromptId(sessionId) !== undefined, + hasActiveTurn: this._activeTurns.has(sessionId), + wasAborted: this._abortedTurns.has(sessionId), + }); + } + + private patchStatus(session: Session): Session { + session.status = this.computeStatus(session.id); + return session; + } + + /** + * Build a fresh index from the store. `includeArchive: true` loads the full + * global set so the index can apply archive visibility per scope; each call + * re-reads to match today's per-call freshness. + */ + private async loadIndex(): Promise { + const index = new SessionIndex(); + const summaries = await this.coreApi().listSessions({ includeArchive: true }); + for (const summary of summaries) { + index.upsert(summary); + } + return index; + } + + /** + * Pagination + hydration shared by every list surface. Mirrors today's + * `SessionService.list` exactly: cursor pivot on the sorted set, page-size + * clamp, `has_more` measured before the post-hydration status filter. + */ + private async paginate( + summaries: readonly SessionSummary[], + query: SessionListQuery, + ): Promise> { + const sorted = summaries.toSorted(compareSessionSummaries); + + let pivotIndex = -1; + if (query.before_id !== undefined) { + pivotIndex = sorted.findIndex((s) => s.id === query.before_id); + } else if (query.after_id !== undefined) { + pivotIndex = sorted.findIndex((s) => s.id === query.after_id); + } + + let slice: readonly SessionSummary[]; + if (query.before_id !== undefined && pivotIndex >= 0) { + slice = sorted.slice(pivotIndex + 1); + } else if (query.after_id !== undefined && pivotIndex >= 0) { + slice = sorted.slice(0, pivotIndex); + } else { + slice = sorted; + } + + const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; + const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); + const pageSummaries = slice.slice(0, pageSize); + const hasMore = slice.length > pageSize; + + const items = await Promise.all( + pageSummaries.map(async (s) => + this.patchStatus(toProtocolSession(s, await tryGetSessionMeta(this.core, s.id))), + ), + ); + + const filtered = + query.status !== undefined ? items.filter((s) => s.status === query.status) : items; + + return { items: filtered, has_more: hasMore }; + } + + async list(query: SessionListQuery): Promise> { + const index = await this.loadIndex(); + const scope: SessionQueryScope = + query.workDir !== undefined ? { kind: 'workDir', workDir: query.workDir } : { kind: 'global' }; + const archived: SessionIndexArchiveVisibility = query.includeArchive ? 'include' : 'exclude'; + return this.paginate(index.list(scope, { archived }), query); + } + + async listChildren(id: string, query: SessionListQuery): Promise> { + const index = await this.loadIndex(); + const parent = index.get(id); + // Mirror today's `SessionService.listChildren`, which first runs `get(id)` + // (global, archived excluded) and throws for a missing or archived parent. + if (parent === undefined || parent.archived === true) { + throw new SessionNotFoundError(id); + } + // Today's implementation always excludes archived children and ignores + // `workDir` / `includeArchive` on the query. + const scope: SessionQueryScope = { kind: 'children', parentId: id }; + return this.paginate(index.list(scope, { archived: 'exclude' }), query); + } + + async listGlobal(query: SessionListQuery): Promise> { + const index = await this.loadIndex(); + const archived: SessionIndexArchiveVisibility = query.includeArchive ? 'include' : 'exclude'; + return this.paginate(index.list({ kind: 'global' }, { archived }), query); + } + + async listByWorkspace( + workspaceId: string, + query: SessionListQuery, + ): Promise> { + const index = await this.loadIndex(); + const archived: SessionIndexArchiveVisibility = query.includeArchive ? 'include' : 'exclude'; + const scope: SessionQueryScope = { kind: 'workspace', workspaceId }; + return this.paginate(index.list(scope, { archived }), query); + } + + async count(scope?: SessionQueryScope): Promise { + const index = await this.loadIndex(); + return index.count(scope ?? { kind: 'global' }); + } + + async search(query: SessionSearchQuery): Promise> { + const index = await this.loadIndex(); + const scope: SessionQueryScope = + query.workDir !== undefined ? { kind: 'workDir', workDir: query.workDir } : { kind: 'global' }; + const archived: SessionIndexArchiveVisibility = query.includeArchive ? 'include' : 'exclude'; + return this.paginate(index.search(scope, query.q, { archived }), query); + } +} + +registerSingleton(ISessionQueryService, SessionQueryService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/session/sessionRepository.ts b/packages/agent-core/src/session/sessionRepository.ts new file mode 100644 index 000000000..ce55c6130 --- /dev/null +++ b/packages/agent-core/src/session/sessionRepository.ts @@ -0,0 +1,94 @@ +import { join } from 'pathe'; +import type { Kaos } from '@moonshot-ai/kaos'; + +import type { SessionMeta } from './index'; + +/** + * Single-entity persistence contract for one session's `state.json`. + * + * Per `services/AGENTS.md` (M0.5, amended in M1.1 fixup-1) a repository is + * the aggregate's source of truth: it owns create / get / update and the + * archive / restore / delete atomic operations, sits *below* the application + * service layer, and is NOT registered as a top-level `*Service` singleton. + * + * Because the runtime `Session` consumes this contract directly, it lives in + * the runtime layer (`src/session/`) rather than under `services/` — the + * runtime must not import from `services/` (the dependency-direction fence). + * + * `ISessionRepository` is therefore a **per-session** object: one instance is + * bound to exactly one session `homedir` (its `state.json`), not to the + * aggregate as a whole. The owner (the runtime `Session`) holds the instance + * and drives it; cross-session orchestration stays in `ISessionService`. + * + * Scope of M1.1: only the read / write / flush operations that already exist + * on the runtime `Session` are modeled here, because they are the only ones + * with a byte-for-byte mirrorable persistence implementation today. The + * archive / restore / purge atomic operations are intentionally deferred to + * M1.5: `archive`'s file IO currently lives in + * `src/session/store/session-store.ts`, and `restore` / `purge` have no + * existing implementation to mirror without inventing new semantics. + */ +export interface ISessionRepository { + /** Read and parse the session's `state.json`. Throws if it does not exist. */ + read(): Promise; + + /** + * Serialize `meta` to `state.json`. Concurrent calls are ordered: each write + * is chained onto the previous one so no write is lost or reordered. + */ + write(meta: SessionMeta): Promise; + + /** Resolve once every previously-submitted `write` has completed. */ + flush(): Promise; +} + +/** + * Per-session persistence of `state.json` behind the service layer. + * + * One `SessionRepository` is bound to exactly one session `homedir`; it is the + * single writer of that session's `state.json`. Writes are serialized through a + * chained promise so concurrent `write()` calls are ordered and none are lost + * or reordered — mirroring the runtime `Session.writeMetadata` semantics this + * class replaces. + * + * This is the `repository` role from `services/AGENTS.md` (M0.5): a + * persistence-layer contract, not a top-level `*Service`. It is instantiated + * per session by its owner (the runtime `Session`) and is never registered as + * a singleton. + */ +export class SessionRepository implements ISessionRepository { + private readonly homedir: string; + private readonly kaos: Kaos; + private readonly metadataPath: string; + private writePromise: Promise = Promise.resolve(); + + constructor(homedir: string, kaos: Kaos) { + this.homedir = homedir; + this.kaos = kaos; + this.metadataPath = join(homedir, 'state.json'); + } + + async read(): Promise { + const text = await this.kaos.readText(this.metadataPath); + return JSON.parse(text) as SessionMeta; + } + + write(meta: SessionMeta): Promise { + // Capture the serialized text synchronously, exactly like the previous + // `Session.writeMetadata`, so the written snapshot matches the metadata at + // the moment `write` is called even if the caller mutates `meta` afterward. + const text = JSON.stringify(meta, null, 2); + const write = async () => { + await this.kaos.mkdir(this.homedir, { parents: true, existOk: true }); + await this.kaos.writeText(this.metadataPath, text); + }; + // Chain on both fulfillment and rejection so a failed write does not break + // the serialization of subsequent writes. + this.writePromise = this.writePromise.then(write, write); + return this.writePromise; + } + + async flush(): Promise { + await this.writePromise; + } +} diff --git a/packages/agent-core/src/session/sessionRuntimeService.ts b/packages/agent-core/src/session/sessionRuntimeService.ts new file mode 100644 index 000000000..ecc692f2c --- /dev/null +++ b/packages/agent-core/src/session/sessionRuntimeService.ts @@ -0,0 +1,188 @@ +import { Disposable, IInstantiationService, InstantiationType, registerSingleton } from '#/_base/di'; +import { Emitter } from '#/_base/event'; +import type { Event, SessionStatus, SessionStatusResponse } from '@moonshot-ai/protocol'; +import type { CoreRPC } from '#/rpc'; + +import { IApprovalService } from '#/approval'; +import { ICoreRuntime } from '#/coreProcess'; +import { IEventService } from '#/event'; +import { IPromptService } from '#/prompt'; +import { IQuestionService } from '#/question'; +import { + ISessionRuntimeService, + SessionNotFoundError, + type SessionLiveState, + type SessionStatusChanged, +} from './session'; +import { applySessionTurnEvent, computeSessionStatus } from './sessionStatus'; + +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + +/** + * Runtime facade for sessions (runtime role). + * + * Owns the live status projection: it subscribes to the global + * `IEventService` stream (the same bus `SessionService` and + * `SessionQueryService` consume) to keep per-session turn-tracking sets, and + * recomputes status via the shared `computeSessionStatus` helper. Status is a + * projection, not truth — nothing here is written back to the store. + * + * `event.session.status_changed` is still published on every real status + * transition with the same type, payload, and timing as before, so downstream + * consumers (the WS broadcast, the SDK) are unaffected. + */ +export class SessionRuntimeService extends Disposable implements ISessionRuntimeService { + readonly _serviceBrand: undefined; + + private readonly _onDidChangeStatus = this._register(new Emitter()); + readonly onDidChangeStatus = this._onDidChangeStatus.event; + + private readonly _statusBySession = new Map(); + private readonly _activeTurns = new Set(); + private readonly _abortedTurns = new Set(); + private _promptService: IPromptService | undefined; + + constructor( + @ICoreRuntime private readonly core: ICoreRuntime, + @IEventService private readonly eventService: IEventService, + @IInstantiationService private readonly instantiation: IInstantiationService, + @IApprovalService private readonly approvalService: IApprovalService, + @IQuestionService private readonly questionService: IQuestionService, + ) { + super(); + this._register( + this.eventService.onDidPublish((event) => { + this.handleBusEvent(event); + }), + ); + } + + private get promptService(): IPromptService { + return (this._promptService ??= this.instantiation.invokeFunction((a) => a.get(IPromptService))); + } + + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site below reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } + + private computeStatus(sessionId: string): SessionStatus { + return computeSessionStatus({ + awaitingApproval: this.approvalService.listPending(sessionId).length > 0, + awaitingQuestion: this.questionService.listPending(sessionId).length > 0, + hasActivePrompt: this.promptService.getCurrentPromptId(sessionId) !== undefined, + hasActiveTurn: this._activeTurns.has(sessionId), + wasAborted: this._abortedTurns.has(sessionId), + }); + } + + private emitStatusChanged(sessionId: string): void { + const previous = this._statusBySession.get(sessionId) ?? 'idle'; + const next = this.computeStatus(sessionId); + if (previous === next) return; + + this._statusBySession.set(sessionId, next); + const currentPromptId = this.promptService.getCurrentPromptId(sessionId); + this._onDidChangeStatus.fire({ + sessionId, + status: next, + previousStatus: previous, + ...(currentPromptId !== undefined ? { currentPromptId } : {}), + }); + this.eventService.publish({ + type: 'event.session.status_changed', + agentId: 'main', + sessionId, + status: next, + previous_status: previous, + current_prompt_id: currentPromptId, + } as unknown as Event); + } + + private handleBusEvent(event: Event): void { + const type = (event as { type?: string }).type; + const sessionId = (event as { sessionId?: string }).sessionId; + if (sessionId === undefined || sessionId === '' || type === undefined) return; + + applySessionTurnEvent( + { activeTurns: this._activeTurns, abortedTurns: this._abortedTurns }, + event, + ); + + switch (type) { + case 'turn.started': + case 'turn.ended': + case 'prompt.submitted': + case 'prompt.completed': + case 'prompt.aborted': + case 'event.approval.requested': + case 'event.approval.resolved': + case 'event.approval.expired': + case 'event.question.requested': + case 'event.question.answered': + case 'event.question.dismissed': + case 'event.question.expired': { + this.emitStatusChanged(sessionId); + break; + } + } + } + + async getStatus(id: string): Promise { + const all = await this.coreApi().listSessions({}); + const summary = all.find((s) => s.id === id); + if (summary === undefined) { + throw new SessionNotFoundError(id); + } + + const [config, context, permission, plan] = await Promise.all([ + this.coreApi().getConfig({ sessionId: id, agentId: 'main' }), + this.coreApi().getContext({ sessionId: id, agentId: 'main' }), + this.coreApi().getPermission({ sessionId: id, agentId: 'main' }), + this.coreApi().getPlan({ sessionId: id, agentId: 'main' }), + ]); + + const maxContextTokens = config.modelCapabilities?.max_context_tokens ?? 0; + const contextTokens = context.tokenCount; + const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; + + const agentState = this.promptService.getAgentStateSnapshot(id); + + return { + status: this.computeStatus(id), + model: config.modelAlias ?? config.provider?.model, + thinking_level: config.thinkingLevel, + permission: permission.mode, + plan_mode: plan !== null, + swarm_mode: agentState?.swarmMode ?? false, + context_tokens: contextTokens, + max_context_tokens: maxContextTokens, + context_usage: contextUsage, + }; + } + + async getLiveState(id: string): Promise { + const snapshot = this.promptService.getAgentStateSnapshot(id); + if (snapshot === undefined) { + return { live: false }; + } + return { live: true, agentState: snapshot }; + } +} + +registerSingleton(ISessionRuntimeService, SessionRuntimeService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/services/session/sessionService.ts b/packages/agent-core/src/session/sessionService.ts similarity index 50% rename from packages/agent-core/src/services/session/sessionService.ts rename to packages/agent-core/src/session/sessionService.ts index 99530c8d9..b60d27a58 100644 --- a/packages/agent-core/src/services/session/sessionService.ts +++ b/packages/agent-core/src/session/sessionService.ts @@ -1,13 +1,11 @@ -import { Disposable, IInstantiationService, InstantiationType, registerSingleton } from '../../di'; -import { Emitter } from '../../base/common/event'; -import { ErrorCodes, KimiError } from '../../errors'; -import type { AgentContextData, ContextMessage } from '../../agent/context'; -import type { JsonObject, ListSessionsPayload, SessionSummary } from '../../rpc'; -import type { SessionMeta } from '../../session'; +import { Disposable, IInstantiationService, InstantiationType, registerSingleton } from '#/_base/di'; +import { Emitter } from '#/_base/event'; +import { ErrorCodes, KimiError } from '#/errors'; +import type { AgentContextData, ContextMessage } from '../agent/context'; +import type { CoreRPC, JsonObject, SessionSummary } from '#/rpc'; import { type CompactSessionRequest, type CompactSessionResponse, - type Event, type Message, type PageResponse, type Session, @@ -15,33 +13,44 @@ import { type SessionCreate, type SessionFork, type SessionStatus, - type SessionStatusResponse, type SessionUpdate, type UndoSessionRequest, type UndoSessionResponse, } from '@moonshot-ai/protocol'; -import { IApprovalService } from '../approval/approval'; -import { ICoreProcessService } from '../coreProcess/coreProcess'; -import { IEventService } from '../event/event'; -import { toProtocolMessage } from '../message/message'; -import { IPromptService, type AgentStatePatch } from '../prompt/prompt'; -import { IQuestionService } from '../question/question'; +import { IApprovalService } from '#/approval'; +import { ICoreRuntime } from '#/coreProcess'; +import { IEventService } from '#/event'; +import { toProtocolMessage } from '#/message'; +import { IPromptService, type AgentStatePatch } from '#/prompt'; +import { IQuestionService } from '#/question'; import { + ISessionRuntimeService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, toProtocolSession, + type ISessionIndex, type SessionCreateOptions, - type SessionListQuery, } from './session'; +import { SessionIndex } from './sessionIndex'; +import { SessionRuntimeService } from './sessionRuntimeService'; +import { applySessionTurnEvent, computeSessionStatus, tryGetSessionMeta } from './sessionStatus'; -const DEFAULT_PAGE_SIZE = 20; -const MAX_PAGE_SIZE = 100; const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50; const MAX_UNDO_MESSAGE_PAGE_SIZE = 100; const CHILD_SESSION_KIND = 'child'; +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + function asJsonObject(value: Record): JsonObject { return value as unknown as JsonObject; } @@ -101,30 +110,70 @@ export class SessionService extends Disposable implements ISessionService { private readonly _onDidClose = this._register(new Emitter<{ sessionId: string }>()); readonly onDidClose = this._onDidClose.event; - private readonly _statusBySession = new Map(); private readonly _activeTurns = new Set(); private readonly _abortedTurns = new Set(); private _promptService: IPromptService | undefined; + private readonly _sessionRuntimeService: ISessionRuntimeService; + /** + * Writer-synced read-model index of session summaries. Every mutating + * command re-reads the affected session's summary and upserts it here (see + * `_syncSessionIndex`) so the read model stays in sync with writes — the + * command-side half of the writer-synced index deferred from M1.3. + */ + private readonly _sessionIndex = new SessionIndex(); constructor( - @ICoreProcessService private readonly core: ICoreProcessService, + @ICoreRuntime private readonly core: ICoreRuntime, @IEventService private readonly eventService: IEventService, @IInstantiationService private readonly instantiation: IInstantiationService, @IApprovalService private readonly approvalService: IApprovalService, @IQuestionService private readonly questionService: IQuestionService, ) { super(); + // Keep our own turn-tracking sets so `_patchSessionStatus` can stamp the + // live status onto sessions returned by create/get/update/fork/createChild. + // The `event.session.status_changed` emission now lives on the composed + // SessionRuntimeService (below); this subscription only feeds the local + // sets, mirroring how SessionQueryService keeps its own sets in M1.3. this._register( this.eventService.onDidPublish((event) => { - this._handleBusEvent(event); + applySessionTurnEvent( + { activeTurns: this._activeTurns, abortedTurns: this._abortedTurns }, + event, + ); }), ); + // Compose the runtime facade eagerly (rather than resolving lazily through + // IInstantiationService) so it subscribes to the bus from the start (so its + // status_changed emission and `getStatus` stay in lock-step), and keeps + // positional `new SessionService(...)` construction working. + this._sessionRuntimeService = this._register( + new SessionRuntimeService( + this.core, + this.eventService, + this.instantiation, + this.approvalService, + this.questionService, + ), + ); } private get promptService(): IPromptService { return (this._promptService ??= this.instantiation.invokeFunction((a) => a.get(IPromptService))); } + /** + * In-process CoreAPI handle — the same methods as `this.core.rpc` but + * dispatched directly on the in-process `KimiCore`, skipping the + * `createRPC` JSON serialize/deserialize hop. Method signatures and return + * shapes are identical to the `rpc` proxy; only the serialization is + * removed. The cast is localized here so every call site below reads + * `this.coreApi().(...)`. + */ + private coreApi(): CoreRPC { + return (this.core as unknown as InProcessCoreApi).getCoreApi(); + } + /** * Compute the session lifecycle status from live daemon state. * @@ -136,98 +185,56 @@ export class SessionService extends Disposable implements ISessionService { * 5. idle — everything else */ private _computeStatus(sessionId: string): SessionStatus { - if (this.approvalService.listPending(sessionId).length > 0) { - return 'awaiting_approval'; - } - if (this.questionService.listPending(sessionId).length > 0) { - return 'awaiting_question'; - } - if ( - this.promptService.getCurrentPromptId(sessionId) !== undefined || - this._activeTurns.has(sessionId) - ) { - return 'running'; - } - if (this._abortedTurns.has(sessionId)) { - return 'aborted'; - } - return 'idle'; + return computeSessionStatus({ + awaitingApproval: this.approvalService.listPending(sessionId).length > 0, + awaitingQuestion: this.questionService.listPending(sessionId).length > 0, + hasActivePrompt: this.promptService.getCurrentPromptId(sessionId) !== undefined, + hasActiveTurn: this._activeTurns.has(sessionId), + wasAborted: this._abortedTurns.has(sessionId), + }); } /** - * Overwrite the placeholder status on a protocol Session with the live value, - * and remember the last status we returned so status-change events can be - * emitted only when the live state actually moves. + * Overwrite the placeholder status on a protocol Session with the live value + * computed from our own turn-tracking sets and the pending approval / + * question / prompt services. */ private _patchSessionStatus(session: Session): Session { - const status = this._computeStatus(session.id); - session.status = status; - this._statusBySession.set(session.id, status); + session.status = this._computeStatus(session.id); return session; } /** - * Publish `event.session.status_changed` when the computed status for a - * session differs from the last one we announced. Called after every relevant - * lifecycle event so the session list stays in sync. + * The writer-synced read-model index kept current by the command path. It is + * exposed on the class (not on the `ISessionService` command interface) so + * read-model consumers — and tests — can observe what the commands have + * written without widening the command surface. */ - private _emitStatusChanged(sessionId: string): void { - const previous = this._statusBySession.get(sessionId) ?? 'idle'; - const next = this._computeStatus(sessionId); - if (previous === next) return; - - this._statusBySession.set(sessionId, next); - this.eventService.publish({ - type: 'event.session.status_changed', - agentId: 'main', - sessionId, - status: next, - previous_status: previous, - current_prompt_id: this.promptService.getCurrentPromptId(sessionId), - } as unknown as Event); + get sessionIndex(): ISessionIndex { + return this._sessionIndex; } - private _handleBusEvent(event: Event): void { - const type = (event as { type?: string }).type; - const sessionId = (event as { sessionId?: string }).sessionId; - if (sessionId === undefined || sessionId === '' || type === undefined) return; - - switch (type) { - case 'turn.started': { - this._activeTurns.add(sessionId); - this._abortedTurns.delete(sessionId); - this._emitStatusChanged(sessionId); - break; - } - case 'turn.ended': { - this._activeTurns.delete(sessionId); - const reason = (event as { reason?: string }).reason; - if (reason === 'cancelled' || reason === 'failed') { - this._abortedTurns.add(sessionId); - } else { - this._abortedTurns.delete(sessionId); - } - this._emitStatusChanged(sessionId); - break; - } - case 'prompt.submitted': { - this._abortedTurns.delete(sessionId); - this._emitStatusChanged(sessionId); - break; - } - case 'prompt.completed': - case 'prompt.aborted': - case 'event.approval.requested': - case 'event.approval.resolved': - case 'event.approval.expired': - case 'event.question.requested': - case 'event.question.answered': - case 'event.question.dismissed': - case 'event.question.expired': { - this._emitStatusChanged(sessionId); - break; - } + /** + * Re-read the affected session's summary from the store and upsert it into + * the writer-synced index. `includeArchive: true` ensures an archived + * session is captured with `archived: true` (rather than dropped by the + * default archive-excluding list). If the id is no longer present (e.g. a + * future purge), the row is removed instead. + * + * Domain events: `create` / `fork` / `createChild` already publish + * `event.session.created` via `emitCreated`. No protocol event type exists + * for update / archive / compact / undo, so those commands only sync the + * index here — inventing a new protocol event type is out of scope (see + * phase M1.5 STATUS). + */ + private async _syncSessionIndex(id: string): Promise { + const all = await this.coreApi().listSessions({ includeArchive: true }); + const summary = all.find((s) => s.id === id); + if (summary === undefined) { + this._sessionIndex.remove(id); + return; } + this._sessionIndex.upsert(summary); } async create(input: SessionCreate, options?: SessionCreateOptions): Promise { @@ -235,7 +242,7 @@ export class SessionService extends Disposable implements ISessionService { throw new Error('SessionService.create: metadata.cwd is required'); } const metadataForCore = asJsonObject(input.metadata as Record); - const summary = await this.core.rpc.createSession({ + const summary = await this.coreApi().createSession({ workDir: input.metadata.cwd, metadata: metadataForCore, model: input.agent_config?.model, @@ -243,81 +250,41 @@ export class SessionService extends Disposable implements ISessionService { }); if (input.title !== undefined) { try { - await this.core.rpc.renameSession({ sessionId: summary.id, title: input.title }); + await this.coreApi().renameSession({ sessionId: summary.id, title: input.title }); } catch { } } - const meta = await this.tryGetMeta(summary.id); + const meta = await tryGetSessionMeta(this.core, summary.id); const session = this._patchSessionStatus(toProtocolSession(summary, meta)); this.emitCreated(session); + await this._syncSessionIndex(session.id); return session; } - async list(query: SessionListQuery): Promise> { - const corePayload: ListSessionsPayload = { - workDir: query.workDir, - includeArchive: query.includeArchive, - }; - const all = await this.core.rpc.listSessions(corePayload); - const sorted = all.toSorted((a, b) => b.updatedAt - a.updatedAt); - - let pivotIndex = -1; - if (query.before_id !== undefined) { - pivotIndex = sorted.findIndex((s) => s.id === query.before_id); - } else if (query.after_id !== undefined) { - pivotIndex = sorted.findIndex((s) => s.id === query.after_id); - } - - let slice: typeof sorted; - if (query.before_id !== undefined && pivotIndex >= 0) { - slice = sorted.slice(pivotIndex + 1); - } else if (query.after_id !== undefined && pivotIndex >= 0) { - slice = sorted.slice(0, pivotIndex); - } else { - slice = sorted; - } - - const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; - const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); - const pageSummaries = slice.slice(0, pageSize); - const hasMore = slice.length > pageSize; - - const items = await Promise.all( - pageSummaries.map(async (s) => - this._patchSessionStatus(toProtocolSession(s, await this.tryGetMeta(s.id))) - ), - ); - - const filtered = - query.status !== undefined ? items.filter((s) => s.status === query.status) : items; - - return { items: filtered, has_more: hasMore }; - } - async get(id: string): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); const summary = all.find((s) => s.id === id); if (summary === undefined) { throw new SessionNotFoundError(id); } - const meta = await this.tryGetMeta(id); + const meta = await tryGetSessionMeta(this.core, id); return this._patchSessionStatus(toProtocolSession(summary, meta)); } async update(id: string, input: SessionUpdate): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); const summary = all.find((s) => s.id === id); if (summary === undefined) { throw new SessionNotFoundError(id); } if (input.title !== undefined) { - await this.core.rpc.renameSession({ sessionId: id, title: input.title }); + await this.coreApi().renameSession({ sessionId: id, title: input.title }); } const metadataPatch = input.metadata; if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) { - await this.core.rpc.updateSessionMetadata({ + await this.coreApi().updateSessionMetadata({ sessionId: id, metadata: { custom: metadataPatch as Record }, }); @@ -346,72 +313,30 @@ export class SessionService extends Disposable implements ISessionService { } } - const allAfter = await this.core.rpc.listSessions({}); + const allAfter = await this.coreApi().listSessions({}); const summaryAfter = allAfter.find((s) => s.id === id) ?? summary; - const meta = await this.tryGetMeta(id); - return this._patchSessionStatus(toProtocolSession(summaryAfter, meta)); + const meta = await tryGetSessionMeta(this.core, id); + const session = this._patchSessionStatus(toProtocolSession(summaryAfter, meta)); + await this._syncSessionIndex(id); + return session; } async fork(id: string, input: SessionFork): Promise { const source = await this.get(id); const title = input.title ?? `Fork: ${source.title || source.id}`; const metadata = input.metadata === undefined ? undefined : asJsonObject(input.metadata); - const summary = await this.core.rpc.forkSession({ + const summary = await this.coreApi().forkSession({ sessionId: id, title, metadata, }); - const meta = await this.tryGetMeta(summary.id); + const meta = await tryGetSessionMeta(this.core, summary.id); const session = this._patchSessionStatus(toProtocolSession(summary, meta)); this.emitCreated(session); + await this._syncSessionIndex(session.id); return session; } - async listChildren(id: string, query: SessionListQuery): Promise> { - await this.get(id); - const all = await this.core.rpc.listSessions({}); - const sorted = all.toSorted((a, b) => b.updatedAt - a.updatedAt); - const children = sorted.filter( - (summary) => - summary.metadata?.['parent_session_id'] === id && - summary.metadata?.['child_session_kind'] === CHILD_SESSION_KIND, - ); - - let pivotIndex = -1; - if (query.before_id !== undefined) { - pivotIndex = children.findIndex((s) => s.id === query.before_id); - } else if (query.after_id !== undefined) { - pivotIndex = children.findIndex((s) => s.id === query.after_id); - } - - let slice: typeof children; - if (query.before_id !== undefined && pivotIndex >= 0) { - slice = children.slice(pivotIndex + 1); - } else if (query.after_id !== undefined && pivotIndex >= 0) { - slice = children.slice(0, pivotIndex); - } else { - slice = children; - } - - const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; - const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); - const pageSummaries = slice.slice(0, pageSize); - const items = await Promise.all( - pageSummaries.map(async (s) => - this._patchSessionStatus(toProtocolSession(s, await this.tryGetMeta(s.id))) - ), - ); - const filtered = - query.status !== undefined - ? items.filter((session) => session.status === query.status) - : items; - - return { - items: filtered, - has_more: slice.length > pageSize, - }; - } - async createChild(id: string, input: SessionChildCreate): Promise { const parent = await this.get(id); const title = input.title ?? `Child: ${parent.title || parent.id}`; @@ -420,14 +345,15 @@ export class SessionService extends Disposable implements ISessionService { parent_session_id: id, child_session_kind: CHILD_SESSION_KIND, }); - const summary = await this.core.rpc.forkSession({ + const summary = await this.coreApi().forkSession({ sessionId: id, title, metadata, }); - const meta = await this.tryGetMeta(summary.id); + const meta = await tryGetSessionMeta(this.core, summary.id); const session = this._patchSessionStatus(toProtocolSession(summary, meta)); this.emitCreated(session); + await this._syncSessionIndex(session.id); return session; } @@ -441,41 +367,8 @@ export class SessionService extends Disposable implements ISessionService { }); } - async getStatus(id: string): Promise { - const all = await this.core.rpc.listSessions({}); - const summary = all.find((s) => s.id === id); - if (summary === undefined) { - throw new SessionNotFoundError(id); - } - - const [config, context, permission, plan] = await Promise.all([ - this.core.rpc.getConfig({ sessionId: id, agentId: 'main' }), - this.core.rpc.getContext({ sessionId: id, agentId: 'main' }), - this.core.rpc.getPermission({ sessionId: id, agentId: 'main' }), - this.core.rpc.getPlan({ sessionId: id, agentId: 'main' }), - ]); - - const maxContextTokens = config.modelCapabilities?.max_context_tokens ?? 0; - const contextTokens = context.tokenCount; - const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; - - const agentState = this.promptService.getAgentStateSnapshot(id); - - return { - status: this._computeStatus(id), - model: config.modelAlias ?? config.provider?.model, - thinking_level: config.thinkingLevel, - permission: permission.mode, - plan_mode: plan !== null, - swarm_mode: agentState?.swarmMode ?? false, - context_tokens: contextTokens, - max_context_tokens: maxContextTokens, - context_usage: contextUsage, - }; - } - async compact(id: string, input: CompactSessionRequest): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); const summary = all.find((s) => s.id === id); if (summary === undefined) { throw new SessionNotFoundError(id); @@ -484,27 +377,28 @@ export class SessionService extends Disposable implements ISessionService { // beginCompaction only sees sessions loaded in core memory — resume first // (mirrors undo) so compacting a freshly-opened session doesn't throw // SESSION_NOT_FOUND. - await this.core.rpc.resumeSession({ sessionId: id }); + await this.coreApi().resumeSession({ sessionId: id }); const instruction = normalizeOptionalString(input.instruction); - await this.core.rpc.beginCompaction({ + await this.coreApi().beginCompaction({ sessionId: id, agentId: 'main', instruction, }); + await this._syncSessionIndex(id); return {}; } async undo(id: string, input: UndoSessionRequest): Promise { const summary = await this.requireSummary(id); - await this.core.rpc.resumeSession({ sessionId: id }); - const before = await this.core.rpc.getContext({ sessionId: id, agentId: 'main' }); + await this.coreApi().resumeSession({ sessionId: id }); + const before = await this.coreApi().getContext({ sessionId: id, agentId: 'main' }); if (!canUndoHistory(before.history, input.count)) { throw new SessionUndoUnavailableError(id); } try { - await this.core.rpc.undoHistory({ + await this.coreApi().undoHistory({ sessionId: id, agentId: 'main', count: input.count, @@ -516,29 +410,31 @@ export class SessionService extends Disposable implements ISessionService { throw error; } - const after = await this.core.rpc.getContext({ sessionId: id, agentId: 'main' }); + const after = await this.coreApi().getContext({ sessionId: id, agentId: 'main' }); + const status = await this._sessionRuntimeService.getStatus(id); + await this._syncSessionIndex(id); return { messages: pageContextMessages(id, summary.createdAt, after, input.page_size), - status: await this.getStatus(id), + status, }; } async archive(id: string): Promise<{ archived: true }> { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); const summary = all.find((s) => s.id === id); if (summary === undefined) { throw new SessionNotFoundError(id); } - await this.core.rpc.archiveSession({ sessionId: id }); + await this.coreApi().archiveSession({ sessionId: id }); this._onDidClose.fire({ sessionId: id }); - this._statusBySession.delete(id); this._activeTurns.delete(id); this._abortedTurns.delete(id); + await this._syncSessionIndex(id); return { archived: true }; } private async requireSummary(id: string): Promise { - const all = await this.core.rpc.listSessions({}); + const all = await this.coreApi().listSessions({}); const summary = all.find((s) => s.id === id); if (summary === undefined) { throw new SessionNotFoundError(id); @@ -546,15 +442,6 @@ export class SessionService extends Disposable implements ISessionService { return summary; } - private async tryGetMeta(id: string): Promise { - try { - const meta = await this.core.rpc.getSessionMetadata({ sessionId: id }); - return meta; - } catch { - return undefined; - } - } - override dispose(): void { if (this._store.isDisposed) return; super.dispose(); diff --git a/packages/agent-core/src/session/sessionStatus.ts b/packages/agent-core/src/session/sessionStatus.ts new file mode 100644 index 000000000..bd932647a --- /dev/null +++ b/packages/agent-core/src/session/sessionStatus.ts @@ -0,0 +1,124 @@ +import type { Event, SessionStatus } from '@moonshot-ai/protocol'; + +import type { ICoreRuntime } from '#/coreProcess'; +import type { CoreRPC } from '#/rpc'; +import type { SessionMeta } from '.'; + +/** + * Narrow in-process CoreAPI accessor supplied by the concrete + * `CoreProcessService` (the sole production `ICoreRuntime`). Routed + * through a structural cast so the public `ICoreRuntime` facade — and + * the many test doubles that implement it across the suite — stay unchanged. + * The daemon-side adapter always provides `getCoreApi()`; see + * `CoreProcessService.getCoreApi` for the zero-serialization rationale. + */ +type InProcessCoreApi = { getCoreApi(): CoreRPC }; + +/** + * Inputs required to derive a session's lifecycle status. Gathered by the + * caller (SessionService for the command path, SessionQueryService for the + * read path) from the live services and the in-memory turn-tracking sets so + * the computation itself stays pure and side-effect-free. + */ +export interface SessionStatusInput { + readonly awaitingApproval: boolean; + readonly awaitingQuestion: boolean; + readonly hasActivePrompt: boolean; + readonly hasActiveTurn: boolean; + readonly wasAborted: boolean; +} + +/** + * Compute the session lifecycle status from live daemon state. + * + * Priority (mirrors the original `SessionService._computeStatus`): + * 1. awaiting_approval — pending approvals exist + * 2. awaiting_question — pending questions exist + * 3. running — active prompt or active turn + * 4. aborted — last turn ended as cancelled/failed and no new work started + * 5. idle — everything else + * + * This helper is shared by `SessionService` (command path) and + * `SessionQueryService` (read path) so the status derivation is defined in + * exactly one place. It does not touch live agents and never resumes one. + */ +export function computeSessionStatus(input: SessionStatusInput): SessionStatus { + if (input.awaitingApproval) { + return 'awaiting_approval'; + } + if (input.awaitingQuestion) { + return 'awaiting_question'; + } + if (input.hasActivePrompt || input.hasActiveTurn) { + return 'running'; + } + if (input.wasAborted) { + return 'aborted'; + } + return 'idle'; +} + +/** + * In-memory turn-tracking state used to feed `computeSessionStatus`'s + * `hasActiveTurn` / `wasAborted` inputs. Both `SessionService` and + * `SessionQueryService` keep their own copy, driven by the same event-bus + * events via `applySessionTurnEvent`, so the live status they derive stays + * consistent without either reaching into the other's privates. + */ +export interface SessionTurnState { + readonly activeTurns: Set; + readonly abortedTurns: Set; +} + +/** + * Fold a single event-bus event into the turn-tracking sets. Only the events + * that move `activeTurns` / `abortedTurns` are handled; everything else is a + * no-op. Status-*change* emission stays with `SessionService` — the query + * path calls this purely to keep its read-model status in sync. + */ +export function applySessionTurnEvent(state: SessionTurnState, event: Event): void { + const type = (event as { type?: string }).type; + const sessionId = (event as { sessionId?: string }).sessionId; + if (sessionId === undefined || sessionId === '' || type === undefined) return; + + switch (type) { + case 'turn.started': { + state.activeTurns.add(sessionId); + state.abortedTurns.delete(sessionId); + break; + } + case 'turn.ended': { + state.activeTurns.delete(sessionId); + const reason = (event as { reason?: string }).reason; + if (reason === 'cancelled' || reason === 'failed') { + state.abortedTurns.add(sessionId); + } else { + state.abortedTurns.delete(sessionId); + } + break; + } + case 'prompt.submitted': { + state.abortedTurns.delete(sessionId); + break; + } + } +} + +/** + * Best-effort read of `SessionMeta` for a session. Returns `undefined` when + * the metadata file is missing or unreadable so callers can fall back to the + * summary row. Shared by both session services; a cold read that never + * resumes an agent. + */ +export async function tryGetSessionMeta( + core: ICoreRuntime, + id: string, +): Promise { + try { + return await (core as unknown as InProcessCoreApi) + .getCoreApi() + .getSessionMetadata({ sessionId: id }); + } catch { + return undefined; + } +} diff --git a/packages/agent-core/src/session/store/index.ts b/packages/agent-core/src/session/store/index.ts index 54bba0465..96c88cc11 100644 --- a/packages/agent-core/src/session/store/index.ts +++ b/packages/agent-core/src/session/store/index.ts @@ -1,8 +1,9 @@ -export { SessionStore } from '#/session/store/session-store'; +export { SessionStore, SessionStoreService } from './session-store'; export type { CreateSessionRecordInput, ForkSessionRecordInput, + ISessionStoreService, SessionStoreOptions, -} from '#/session/store/session-store'; -export { sessionIndexPath } from '#/session/store/session-index'; -export { encodeWorkDirKey, normalizeWorkDir } from '#/session/store/workdir-key'; +} from './session-store'; +export { sessionIndexPath } from './session-index'; +export { encodeWorkDirKey, normalizeWorkDir } from './workdir-key'; diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index dfa012c0d..93d871270 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -4,11 +4,12 @@ import { dirname, isAbsolute, join, relative } from 'pathe'; import { z } from 'zod'; import { ErrorCodes, KimiError } from '#/errors'; -import type { SessionIndexEntry } from '#/session/store/session-index'; -import { appendSessionIndexEntry, readSessionIndex } from '#/session/store/session-index'; -import { encodeWorkDirKey, normalizeWorkDir } from '#/session/store/workdir-key'; +import type { SessionIndexEntry } from './session-index'; +import { appendSessionIndexEntry, readSessionIndex } from './session-index'; +import { encodeWorkDirKey, normalizeWorkDir } from './workdir-key'; import type { JsonObject, ListSessionsPayload, SessionSummary } from '#/rpc/core-api'; import { FileSystemAgentRecordPersistence, type AgentRecordOf } from '../../agent/records'; +import { createDecorator } from '../../_base/di'; const SessionSummaryStateSchema = z.object({ archived: z.boolean().optional(), @@ -478,6 +479,21 @@ function remapSessionPath(value: string, sourceDir: string, targetDir: string): return join(targetDir, rel); } +export interface ISessionStoreService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw store; do not use in new code. */ + unwrap(): SessionStore; +} + +export const ISessionStoreService = createDecorator('sessionStoreService'); + +export class SessionStoreService extends SessionStore implements ISessionStoreService { + readonly _serviceBrand: undefined; + unwrap(): SessionStore { + return this; + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/agent-core/src/session/store/workdir-key.ts b/packages/agent-core/src/session/store/workdir-key.ts index 7b1c7f801..aa83f4117 100644 --- a/packages/agent-core/src/session/store/workdir-key.ts +++ b/packages/agent-core/src/session/store/workdir-key.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { basename, resolve } from 'pathe'; -import { slugifyWorkDirName } from '#/utils/workdir-slug'; +import { slugifyWorkDirName } from '#/_utils/slug'; const WORKDIR_KEY_PREFIX = 'wd_'; const HASH_LENGTH = 12; diff --git a/packages/agent-core/src/session/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts index 9146fa41b..bb1290816 100644 --- a/packages/agent-core/src/session/subagent-batch.ts +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -6,7 +6,7 @@ import type { SpawnSubagentOptions, SubagentHandle, } from './subagent-host'; -import { isUserCancellation } from '../utils/abort'; +import { isUserCancellation } from '#/_utils/abort'; /* Subagent batch scheduling contract: diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b47e1cd68..c93098782 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -18,8 +18,9 @@ import { import { linkAbortSignal, userCancellationReason, -} from '../utils/abort'; +} from '#/_utils/abort'; import { collectGitContext } from './git-context'; +import { createDecorator } from '../_base/di'; import type { Session } from './index'; import { SubagentBatch, @@ -224,8 +225,8 @@ export class SessionSubagentHost { thinkingLevel: parent.config.thinkingLevel, systemPrompt: parent.config.systemPrompt, }); - child.tools.copyLoopToolsFrom(parent.tools); - child.context.useProjectedHistoryFrom(parent.context); + child.tools.copyLoopToolsFrom(parent.tools.unwrap()); + child.context.useProjectedHistoryFrom(parent.context.unwrap()); child.context.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), { kind: 'system_trigger', name: 'btw', @@ -367,7 +368,7 @@ export class SessionSubagentHost { this.session.options.kimiHomeDir, ); child.useProfile(profile, context); - child.tools.inheritUserTools(parent.tools); + child.tools.inheritUserTools(parent.tools.unwrap()); } private async triggerSubagentStart( @@ -493,6 +494,21 @@ function lastAssistantText(agent: Agent): string { return ''; } +export interface ISubagentHostService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw host; do not use in new code. */ + unwrap(): SessionSubagentHost; +} + +export const ISubagentHostService = createDecorator('subagentHostService'); + +export class SubagentHostService extends SessionSubagentHost implements ISubagentHostService { + readonly _serviceBrand: undefined; + unwrap(): SessionSubagentHost { + return this; + } +} + function shouldSuppressQueuedAttemptFailureEvent( options: RunSubagentOptions, error: unknown, diff --git a/packages/agent-core/src/skill/builtin/index.ts b/packages/agent-core/src/skill/builtin/index.ts index e73204c06..51fe82a55 100644 --- a/packages/agent-core/src/skill/builtin/index.ts +++ b/packages/agent-core/src/skill/builtin/index.ts @@ -1,4 +1,4 @@ -import type { SessionSkillRegistry } from '../registry'; +import type { ISkillRegistryService } from '../registry'; import { CUSTOM_THEME_SKILL } from './custom-theme'; import { IMPORT_FROM_CC_CODEX_SKILL } from './import-from-cc-codex'; import { MCP_CONFIG_SKILL } from './mcp-config'; @@ -9,7 +9,7 @@ import { } from './sub-skill'; import { UPDATE_CONFIG_SKILL } from './update-config'; -export function registerBuiltinSkills(registry: SessionSkillRegistry): void { +export function registerBuiltinSkills(registry: ISkillRegistryService): void { registry.registerBuiltinSkill(MCP_CONFIG_SKILL); registry.registerBuiltinSkill(IMPORT_FROM_CC_CODEX_SKILL); registry.registerBuiltinSkill(UPDATE_CONFIG_SKILL); diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts index 23d281c9d..9e54c2711 100644 --- a/packages/agent-core/src/skill/parser.ts +++ b/packages/agent-core/src/skill/parser.ts @@ -6,7 +6,7 @@ import regexpEscape from 'regexp.escape'; import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; import { isSupportedSkillType } from './types'; -import { escapeXmlTags } from '../utils/xml-escape'; +import { escapeXmlTags } from '#/_utils/xml'; export class FrontmatterError extends Error { constructor(message: string, cause?: unknown) { diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index c3052491a..e280ada64 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -3,7 +3,8 @@ import { discoverSkills, type DiscoverSkillsOptions } from './scanner'; import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types'; import { isInlineSkillType, normalizeSkillName } from './types'; import type { SkillRegistry as AgentSkillRegistry } from '../agent/skill/types'; -import { escapeXmlAttr } from '../utils/xml-escape'; +import { createDecorator } from '../_base/di'; +import { escapeXmlAttr } from '#/_utils/xml'; const LISTING_DESC_MAX = 250; @@ -143,6 +144,21 @@ export class SessionSkillRegistry implements AgentSkillRegistry { } } +export interface ISkillRegistryService extends Pick { + readonly _serviceBrand: undefined; + /** @internal migration bridge — reach the raw registry; do not use in new code. */ + unwrap(): SessionSkillRegistry; +} + +export const ISkillRegistryService = createDecorator('skillRegistryService'); + +export class SkillRegistryService extends SessionSkillRegistry implements ISkillRegistryService { + readonly _serviceBrand: undefined; + unwrap(): SessionSkillRegistry { + return this; + } +} + function pluginSkillKey(pluginId: string, skillName: string): string { return `${pluginId}\0${normalizeSkillName(skillName)}`; } diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 3e6205d6e..7d4c2bc40 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -5,7 +5,7 @@ import type { BuiltinTool } from '../../../agent/tool'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, type QueuedSubagentTask, - type SessionSubagentHost, + type ISubagentHostService, } from '../../../session/subagent-host'; import { ToolAccesses } from '../../../loop/tool-access'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; @@ -89,7 +89,7 @@ export class AgentSwarmTool implements BuiltinTool { readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); constructor( - private readonly subagentHost: SessionSubagentHost, + private readonly subagentHost: ISubagentHostService, private readonly swarmMode: SwarmMode, ) {} diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 29de3ab7d..de640f2fa 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -19,7 +19,7 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; -import type { Logger } from '../../../logging'; +import type { Logger } from '../../../_base/logging'; import { ToolAccesses } from '../../../loop/tool-access'; import { isAbortError } from '../../../loop/errors'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; @@ -27,14 +27,14 @@ import type { ResolvedAgentProfile } from '../../../profile'; import { DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION, DEFAULT_SUBAGENT_TIMEOUT_MS, - type SessionSubagentHost, + type ISubagentHostService, type SubagentHandle, } from '../../../session/subagent-host'; import { createDeadlineAbortSignal, isUserCancellation, type DeadlineAbortSignal, -} from '../../../utils/abort'; +} from '#/_utils/abort'; import { AgentBackgroundTask, type BackgroundManager } from '../../../agent/background'; import { toInputJsonSchema } from '../../support/input-schema'; import { matchesGlobRuleSubject } from '../../support/rule-match'; @@ -112,7 +112,7 @@ export class AgentTool implements BuiltinTool { readonly description: string; readonly parameters: Record = toInputJsonSchema(AgentToolInputSchema); constructor( - private readonly subagentHost: SessionSubagentHost, + private readonly subagentHost: ISubagentHostService, private readonly backgroundManager?: BackgroundManager | undefined, subagents?: ResolvedAgentProfile['subagents'] | undefined, options?: { diff --git a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts index 2ee932dd0..3539ab16a 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -21,7 +21,7 @@ import { renderModelToolSkillPrompt } from '../../../agent/skill/prompt'; import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; import { isInlineSkillType, type SkillDefinition } from '../../../skill'; -import { renderPrompt } from '../../../utils/render-prompt'; +import { renderPrompt } from '#/_utils/template'; import { toInputJsonSchema } from '../../support/input-schema'; import { matchesGlobRuleSubject } from '../../support/rule-match'; import skillDescriptionTemplate from './skill-tool.md?raw'; diff --git a/packages/agent-core/src/tools/builtin/file/read-media.ts b/packages/agent-core/src/tools/builtin/file/read-media.ts index f21886974..4531787e6 100644 --- a/packages/agent-core/src/tools/builtin/file/read-media.ts +++ b/packages/agent-core/src/tools/builtin/file/read-media.ts @@ -27,7 +27,7 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; import { ToolAccesses } from '../../../loop/tool-access'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; -import { renderPrompt } from '../../../utils/render-prompt'; +import { renderPrompt } from '#/_utils/template'; import { resolvePathAccessPath } from '../../policies/path-access'; import { MEDIA_SNIFF_BYTES, detectFileType, sniffImageDimensions } from '../../support/file-type'; import { toInputJsonSchema } from '../../support/input-schema'; diff --git a/packages/agent-core/src/tools/builtin/file/read.ts b/packages/agent-core/src/tools/builtin/file/read.ts index 252eef3af..3f08a608c 100644 --- a/packages/agent-core/src/tools/builtin/file/read.ts +++ b/packages/agent-core/src/tools/builtin/file/read.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; import { ToolAccesses } from '../../../loop/tool-access'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; -import { renderPrompt } from '../../../utils/render-prompt'; +import { renderPrompt } from '#/_utils/template'; import { resolvePathAccessPath } from '../../policies/path-access'; import { MEDIA_SNIFF_BYTES, detectFileType } from '../../support/file-type'; import { toInputJsonSchema } from '../../support/input-schema'; diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 642ed5c08..5e6e07704 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -32,7 +32,7 @@ import { z } from 'zod'; import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../loop/types'; -import { renderPrompt } from '../../../utils/render-prompt'; +import { renderPrompt } from '#/_utils/template'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; import { ToolResultBuilder } from '../../support/result-builder'; diff --git a/packages/agent-core/src/tools/cron/persist.ts b/packages/agent-core/src/tools/cron/persist.ts index 0421a5697..d5c6fbaef 100644 --- a/packages/agent-core/src/tools/cron/persist.ts +++ b/packages/agent-core/src/tools/cron/persist.ts @@ -17,7 +17,7 @@ * to boot. */ -import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store'; +import { createPerIdJsonStore, type PerIdJsonStore } from '#/_utils/persistence'; import type { CronTask } from './types'; /** diff --git a/packages/agent-core/src/tools/support/rg-locator.ts b/packages/agent-core/src/tools/support/rg-locator.ts index 88ae3610f..a2cdc4565 100644 --- a/packages/agent-core/src/tools/support/rg-locator.ts +++ b/packages/agent-core/src/tools/support/rg-locator.ts @@ -23,7 +23,7 @@ import { pipeline } from 'node:stream/promises'; import { extract as extractTar } from 'tar'; import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; -import { abortable } from '../../utils/abort'; +import { abortable } from '#/_utils/abort'; const RG_VERSION = '15.0.0'; const RG_BASE_URL = 'https://code.kimi.com/kimi-code/rg'; diff --git a/packages/agent-core/test/agent/agent-slim.test.ts b/packages/agent-core/test/agent/agent-slim.test.ts new file mode 100644 index 000000000..2caceed57 --- /dev/null +++ b/packages/agent-core/test/agent/agent-slim.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { ResolvedAgentProfile } from '../../src/profile'; +import type { AgentEvent } from '../../src/rpc'; +import { testAgent } from './harness/agent'; + +describe('Agent slim handle', () => { + it('exposes the expected service handles as a stable aggregate', () => { + const { agent } = testAgent(); + + // Core service handles are present on the public surface. + expect(agent.turn).toBeDefined(); + expect(agent.config).toBeDefined(); + expect(agent.context).toBeDefined(); + expect(agent.permission).toBeDefined(); + expect(agent.tools).toBeDefined(); + expect(agent.records).toBeDefined(); + expect(agent.usage).toBeDefined(); + expect(agent.eventBus).toBeDefined(); + expect(agent.lifecycle).toBeDefined(); + expect(agent.statusService).toBeDefined(); + expect(agent.rpcController).toBeDefined(); + expect(agent.resumeService).toBeDefined(); + expect(agent.profileService).toBeDefined(); + + // The handle is a stable aggregate: repeated access returns the same instance. + expect(agent.turn).toBe(agent.turn); + expect(agent.config).toBe(agent.config); + expect(agent.eventBus).toBe(agent.eventBus); + expect(agent.lifecycle).toBe(agent.lifecycle); + }); + + it('exposes the read-only identity fields', () => { + const { agent } = testAgent(); + + expect(agent.type).toBe('main'); + expect(agent.id).toBeUndefined(); + expect(agent.kaos).toBeDefined(); + }); + + it('forwards the generate / llm / rpcMethods getters to the owning services', () => { + const { agent } = testAgent(); + + // `llmService.generate` is a function-valued getter, which vitest's typed + // `spyOn(..., 'get')` overload does not accept. Redefine the getter on the + // instance to hand back a sentinel, then assert the Agent getter forwards it. + const sentinelGenerate = (() => + undefined) as unknown as typeof agent.llmService.generate; + Object.defineProperty(agent.llmService, 'generate', { + configurable: true, + get: () => sentinelGenerate, + }); + expect(agent.generate).toBe(sentinelGenerate); + + // `llm` and `rpcMethods` are object-valued getters, so intercept the getter + // and hand back a sentinel to prove the Agent getter forwards to the service. + const sentinelLlm = { sentinel: 'llm' } as unknown as typeof agent.llmService.llm; + const sentinelRpcMethods = { + prompt: () => undefined, + } as unknown as typeof agent.rpcController.rpcMethods; + + vi.spyOn(agent.llmService, 'llm', 'get').mockReturnValue(sentinelLlm); + vi.spyOn(agent.rpcController, 'rpcMethods', 'get').mockReturnValue(sentinelRpcMethods); + + expect(agent.llm).toBe(sentinelLlm); + expect(agent.rpcMethods).toBe(sentinelRpcMethods); + }); + + it('delegates resume() and useProfile() to the resume and profile services', async () => { + const { agent } = testAgent(); + + const resumeSpy = vi + .spyOn(agent.resumeService, 'resume') + .mockResolvedValue({ warning: 'heads-up' }); + const profileSpy = vi.spyOn(agent.profileService, 'useProfile').mockImplementation(() => {}); + + const result = await agent.resume({ rewriteMigratedRecords: false }); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(resumeSpy).toHaveBeenCalledWith({ rewriteMigratedRecords: false }); + expect(result).toEqual({ warning: 'heads-up' }); + + const profile: ResolvedAgentProfile = { + name: 'tester', + tools: ['Bash', 'Read'], + systemPrompt: () => 'PROMPT', + }; + const context = { cwdListing: 'LISTING', agentsMd: 'AGENTS' }; + + agent.useProfile(profile, context); + + expect(profileSpy).toHaveBeenCalledTimes(1); + expect(profileSpy).toHaveBeenCalledWith(profile, context); + }); + + it('delegates emitEvent() to the domain event bus', () => { + const { agent } = testAgent(); + + const publishSpy = vi.spyOn(agent.eventBus, 'publish').mockImplementation(() => {}); + + const event: AgentEvent = { type: 'warning', message: 'be careful' }; + agent.emitEvent(event); + + expect(publishSpy).toHaveBeenCalledTimes(1); + expect(publishSpy).toHaveBeenCalledWith(event); + }); +}); diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index d0ab4062b..c76e99566 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -19,7 +19,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AgentOptions } from '../../../src/agent'; import { DefaultCompactionStrategy, type CompactionStrategy } from '../../../src/agent/compaction'; import { FLAG_DEFINITIONS, MASTER_ENV } from '../../../src/flags'; -import { HookEngine, type HookEngineTriggerArgs } from '../../../src/session/hooks'; +import { HookService, type HookEngineTriggerArgs, type IHookService } from '../../../src/session/hooks'; import { estimateTokensForMessages } from '../../../src/utils/tokens'; import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; import type { TestAgentContext, TestAgentOptions } from '../harness/agent'; @@ -396,7 +396,7 @@ describe('FullCompaction', () => { const hookLog = join(dir, 'hooks.jsonl'); const hookCommand = hookPayloadLoggerCommand(hookLog); const ctx = testAgent({ - hookEngine: new HookEngine( + hookEngine: new HookService( [ { event: 'PreCompact', matcher: 'auto', command: hookCommand, timeout: 5 }, { event: 'PostCompact', matcher: 'auto', command: hookCommand, timeout: 5 }, @@ -455,7 +455,7 @@ describe('FullCompaction', () => { }); return []; }); - const ctx = testAgent({ hookEngine: { trigger } as unknown as HookEngine }); + const ctx = testAgent({ hookEngine: { trigger } as unknown as IHookService }); ctx.configure({ provider: CATALOGUED_PROVIDER, diff --git a/packages/agent-core/test/agent/cron/agent-integration.test.ts b/packages/agent-core/test/agent/cron/agent-integration.test.ts index 76105409d..ee8a67d36 100644 --- a/packages/agent-core/test/agent/cron/agent-integration.test.ts +++ b/packages/agent-core/test/agent/cron/agent-integration.test.ts @@ -58,7 +58,7 @@ describe('Agent + Cron integration (P1.7)', () => { // killswitch lives in `resolveExecution`, so a direct call is the // precise unit being asserted, and it stays robust if the loop / // dispatch surface changes around it (P1.8 onwards). - const tool = new CronCreateTool(ctx.agent.cron!); + const tool = new CronCreateTool(ctx.agent.cron!.unwrap()); const args: CronCreateInput = { cron: '*/5 * * * *', prompt: 'x', diff --git a/packages/agent-core/test/agent/cron/cron.e2e.test.ts b/packages/agent-core/test/agent/cron/cron.e2e.test.ts index 4b2602342..35f15442d 100644 --- a/packages/agent-core/test/agent/cron/cron.e2e.test.ts +++ b/packages/agent-core/test/agent/cron/cron.e2e.test.ts @@ -8,7 +8,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { CronManager } from '../../../src/agent/cron'; +import { CronService } from '../../../src/agent/cron'; import { CronCreateTool } from '../../../src/tools/cron/cron-create'; import { CronDeleteTool } from '../../../src/tools/cron/cron-delete'; import { CronListTool } from '../../../src/tools/cron/cron-list'; @@ -62,7 +62,7 @@ describe('Cron — session E2E (P1.9)', () => { // only legitimate exception. await ctx.agent.cron!.stop(); const harness = createClocks(LOCAL_ANCHOR_MS); - (ctx.agent as unknown as { cron: CronManager }).cron = new CronManager( + (ctx.agent as unknown as { cron: CronService }).cron = new CronService( ctx.agent, { clocks: harness.clocks, @@ -96,7 +96,7 @@ describe('Cron — session E2E (P1.9)', () => { // bypass `emitScheduled` telemetry and skip the byte-length / // expression checks; that would not be the production code path // this commit is meant to smoke. - const createTool = new CronCreateTool(ctx.agent.cron!); + const createTool = new CronCreateTool(ctx.agent.cron!.unwrap()); const execution = createTool.resolveExecution({ cron: '*/5 * * * *', prompt: 'cron-fired prompt', @@ -149,9 +149,9 @@ describe('Cron — session E2E (P1.9)', () => { // Optional second case from the P1.9 plan: prove the three-tool // surface composes correctly end-to-end on the real manager. No // clock manipulation needed — list/delete are time-invariant. - const createTool = new CronCreateTool(ctx.agent.cron!); - const listTool = new CronListTool(ctx.agent.cron!); - const deleteTool = new CronDeleteTool(ctx.agent.cron!); + const createTool = new CronCreateTool(ctx.agent.cron!.unwrap()); + const listTool = new CronListTool(ctx.agent.cron!.unwrap()); + const deleteTool = new CronDeleteTool(ctx.agent.cron!.unwrap()); const ctxArgs = { turnId: 'p19-tools', toolCallId: 'p19-tools-call', diff --git a/packages/agent-core/test/agent/factory.test.ts b/packages/agent-core/test/agent/factory.test.ts new file mode 100644 index 000000000..d8b8c59cf --- /dev/null +++ b/packages/agent-core/test/agent/factory.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +import { AgentFactory } from '../../src/agent/factory'; +import type { Agent, AgentOptions, AgentType } from '../../src/agent/index'; +import { ICronService } from '../../src/agent/cron'; +import { IGoalService } from '../../src/agent/goal'; +import { ILifecycleService } from '../../src/agent/lifecycle'; +import { IRecordsService } from '../../src/agent/records'; +import { IAgentSkillService } from '../../src/agent/skill'; +import type { SkillRegistry } from '../../src/agent/skill/types'; +import { ITurnService } from '../../src/agent/turn'; +import { IDomainEventBus } from '#/event'; +import { noopTelemetryClient } from '../../src/telemetry'; + +function makeStubAgent(overrides: { type?: AgentType } = {}): Agent { + return { + type: overrides.type ?? 'main', + kaos: {} as Kaos, + homedir: undefined, + telemetry: noopTelemetryClient, + } as unknown as Agent; +} + +function makeOptions(overrides: Partial = {}): AgentOptions { + return { kaos: {} as Kaos, ...overrides }; +} + +describe('AgentFactory.buildServiceCollection', () => { + it('registers the core per-agent services', () => { + const services = AgentFactory.buildServiceCollection( + makeStubAgent(), + makeOptions(), + undefined, + undefined, + ); + + expect(services.has(IRecordsService)).toBe(true); + expect(services.has(ITurnService)).toBe(true); + expect(services.has(IGoalService)).toBe(true); + expect(services.has(IDomainEventBus)).toBe(true); + expect(services.has(ILifecycleService)).toBe(true); + }); + + it('registers IAgentSkillService only when options.skills is provided', () => { + const withoutSkills = AgentFactory.buildServiceCollection( + makeStubAgent(), + makeOptions(), + undefined, + undefined, + ); + expect(withoutSkills.has(IAgentSkillService)).toBe(false); + + const skills = {} as unknown as SkillRegistry; + const withSkills = AgentFactory.buildServiceCollection( + makeStubAgent(), + makeOptions({ skills }), + undefined, + undefined, + ); + expect(withSkills.has(IAgentSkillService)).toBe(true); + }); + + it('registers ICronService for non-sub agents only', () => { + const mainAgentServices = AgentFactory.buildServiceCollection( + makeStubAgent({ type: 'main' }), + makeOptions(), + undefined, + undefined, + ); + expect(mainAgentServices.has(ICronService)).toBe(true); + + const subAgentServices = AgentFactory.buildServiceCollection( + makeStubAgent({ type: 'sub' }), + makeOptions(), + undefined, + undefined, + ); + expect(subAgentServices.has(ICronService)).toBe(false); + }); +}); diff --git a/packages/agent-core/test/agent/goal.test.ts b/packages/agent-core/test/agent/goal.test.ts index 408c5de7c..38a152939 100644 --- a/packages/agent-core/test/agent/goal.test.ts +++ b/packages/agent-core/test/agent/goal.test.ts @@ -49,7 +49,7 @@ function makeGoalMode() { } as unknown as Agent; return { - goals: new GoalMode(agent), + goals: new GoalMode(agent.telemetry, (e) => agent.emitEvent(e), agent.records, agent.replayBuilder, agent.context), records, replay, events, diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index e2f08b813..7266e49df 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -13,7 +13,7 @@ import { type AgentRecordPersistence, } from '../../../src/agent'; import type { CompactionStrategy } from '../../../src/agent/compaction'; -import type { GoalMode } from '../../../src/agent/goal'; +import type { IGoalService } from '../../../src/agent/goal'; import type { ApprovalResponse } from '../../../src/agent/permission'; import { AGENT_WIRE_PROTOCOL_VERSION, @@ -21,13 +21,13 @@ import { } from '../../../src/agent/records'; import type { KimiConfig } from '../../../src/config'; import type { ExecutableToolResult } from '../../../src/loop'; -import type { Logger } from '../../../src/logging'; +import type { Logger } from '#/_base/logging'; import { ProviderManager } from '../../../src/session/provider-manager'; import type { QuestionResult, RPCCallOptions, SDKAgentRPC } from '../../../src/rpc'; import type { AgentAPI } from '../../../src/rpc/core-api'; import type { ToolServices } from '../../../src/tools/support/services'; import type { TelemetryClient } from '../../../src/telemetry'; -import type { PromisifyMethods } from '../../../src/utils/types'; +import type { PromisifyMethods } from '#/_utils/types'; import { createFakeKaos } from '../../tools/fixtures/fake-kaos'; import { testKaos } from '../../fixtures/test-kaos'; @@ -97,7 +97,7 @@ export interface TestAgentOptions { readonly hookEngine?: AgentOptions['hookEngine']; readonly type?: AgentOptions['type']; readonly permission?: AgentOptions['permission']; - readonly goal?: GoalMode; + readonly goal?: IGoalService; readonly providerManager?: ProviderManager; readonly initialConfig?: KimiConfig; readonly providerManagerOverrides?: Omit[0], 'config'>; @@ -199,7 +199,7 @@ export class AgentTestContext { experimentalFlags: options.experimentalFlags, }); if (options.goal !== undefined) { - (this.agent as unknown as { goal: GoalMode }).goal = options.goal; + (this.agent as unknown as { goal: IGoalService }).goal = options.goal; } this.rpc = this.createPromiseAgentApi(this.agent); // The Agent constructor now eagerly binds a SIGUSR1 listener via diff --git a/packages/agent-core/test/agent/injection/goal.test.ts b/packages/agent-core/test/agent/injection/goal.test.ts index 53e0c924c..e8aab0094 100644 --- a/packages/agent-core/test/agent/injection/goal.test.ts +++ b/packages/agent-core/test/agent/injection/goal.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Agent } from '../../../src/agent'; -import { GoalMode } from '../../../src/agent/goal'; +import { GoalService, type IGoalService } from '../../../src/agent/goal'; import { GoalInjector } from '../../../src/agent/injection/goal'; import { InMemoryAgentRecordPersistence } from '../../../src/agent/records'; import { testAgent } from '../harness/agent'; @@ -12,11 +12,11 @@ function makeStore() { emitEvent: () => {}, telemetry: { track: () => {} }, } as unknown as Agent; - return new GoalMode(agent); + return new GoalService(agent.telemetry, (e) => agent.emitEvent(e), agent.records, agent.replayBuilder, agent.context); } /** Fake agent exposing a goal store and a capturing context, for getInjection tests. */ -function injectorAgent(store: GoalMode): { +function injectorAgent(store: IGoalService): { agent: Agent; reminders: string[]; } { @@ -36,7 +36,7 @@ function injectorAgent(store: GoalMode): { return { agent, reminders }; } -async function injectOnce(store: GoalMode): Promise { +async function injectOnce(store: IGoalService): Promise { const { agent, reminders } = injectorAgent(store); await new GoalInjector(agent).inject(); return reminders.at(-1); diff --git a/packages/agent-core/test/agent/injection/manager.test.ts b/packages/agent-core/test/agent/injection/manager.test.ts index a8a91ea93..714743b3e 100644 --- a/packages/agent-core/test/agent/injection/manager.test.ts +++ b/packages/agent-core/test/agent/injection/manager.test.ts @@ -47,7 +47,7 @@ describe('InjectionManager.onContextCompacted', () => { ctx.configure(); const a = new RecordingInjector(ctx.agent); const b = new RecordingInjector(ctx.agent); - installInjectors(ctx.agent.injection, [a, b]); + installInjectors(ctx.agent.injection.unwrap(), [a, b]); ctx.agent.injection.onContextCompacted(3); @@ -59,7 +59,7 @@ describe('InjectionManager.onContextCompacted', () => { const ctx = testAgent(); ctx.configure(); const recorder = new RecordingInjector(ctx.agent); - installInjectors(ctx.agent.injection, [new BoomInjector(ctx.agent), recorder]); + installInjectors(ctx.agent.injection.unwrap(), [new BoomInjector(ctx.agent), recorder]); expect(() => { ctx.agent.injection.onContextCompacted(2); @@ -71,7 +71,7 @@ describe('InjectionManager.onContextCompacted', () => { const ctx = testAgent(); ctx.configure(); const recorder = new RecordingInjector(ctx.agent); - installInjectors(ctx.agent.injection, [new BoomInjector(ctx.agent), recorder]); + installInjectors(ctx.agent.injection.unwrap(), [new BoomInjector(ctx.agent), recorder]); expect(() => { ctx.agent.injection.onContextCompacted(1); @@ -86,7 +86,7 @@ describe('InjectionManager.onContextCompacted', () => { const ctx = testAgent(); ctx.configure(); const recorder = new RecordingInjector(ctx.agent); - installInjectors(ctx.agent.injection, [recorder]); + installInjectors(ctx.agent.injection.unwrap(), [recorder]); ctx.agent.records.restore({ type: 'context.clear' }); ctx.agent.records.restore({ diff --git a/packages/agent-core/test/agent/lifecycle.test.ts b/packages/agent-core/test/agent/lifecycle.test.ts new file mode 100644 index 000000000..3d16be8bf --- /dev/null +++ b/packages/agent-core/test/agent/lifecycle.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LifecycleService, type PromptCtx, type TurnHookCtx } from '#/agent/lifecycle'; +import type { IDisposable } from '#/_base/di'; + +type Handler = (ctx: Ctx) => void | Promise; + +/** + * Exercises the three guarantees every lifecycle hook must honor: + * 1. a registered handler is invoked with the fired ctx; + * 2. `fireXxx` awaits async handlers (sequential, like `fireBeforePrompt`); + * 3. a disposed subscription no longer receives fires. + * + * Each check disposes its own subscription before the next so the shared + * handler set is clean between them. The ctx is passed as a plain object + * literal at every call site; for `TurnHookCtx` (whose `turnId` is optional) + * the call fixes the type parameter explicitly so the literal does not + * narrow it to `{ turnId: number }`. + */ +async function expectHookContract( + register: (handler: Handler) => IDisposable, + fire: (ctx: Ctx) => Promise, + ctx: Ctx, +): Promise { + const handler = vi.fn(); + const subscription = register(handler); + await fire(ctx); + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith(ctx); + subscription.dispose(); + + let asyncResolved = false; + const asyncSubscription = register(async () => { + await Promise.resolve(); + asyncResolved = true; + }); + await fire(ctx); + expect(asyncResolved).toBe(true); + asyncSubscription.dispose(); + + const disposedHandler = vi.fn(); + const disposedSubscription = register(disposedHandler); + disposedSubscription.dispose(); + await fire(ctx); + expect(disposedHandler).not.toHaveBeenCalled(); +} + +describe('LifecycleService', () => { + it('onBeforePrompt / fireBeforePrompt still works (regression guard)', async () => { + const lifecycle = new LifecycleService(); + const handler = vi.fn(); + const subscription = lifecycle.onBeforePrompt(handler); + const ctx: PromptCtx = { injectSystemReminder: () => {} }; + + await lifecycle.fireBeforePrompt(ctx); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith(ctx); + subscription.dispose(); + }); + + describe('session hooks', () => { + it('onSessionWillStart / fireSessionWillStart', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onSessionWillStart(h), + (ctx) => lifecycle.fireSessionWillStart(ctx), + { sessionId: 'session-1' }, + ); + }); + + it('onSessionDidStart / fireSessionDidStart', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onSessionDidStart(h), + (ctx) => lifecycle.fireSessionDidStart(ctx), + { sessionId: 'session-1' }, + ); + }); + + it('onSessionWillClose / fireSessionWillClose', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onSessionWillClose(h), + (ctx) => lifecycle.fireSessionWillClose(ctx), + { sessionId: 'session-1' }, + ); + }); + + it('onSessionDidClose / fireSessionDidClose', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onSessionDidClose(h), + (ctx) => lifecycle.fireSessionDidClose(ctx), + { sessionId: 'session-1' }, + ); + }); + }); + + describe('agent hooks', () => { + it('onAgentWillCreate / fireAgentWillCreate', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onAgentWillCreate(h), + (ctx) => lifecycle.fireAgentWillCreate(ctx), + { agentId: 'agent-1' }, + ); + }); + + it('onAgentDidCreate / fireAgentDidCreate', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onAgentDidCreate(h), + (ctx) => lifecycle.fireAgentDidCreate(ctx), + { agentId: 'agent-1' }, + ); + }); + + it('onAgentWillResume / fireAgentWillResume', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onAgentWillResume(h), + (ctx) => lifecycle.fireAgentWillResume(ctx), + { agentId: 'agent-1' }, + ); + }); + + it('onAgentDidResume / fireAgentDidResume', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onAgentDidResume(h), + (ctx) => lifecycle.fireAgentDidResume(ctx), + { agentId: 'agent-1' }, + ); + }); + + it('onAgentWillDispose / fireAgentWillDispose', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onAgentWillDispose(h), + (ctx) => lifecycle.fireAgentWillDispose(ctx), + { agentId: 'agent-1' }, + ); + }); + }); + + describe('turn hooks', () => { + it('onTurnWillStart / fireTurnWillStart', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onTurnWillStart(h), + (ctx) => lifecycle.fireTurnWillStart(ctx), + { turnId: 1 }, + ); + }); + + it('onTurnDidStart / fireTurnDidStart', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onTurnDidStart(h), + (ctx) => lifecycle.fireTurnDidStart(ctx), + { turnId: 1 }, + ); + }); + + it('onTurnDidEnd / fireTurnDidEnd', async () => { + const lifecycle = new LifecycleService(); + await expectHookContract( + (h) => lifecycle.onTurnDidEnd(h), + (ctx) => lifecycle.fireTurnDidEnd(ctx), + { turnId: 1 }, + ); + }); + }); +}); diff --git a/packages/agent-core/test/agent/llm.test.ts b/packages/agent-core/test/agent/llm.test.ts new file mode 100644 index 000000000..7ed67a4e5 --- /dev/null +++ b/packages/agent-core/test/agent/llm.test.ts @@ -0,0 +1,190 @@ +import { + emptyUsage, + UNKNOWN_CAPABILITY, + type ChatProvider, + type GenerateResult, + type Message, + type ModelCapability, + type ProviderRequestAuth, +} from '@moonshot-ai/kosong'; +import { describe, expect, it, vi, type Mock } from 'vitest'; + +import { LlmService, type LlmServiceConfig, type LlmServiceDeps } from '../../src/agent/llm'; +import { LlmRequestLogger } from '../../src/agent/llm-request-logger'; +import { KosongLLM } from '../../src/agent/turn/kosong-llm'; +import type { KimiConfig } from '../../src/config'; +import type { Logger } from '#/_base/logging'; +import type { ModelProvider } from '../../src/session/provider-manager'; + +interface TestDeps extends LlmServiceDeps { + readonly rawGenerate: Mock; +} + +function createLogger(): Logger { + const logger: Logger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + createChild: () => logger, + }; + return logger; +} + +function makeProvider(modelName = 'test-model'): ChatProvider { + return { + name: 'test', + modelName, + thinkingEffort: null, + generate: vi.fn(), + withThinking() { + return this; + }, + }; +} + +function makeConfig(overrides: Partial = {}): LlmServiceConfig { + return { + modelAlias: undefined, + provider: makeProvider(), + maxOutputSize: undefined, + systemPrompt: 'system prompt', + modelCapabilities: UNKNOWN_CAPABILITY, + ...overrides, + }; +} + +function makeGenerateResult(): GenerateResult { + return { + id: 'resp-1', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; +} + +function makeDeps(overrides: Partial = {}): TestDeps { + const logger = createLogger(); + return { + config: makeConfig(), + llmRequestLogger: new LlmRequestLogger(logger), + rawGenerate: vi.fn().mockResolvedValue( + makeGenerateResult(), + ), + modelProvider: undefined, + log: logger, + kimiConfig: undefined, + ...overrides, + } as TestDeps; +} + +describe('LlmService.generate', () => { + it('wraps each call with llmRequestLogger.logRequest before invoking rawGenerate', async () => { + const deps = makeDeps(); + const logRequest = vi.spyOn(deps.llmRequestLogger, 'logRequest'); + const service = new LlmService(deps); + const provider = makeProvider('model-a'); + const history: Message[] = []; + + await service.generate(provider, 'sys', [], history, undefined, undefined); + + expect(logRequest).toHaveBeenCalledTimes(1); + expect(logRequest).toHaveBeenCalledWith({ + provider, + modelAlias: undefined, + systemPrompt: 'sys', + tools: [], + messages: history, + fields: undefined, + }); + expect(deps.rawGenerate).toHaveBeenCalledTimes(1); + expect(deps.rawGenerate).toHaveBeenCalledWith(provider, 'sys', [], history, undefined, undefined); + }); + + it('resolves request-scoped auth via modelProvider.resolveAuth when modelAlias is set and no auth in options', async () => { + const injectedAuth: ProviderRequestAuth = { apiKey: 'resolved-token' }; + const authorizedRequest = (request: (auth: ProviderRequestAuth) => Promise): Promise => + request(injectedAuth); + const resolveAuth = vi.fn>().mockReturnValue( + authorizedRequest, + ); + const modelProvider: ModelProvider = { + resolveProviderConfig: vi.fn(), + resolveAuth, + }; + const deps = makeDeps({ + config: makeConfig({ modelAlias: 'kimi-code' }), + modelProvider, + }); + const service = new LlmService(deps); + const provider = makeProvider(); + + await service.generate(provider, 'sys', [], [], undefined, {}); + + expect(resolveAuth).toHaveBeenCalledTimes(1); + expect(resolveAuth).toHaveBeenCalledWith('kimi-code', { log: deps.log }); + expect(deps.rawGenerate).toHaveBeenCalledTimes(1); + const options = deps.rawGenerate.mock.calls[0]?.[5]; + expect(options).toMatchObject({ auth: injectedAuth }); + }); + + it('skips auth resolution when options already carry auth', async () => { + const resolveAuth = vi.fn>(); + const modelProvider: ModelProvider = { + resolveProviderConfig: vi.fn(), + resolveAuth, + }; + const deps = makeDeps({ + config: makeConfig({ modelAlias: 'kimi-code' }), + modelProvider, + }); + const service = new LlmService(deps); + const explicitAuth: ProviderRequestAuth = { apiKey: 'caller-supplied' }; + + await service.generate(makeProvider(), 'sys', [], [], undefined, { auth: explicitAuth }); + + expect(resolveAuth).not.toHaveBeenCalled(); + const options = deps.rawGenerate.mock.calls[0]?.[5]; + expect(options).toMatchObject({ auth: explicitAuth }); + }); +}); + +describe('LlmService.llm', () => { + it('constructs a KosongLLM with the resolved provider, system prompt, and capability', () => { + const capability: ModelCapability = { + image_in: true, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 200_000, + }; + const provider = makeProvider('kimi-for-coding'); + const kimiConfig: KimiConfig = { + providers: {}, + loopControl: { reservedContextSize: 4096 }, + }; + const deps = makeDeps({ + config: makeConfig({ + provider, + systemPrompt: 'you are helpful', + modelCapabilities: capability, + maxOutputSize: 8192, + }), + kimiConfig, + }); + const service = new LlmService(deps); + + const llm = service.llm; + + expect(llm).toBeInstanceOf(KosongLLM); + expect(llm.systemPrompt).toBe('you are helpful'); + expect(llm.modelName).toBe('kimi-for-coding'); + expect(llm.capability).toBe(capability); + }); +}); diff --git a/packages/agent-core/test/agent/profile.test.ts b/packages/agent-core/test/agent/profile.test.ts new file mode 100644 index 000000000..2f3229ebc --- /dev/null +++ b/packages/agent-core/test/agent/profile.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +import { AgentProfileService, type AgentProfileHost } from '../../src/agent/profile'; +import type { IAgentConfigService } from '../../src/agent/config'; +import type { IAgentSkillService } from '../../src/agent/skill'; +import type { IAgentToolService } from '../../src/agent/tool'; +import type { + PreparedSystemPromptContext, + ResolvedAgentProfile, + SystemPromptRenderer, +} from '../../src/profile'; + +const FAKE_OS_ENV = { os: 'linux', shell: 'bash' }; +const FAKE_REGISTRY = { kind: 'fake-skill-registry' }; + +interface ProfileServiceHarness { + readonly service: AgentProfileService; + readonly profile: ResolvedAgentProfile & { systemPrompt: ReturnType> }; + readonly config: { readonly cwd: string; readonly update: ReturnType }; + readonly tools: { readonly setActiveTools: ReturnType }; + readonly kaos: { readonly osEnv: typeof FAKE_OS_ENV }; +} + +function makeProfileServiceHost(options: { withSkills?: boolean } = {}): ProfileServiceHarness { + const systemPrompt = vi.fn().mockReturnValue('RENDERED_PROMPT'); + const profile = { + name: 'tester', + tools: ['Bash', 'Read'], + systemPrompt, + } satisfies ResolvedAgentProfile; + + const config = { + cwd: '/work', + update: vi.fn(), + }; + const tools = { + setActiveTools: vi.fn(), + }; + const skills = options.withSkills ? { registry: FAKE_REGISTRY } : null; + const kaos = { osEnv: FAKE_OS_ENV }; + + const host: AgentProfileHost = { + kaos: kaos as unknown as Kaos, + config: config as unknown as IAgentConfigService, + skills: skills as unknown as IAgentSkillService | null, + tools: tools as unknown as IAgentToolService, + }; + + return { + service: new AgentProfileService(host), + profile, + config, + tools, + kaos, + }; +} + +describe('AgentProfileService', () => { + it('calls config.update with { profileName, systemPrompt } and tools.setActiveTools with profile.tools', () => { + const harness = makeProfileServiceHost(); + + harness.service.useProfile(harness.profile); + + expect(harness.config.update).toHaveBeenCalledTimes(1); + expect(harness.config.update).toHaveBeenCalledWith({ + profileName: 'tester', + systemPrompt: 'RENDERED_PROMPT', + }); + expect(harness.tools.setActiveTools).toHaveBeenCalledTimes(1); + expect(harness.tools.setActiveTools).toHaveBeenCalledWith(['Bash', 'Read']); + }); + + it('builds the system prompt from the profile, host runtime, and prepared context', () => { + const harness = makeProfileServiceHost({ withSkills: true }); + const context: PreparedSystemPromptContext = { + cwdListing: 'LISTING', + agentsMd: 'AGENTS', + }; + + harness.service.useProfile(harness.profile, context); + + expect(harness.profile.systemPrompt).toHaveBeenCalledTimes(1); + expect(harness.profile.systemPrompt).toHaveBeenCalledWith({ + osEnv: FAKE_OS_ENV, + cwd: '/work', + skills: FAKE_REGISTRY, + cwdListing: 'LISTING', + agentsMd: 'AGENTS', + }); + }); + + it('passes undefined skills / cwdListing / agentsMd when the host has no skills and no context is supplied', () => { + const harness = makeProfileServiceHost(); + + harness.service.useProfile(harness.profile); + + expect(harness.profile.systemPrompt).toHaveBeenCalledWith({ + osEnv: FAKE_OS_ENV, + cwd: '/work', + skills: undefined, + cwdListing: undefined, + agentsMd: undefined, + }); + }); +}); diff --git a/packages/agent-core/test/agent/records.test.ts b/packages/agent-core/test/agent/records.test.ts new file mode 100644 index 000000000..fa58f45a3 --- /dev/null +++ b/packages/agent-core/test/agent/records.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { type AgentRecord } from '../../src/agent/records'; +import { ErrorCodes } from '../../src/errors'; +import type { ErrorEvent } from '../../src/rpc'; +import { testAgent } from './harness/agent'; + +describe('RecordsService.emitWriteError', () => { + it('publishes a records-write-error event with the expected payload', () => { + const { agent } = testAgent(); + + const events: ErrorEvent[] = []; + const subscription = agent.eventBus.subscribe('error', (event) => { + events.push(event); + }); + + const record = { + type: 'turn.prompt', + input: [{ type: 'text', text: 'hello' }], + origin: { kind: 'user' }, + } as unknown as AgentRecord; + + agent.records.emitWriteError(new Error('disk full'), record); + + subscription.dispose(); + + expect(events).toHaveLength(1); + const event = events[0]; + expect(event?.type).toBe('error'); + expect(event?.code).toBe(ErrorCodes.RECORDS_WRITE_FAILED); + expect(event?.message).toBe('Failed to write agent records: disk full'); + expect(event?.details).toEqual({ recordType: 'turn.prompt' }); + }); + + it('stringifies non-Error values and omits recordType when no record is given', () => { + const { agent } = testAgent(); + + const events: ErrorEvent[] = []; + const subscription = agent.eventBus.subscribe('error', (event) => { + events.push(event); + }); + + agent.records.emitWriteError('plain failure'); + + subscription.dispose(); + + expect(events).toHaveLength(1); + const event = events[0]; + expect(event?.type).toBe('error'); + expect(event?.code).toBe(ErrorCodes.RECORDS_WRITE_FAILED); + expect(event?.message).toBe('Failed to write agent records: plain failure'); + expect(event?.details).toEqual({ recordType: undefined }); + }); +}); diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index ee963e413..fa99dde1b 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -9,10 +9,20 @@ import { AGENT_WIRE_PROTOCOL_VERSION, InMemoryAgentRecordPersistence, } from '../../src/agent/records'; -import { BackgroundTaskPersistence } from '../../src/agent/background'; +import { BackgroundTaskPersistence, type IBackgroundService } from '../../src/agent/background'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { testAgent } from './harness/agent'; import { DEFAULT_TEST_SYSTEM_PROMPT } from './harness/snapshots'; +import { AgentResumeService, type AgentResumeHost } from '../../src/agent/resume'; +import type { IContextService } from '../../src/agent/context'; +import type { ICronService } from '../../src/agent/cron'; +import type { IGoalService } from '../../src/agent/goal'; +import type { AgentHookCtx, ILifecycleService } from '../../src/agent/lifecycle'; +import type { AgentRecordsReplayOptions, IRecordsService } from '../../src/agent/records'; +import type { IReplayService } from '../../src/agent/replay'; +import type { ITurnService } from '../../src/agent/turn'; + +type AgentHookHandler = (ctx: AgentHookCtx) => void | Promise; const MOCK_PROVIDER = { type: 'kimi', @@ -1198,3 +1208,212 @@ function findRpcEvent( ) { return ctxEvents.find((entry) => entry.type === '[rpc]' && entry.event === event); } + +interface ResumeServiceHarness { + readonly host: AgentResumeHost; + readonly service: AgentResumeService; + readonly order: string[]; + readonly replayBuilder: { postRestoring: boolean }; + readonly records: { readonly replay: ReturnType }; + readonly lifecycle: { + readonly fireAgentWillResume: ReturnType; + readonly fireAgentDidResume: ReturnType; + }; + readonly postRestoringDuringStages: boolean | undefined; +} + +function makeResumeServiceHost( + options: { readonly id?: string; readonly failAt?: string; readonly replayResult?: { warning?: string } } = {}, +): ResumeServiceHarness { + const order: string[] = []; + const replayBuilder = { postRestoring: false }; + let postRestoringDuringStages: boolean | undefined; + const maybeFail = (label: string): void => { + if (options.failAt === label) { + throw new Error(`boom:${label}`); + } + }; + + const records = { + replay: vi.fn(async (_options?: AgentRecordsReplayOptions) => { + order.push('records.replay'); + maybeFail('records.replay'); + return options.replayResult ?? {}; + }), + }; + let willResumeHandler: AgentHookHandler | undefined; + const lifecycle = { + onAgentWillResume: vi.fn((handler: AgentHookHandler) => { + willResumeHandler = handler; + return { dispose: () => { willResumeHandler = undefined; } }; + }), + onAgentDidResume: vi.fn((_handler: AgentHookHandler) => { + return { dispose: () => {} }; + }), + fireAgentWillResume: vi.fn(async (ctx: AgentHookCtx) => { + order.push('lifecycle.fireAgentWillResume'); + await willResumeHandler?.(ctx); + }), + fireAgentDidResume: vi.fn(async () => { + order.push('lifecycle.fireAgentDidResume'); + }), + }; + const goal = { + normalizeAfterReplay: vi.fn(() => { + // Capture the replay-builder flag as the first restoration stage runs, so + // the test can prove `postRestoring` is true for the whole stage window. + postRestoringDuringStages = replayBuilder.postRestoring; + order.push('goal.normalizeAfterReplay'); + maybeFail('goal.normalizeAfterReplay'); + }), + }; + const background = { + loadFromDisk: vi.fn(async () => { + order.push('background.loadFromDisk'); + maybeFail('background.loadFromDisk'); + }), + reconcile: vi.fn(async () => { + order.push('background.reconcile'); + maybeFail('background.reconcile'); + }), + }; + const cron = { + loadFromDisk: vi.fn(async () => { + order.push('cron.loadFromDisk'); + maybeFail('cron.loadFromDisk'); + }), + }; + const context = { + finishResume: vi.fn(() => { + order.push('context.finishResume'); + maybeFail('context.finishResume'); + }), + }; + const turn = { + finishResume: vi.fn(() => { + order.push('turn.finishResume'); + maybeFail('turn.finishResume'); + }), + }; + + const host: AgentResumeHost = { + id: options.id, + lifecycle: lifecycle as unknown as ILifecycleService, + records: records as unknown as IRecordsService, + replayBuilder: replayBuilder as unknown as IReplayService, + goal: goal as unknown as IGoalService, + background: background as unknown as IBackgroundService, + cron: cron as unknown as ICronService, + context: context as unknown as IContextService, + turn: turn as unknown as ITurnService, + }; + + return { + host, + service: new AgentResumeService(host), + order, + replayBuilder, + records, + lifecycle, + get postRestoringDuringStages() { + return postRestoringDuringStages; + }, + }; +} + +describe('AgentResumeService', () => { + it('runs the resume stages in order: records → goal → background → cron → context → turn, bracketed by lifecycle hooks', async () => { + const harness = makeResumeServiceHost({ id: 'agent-1' }); + const options: AgentRecordsReplayOptions = { rewriteMigratedRecords: true }; + + const result = await harness.service.resume(options); + + expect(result).toEqual({}); + expect(harness.records.replay).toHaveBeenCalledWith(options); + expect(harness.order).toEqual([ + 'lifecycle.fireAgentWillResume', + 'records.replay', + 'goal.normalizeAfterReplay', + 'background.loadFromDisk', + 'background.reconcile', + 'cron.loadFromDisk', + 'context.finishResume', + 'turn.finishResume', + 'lifecycle.fireAgentDidResume', + ]); + expect(harness.lifecycle.fireAgentWillResume).toHaveBeenCalledWith({ agentId: 'agent-1' }); + expect(harness.lifecycle.fireAgentDidResume).toHaveBeenCalledWith({ agentId: 'agent-1' }); + }); + + it('holds replayBuilder.postRestoring true across the restoration stages and resets it after', async () => { + const harness = makeResumeServiceHost({ id: 'agent-1b' }); + + await harness.service.resume(); + + expect(harness.postRestoringDuringStages).toBe(true); + expect(harness.replayBuilder.postRestoring).toBe(false); + }); + + it('fires fireAgentWillResume before replay and fireAgentDidResume after all stages', async () => { + const harness = makeResumeServiceHost({ id: 'agent-2' }); + + await harness.service.resume(); + + const willIndex = harness.order.indexOf('lifecycle.fireAgentWillResume'); + const replayIndex = harness.order.indexOf('records.replay'); + const turnIndex = harness.order.indexOf('turn.finishResume'); + const didIndex = harness.order.indexOf('lifecycle.fireAgentDidResume'); + + expect(willIndex).toBe(0); + expect(willIndex).toBeLessThan(replayIndex); + expect(replayIndex).toBeLessThan(turnIndex); + expect(turnIndex).toBeLessThan(didIndex); + expect(harness.lifecycle.fireAgentWillResume).toHaveBeenCalledTimes(1); + expect(harness.lifecycle.fireAgentDidResume).toHaveBeenCalledTimes(1); + }); + + it('skips the lifecycle hooks when the agent has no id', async () => { + const harness = makeResumeServiceHost(); + + await harness.service.resume(); + + expect(harness.lifecycle.fireAgentWillResume).not.toHaveBeenCalled(); + expect(harness.lifecycle.fireAgentDidResume).not.toHaveBeenCalled(); + expect(harness.order).toEqual([ + 'records.replay', + 'goal.normalizeAfterReplay', + 'background.loadFromDisk', + 'background.reconcile', + 'cron.loadFromDisk', + 'context.finishResume', + 'turn.finishResume', + ]); + }); + + it('forwards the replay result (including warning) to the caller', async () => { + const warning = 'Session wire protocol version 99 is newer than the current version 1.'; + const harness = makeResumeServiceHost({ id: 'agent-3', replayResult: { warning } }); + + const result = await harness.service.resume(); + + expect(result).toEqual({ warning }); + }); + + it('resets replayBuilder.postRestoring in finally even when a stage throws', async () => { + const harness = makeResumeServiceHost({ id: 'agent-4', failAt: 'background.reconcile' }); + + await expect(harness.service.resume()).rejects.toThrow('boom:background.reconcile'); + + expect(harness.postRestoringDuringStages).toBe(true); + expect(harness.replayBuilder.postRestoring).toBe(false); + // The failing stage ran, but stages after it and the DidResume hook did not. + expect(harness.order).toEqual([ + 'lifecycle.fireAgentWillResume', + 'records.replay', + 'goal.normalizeAfterReplay', + 'background.loadFromDisk', + 'background.reconcile', + ]); + expect(harness.lifecycle.fireAgentDidResume).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-core/test/agent/rpc-controller.test.ts b/packages/agent-core/test/agent/rpc-controller.test.ts new file mode 100644 index 000000000..84dc6fc12 --- /dev/null +++ b/packages/agent-core/test/agent/rpc-controller.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AgentRpcController, type AgentRpcHost } from '../../src/agent/rpc-controller'; +import type { IBackgroundService } from '../../src/agent/background'; +import type { ICompactionService } from '../../src/agent/compaction'; +import type { IAgentConfigService } from '../../src/agent/config'; +import type { IContextService } from '../../src/agent/context'; +import type { IGoalService } from '../../src/agent/goal'; +import type { IPermissionService } from '../../src/agent/permission'; +import type { IPlanService } from '../../src/agent/plan'; +import type { IAgentSkillService } from '../../src/agent/skill'; +import type { ISwarmService } from '../../src/agent/swarm'; +import type { IAgentToolService } from '../../src/agent/tool/index'; +import type { ITurnService } from '../../src/agent/turn'; +import type { IUsageService } from '../../src/agent/usage'; +import type { ModelProvider } from '../../src/session/provider-manager'; +import type { ISubagentHostService } from '../../src/session/subagent-host'; +import type { TelemetryClient, TelemetryProperties } from '../../src/telemetry'; + +interface TelemetryCall { + readonly event: string; + readonly properties?: TelemetryProperties; +} + +interface HostHarness { + readonly host: AgentRpcHost; + readonly controller: AgentRpcController; + readonly telemetryCalls: TelemetryCall[]; + readonly turn: { + readonly prompt: ReturnType; + readonly steer: ReturnType; + readonly cancel: ReturnType; + }; + readonly config: { + readonly update: ReturnType; + thinkingLevel: string; + modelAlias: string | undefined; + }; + readonly permission: { + readonly setMode: ReturnType; + mode: string; + }; + readonly modelProvider: { + readonly resolveProviderConfig: ReturnType; + }; + readonly fullCompaction: { + readonly begin: ReturnType; + readonly cancel: ReturnType; + isCompacting: boolean; + }; + readonly skills: { readonly activate: ReturnType } | null; +} + +function makeHost( + options: { readonly skills?: 'present' | 'null' } = {}, +): HostHarness { + const telemetryCalls: TelemetryCall[] = []; + const telemetry: TelemetryClient = { + track: (event, properties) => { + telemetryCalls.push({ event, properties }); + }, + }; + + const turn = { + prompt: vi.fn(), + steer: vi.fn(), + cancel: vi.fn(), + hasActiveTurn: false, + }; + const config = { + thinkingLevel: 'off', + modelAlias: undefined as string | undefined, + update: vi.fn((patch: { thinkingLevel?: string; modelAlias?: string }) => { + if (patch.thinkingLevel !== undefined) config.thinkingLevel = patch.thinkingLevel; + if (patch.modelAlias !== undefined) config.modelAlias = patch.modelAlias; + }), + data: vi.fn(), + }; + const permission = { + mode: 'manual', + setMode: vi.fn((mode: string) => { + permission.mode = mode; + }), + data: vi.fn(), + }; + const modelProvider = { + resolveProviderConfig: vi.fn(() => ({ providerName: 'test-provider' })), + }; + const fullCompaction = { + begin: vi.fn(), + cancel: vi.fn(), + isCompacting: false, + }; + const skills = + options.skills === 'null' ? null : { activate: vi.fn() }; + + const host: AgentRpcHost = { + turn: turn as unknown as ITurnService, + telemetry, + context: { + undo: vi.fn(), + clear: vi.fn(), + data: vi.fn(), + } as unknown as IContextService, + config: config as unknown as IAgentConfigService, + permission: permission as unknown as IPermissionService, + modelProvider: modelProvider as unknown as ModelProvider, + planMode: { + enter: vi.fn(async () => {}), + cancel: vi.fn(), + clear: vi.fn(), + data: vi.fn(), + } as unknown as IPlanService, + swarmMode: { + enter: vi.fn(), + exit: vi.fn(), + isActive: false, + } as unknown as ISwarmService, + fullCompaction: fullCompaction as unknown as ICompactionService, + tools: { + registerUserTool: vi.fn(), + unregisterUserTool: vi.fn(), + setActiveTools: vi.fn(), + data: vi.fn(), + } as unknown as IAgentToolService, + background: { + stop: vi.fn(async () => {}), + readOutput: vi.fn(), + list: vi.fn(), + } as unknown as IBackgroundService, + skills: skills as unknown as IAgentSkillService | null, + subagentHost: { startBtw: vi.fn() } as unknown as ISubagentHostService, + goal: { + createGoal: vi.fn(), + getGoal: vi.fn(), + pauseGoal: vi.fn(), + resumeGoal: vi.fn(), + cancelGoal: vi.fn(), + } as unknown as IGoalService, + usage: { data: vi.fn() } as unknown as IUsageService, + }; + + return { + host, + controller: new AgentRpcController(host), + telemetryCalls, + turn: { prompt: turn.prompt, steer: turn.steer, cancel: turn.cancel }, + config, + permission, + modelProvider, + fullCompaction, + skills, + }; +} + +describe('AgentRpcController', () => { + it('delegates prompt / steer / cancel to the turn service with the right arguments', () => { + const { controller, turn, telemetryCalls } = makeHost(); + const methods = controller.rpcMethods; + + methods.prompt({ input: [{ type: 'text', text: 'hi' }] } as never); + methods.steer({ input: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } as never); + methods.cancel({ turnId: 7 } as never); + + expect(turn.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hi' }]); + expect(turn.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'a' }, + { type: 'text', text: 'b' }, + ]); + expect(turn.cancel).toHaveBeenCalledWith(7); + // steer always tracks input_steer with the number of parts; cancel does not + // track when there is no active turn. + expect(telemetryCalls).toEqual([{ event: 'input_steer', properties: { parts: 2 } }]); + }); + + it('tracks cancel streaming telemetry only when a turn is active', () => { + const harness = makeHost(); + const activeMethods = harness.controller.rpcMethods; + (harness.host.turn as { hasActiveTurn: boolean }).hasActiveTurn = true; + activeMethods.cancel({ turnId: 1 } as never); + + const idleHarness = makeHost(); + idleHarness.controller.rpcMethods.cancel({ turnId: 2 } as never); + + expect(harness.telemetryCalls).toEqual([ + { event: 'cancel', properties: { from: 'streaming' } }, + ]); + expect(idleHarness.telemetryCalls).toEqual([]); + }); + + it('tracks thinking_toggle only when the effective enabled state changes', () => { + const harness = makeHost(); + const methods = harness.controller.rpcMethods; + + // off -> on: should fire with enabled=true + methods.setThinking({ level: 'low' } as never); + expect(harness.config.update).toHaveBeenCalledWith({ thinkingLevel: 'low' }); + expect(harness.telemetryCalls).toEqual([ + { event: 'thinking_toggle', properties: { enabled: true } }, + ]); + + // low -> high: still enabled both before and after, no extra event + harness.telemetryCalls.length = 0; + methods.setThinking({ level: 'high' } as never); + expect(harness.telemetryCalls).toEqual([]); + + // high -> off: enabled flips to false + methods.setThinking({ level: 'off' } as never); + expect(harness.telemetryCalls).toEqual([ + { event: 'thinking_toggle', properties: { enabled: false } }, + ]); + }); + + it('tracks yolo_toggle / afk_toggle based on permission transitions', () => { + const harness = makeHost(); + const methods = harness.controller.rpcMethods; + + methods.setPermission({ mode: 'yolo' } as never); + expect(harness.telemetryCalls).toEqual([ + { event: 'yolo_toggle', properties: { enabled: true } }, + ]); + + harness.telemetryCalls.length = 0; + methods.setPermission({ mode: 'auto' } as never); + expect(harness.telemetryCalls).toEqual([ + { event: 'yolo_toggle', properties: { enabled: false } }, + { event: 'afk_toggle', properties: { enabled: true } }, + ]); + }); + + it('setModel resolves the provider, updates config, and tracks model_switch on change', () => { + const harness = makeHost(); + harness.config.modelAlias = 'old-model'; + const methods = harness.controller.rpcMethods; + + const result = methods.setModel({ model: 'new-model' } as never); + + expect(harness.modelProvider.resolveProviderConfig).toHaveBeenCalledWith('new-model'); + expect(harness.config.update).toHaveBeenCalledWith({ modelAlias: 'new-model' }); + expect(harness.telemetryCalls).toEqual([ + { event: 'model_switch', properties: { model: 'new-model' } }, + ]); + expect(result).toEqual({ model: 'new-model', providerName: 'test-provider' }); + }); + + it('setModel skips config update + telemetry when the alias is unchanged', () => { + const harness = makeHost(); + harness.config.modelAlias = 'same-model'; + const methods = harness.controller.rpcMethods; + + methods.setModel({ model: 'same-model' } as never); + + expect(harness.modelProvider.resolveProviderConfig).toHaveBeenCalledWith('same-model'); + expect(harness.config.update).not.toHaveBeenCalled(); + expect(harness.telemetryCalls).toEqual([]); + }); + + it('beginCompaction forwards source=manual; cancelCompaction tracks only when compacting', () => { + const harness = makeHost(); + const methods = harness.controller.rpcMethods; + + methods.beginCompaction({ instruction: 'shrink' } as never); + expect(harness.fullCompaction.begin).toHaveBeenCalledWith({ + source: 'manual', + instruction: 'shrink', + }); + + methods.cancelCompaction(undefined as never); + expect(harness.fullCompaction.cancel).toHaveBeenCalledTimes(1); + expect(harness.telemetryCalls).toEqual([]); + + harness.fullCompaction.isCompacting = true; + methods.cancelCompaction(undefined as never); + expect(harness.telemetryCalls).toEqual([ + { event: 'cancel', properties: { from: 'compacting' } }, + ]); + }); + + it('activateSkill throws KimiError when skills are unavailable, otherwise activates', () => { + const nullSkills = makeHost({ skills: 'null' }); + expect(() => + nullSkills.controller.rpcMethods.activateSkill({ name: 'missing' } as never), + ).toThrowError(/Skill "missing" was not found/); + + const present = makeHost(); + present.controller.rpcMethods.activateSkill({ name: 'present' } as never); + expect(present.skills?.activate).toHaveBeenCalledWith({ name: 'present' }); + }); +}); diff --git a/packages/agent-core/test/agent/skill-tool-manager.test.ts b/packages/agent-core/test/agent/skill-tool-manager.test.ts index 51b42a74e..a4b40677f 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -8,7 +8,7 @@ import { Agent, type AgentRecord } from '../../src/agent'; import { testKaos } from '../fixtures/test-kaos'; import { InMemoryAgentRecordPersistence } from '../../src/agent/records'; import type { AgentRecordPersistence } from '../../src/agent/records'; -import { ProviderManager } from '../../src/session/provider-manager'; +import { ProviderService, type IProviderService } from '../../src/session/provider-manager'; import type { ApprovalResponse, SDKAgentRPC, SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { SessionSkillRegistry, type SkillDefinition } from '../../src/skill'; @@ -76,8 +76,8 @@ function sessionRpc(): SDKSessionRPC { } as unknown as SDKSessionRPC; } -function testProviderManager(): ProviderManager { - return new ProviderManager({ +function testProviderManager() : IProviderService { + return new ProviderService({ config: { providers: { test: { diff --git a/packages/agent-core/test/agent/status-projection.test.ts b/packages/agent-core/test/agent/status-projection.test.ts new file mode 100644 index 000000000..322c854a1 --- /dev/null +++ b/packages/agent-core/test/agent/status-projection.test.ts @@ -0,0 +1,273 @@ +import { UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import type { Event as ProtocolEvent, SessionStatus } from '@moonshot-ai/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { Emitter, IInstantiationService } from '../../src'; +import type { IAgentConfigService } from '../../src/agent/config'; +import type { IContextService } from '../../src/agent/context'; +import type { IPermissionService, PermissionMode } from '../../src/agent/permission'; +import type { IPlanService } from '../../src/agent/plan'; +import type { IRecordsService } from '../../src/agent/records'; +import { AgentStatusService, type AgentStatusHost } from '../../src/agent/status'; +import type { ISwarmService } from '../../src/agent/swarm'; +import type { IUsageService } from '../../src/agent/usage'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IApprovalService } from '#/approval'; +import type { IDomainEventBus, IEventService } from '#/event'; +import { IQuestionService } from '#/question'; +import type { AgentEvent, UsageStatus } from '../../src/rpc'; +import { IPromptService } from '#/prompt'; +import { type ICoreRuntime } from '#/coreProcess'; +import { SessionRuntimeService } from '#/session'; + +// ─── status projection model ──────────────────────────────────────────────── +// +// Two projections publish status events. Both are driven from the outside: +// neither service reaches back into `Agent` through a callback closure. +// +// 1. `agent.status.updated` — service-driven. +// Status-driving services (plan / swarm / usage) call +// `IAgentStatusService.notifyStatusChanged()` after mutating state. The +// service reads its inputs lazily from an injected `AgentStatusHost` and +// publishes the event on the domain event bus. +// +// 2. `session.status_changed` — event-driven. +// `SessionRuntimeService` subscribes to `IEventService.onDidPublish`. When a +// relevant bus event arrives it recomputes the per-session status, fires its +// local `onDidChangeStatus`, and republishes `event.session.status_changed`. +// +// This file pins both trigger chains so the projection model stays event / +// service driven and no "service calls Agent" status callback creeps back in. + +// ─── agent.status.updated helpers ─────────────────────────────────────────── + +interface HostOverrides { + readonly restoring?: boolean; + readonly hasModel?: boolean; + readonly model?: string; + readonly contextTokens?: number; + readonly maxContextTokens?: number | undefined; + readonly usage?: UsageStatus | undefined; + readonly planMode?: boolean; + readonly swarmMode?: boolean; + readonly permission?: PermissionMode; +} + +function makeHost(overrides: HostOverrides = {}): { + host: AgentStatusHost; + events: AgentEvent[]; +} { + const events: AgentEvent[] = []; + const modelCapabilities = { + ...UNKNOWN_CAPABILITY, + max_context_tokens: overrides.maxContextTokens, + }; + const restoring = overrides.restoring ?? false; + const host: AgentStatusHost = { + records: { restoring: restoring ? { time: 0 } : null } as unknown as IRecordsService, + config: { + hasModel: overrides.hasModel ?? true, + model: overrides.model ?? 'test-model', + modelCapabilities, + } as unknown as IAgentConfigService, + context: { + tokenCount: overrides.contextTokens ?? 0, + } as unknown as IContextService, + usage: { + status: () => overrides.usage, + } as unknown as IUsageService, + planMode: { isActive: overrides.planMode ?? false } as unknown as IPlanService, + swarmMode: { isActive: overrides.swarmMode ?? false } as unknown as ISwarmService, + permission: { mode: overrides.permission ?? 'manual' } as unknown as IPermissionService, + eventBus: { + publish: (event: AgentEvent) => { + events.push(event); + }, + } as unknown as IDomainEventBus, + }; + return { host, events }; +} + +// ─── session.status_changed helpers ───────────────────────────────────────── + +function makeEventServiceStub(): { + eventService: IEventService; + published: ProtocolEvent[]; +} { + const published: ProtocolEvent[] = []; + const emitter = new Emitter(); + return { + published, + eventService: { + _serviceBrand: undefined, + publish: vi.fn((event: ProtocolEvent) => { + published.push(event); + emitter.fire(event); + }) as IEventService['publish'], + onDidPublish: emitter.event, + }, + }; +} + +function makeRuntimeService(): { + svc: SessionRuntimeService; + eventBus: ReturnType; + dispose: () => void; +} { + const eventBus = makeEventServiceStub(); + const promptService: IPromptService = { + _serviceBrand: undefined, + getCurrentPromptId: vi.fn().mockReturnValue(undefined), + } as unknown as IPromptService; + const approvalService: IApprovalService = { + _serviceBrand: undefined, + listPending: vi.fn().mockReturnValue([]), + } as unknown as IApprovalService; + const questionService: IQuestionService = { + _serviceBrand: undefined, + listPending: vi.fn().mockReturnValue([]), + } as unknown as IQuestionService; + + const instantiation = new TestInstantiationService(undefined, true); + instantiation.stub(IInstantiationService, instantiation); + instantiation.stub(IPromptService, promptService); + + const core = { _serviceBrand: undefined } as unknown as ICoreRuntime; + + const svc = new SessionRuntimeService( + core, + eventBus.eventService, + instantiation, + approvalService, + questionService, + ); + return { + svc, + eventBus, + dispose: () => { + svc.dispose(); + instantiation.dispose(); + }, + }; +} + +function statusEvent( + overrides: { type: string; sessionId: string; reason?: string }, +): ProtocolEvent { + return overrides as unknown as ProtocolEvent; +} + +// ─── agent.status.updated chain ───────────────────────────────────────────── + +describe('agent.status.updated projection (service-driven)', () => { + it('publishes when a status-driving service notifies a plan-mode change', () => { + const { host, events } = makeHost({ planMode: true }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + expect(events).toHaveLength(1); + const event = events[0]; + expect(event?.type).toBe('agent.status.updated'); + if (event?.type !== 'agent.status.updated') throw new Error('unexpected event type'); + expect(event.planMode).toBe(true); + }); + + it('publishes when a status-driving service notifies a swarm-mode change', () => { + const { host, events } = makeHost({ swarmMode: true }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + const event = events[0]; + expect(event?.type).toBe('agent.status.updated'); + if (event?.type !== 'agent.status.updated') throw new Error('unexpected event type'); + expect(event.swarmMode).toBe(true); + }); + + it('keeps the restoring gate so no event leaks mid-restore', () => { + const { host, events } = makeHost({ restoring: true, planMode: true }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + expect(events).toHaveLength(0); + }); +}); + +// ─── session.status_changed chain ─────────────────────────────────────────── + +describe('session.status_changed projection (event-driven)', () => { + it('fires onDidChangeStatus and republishes when a turn.started bus event arrives', () => { + const { svc, eventBus, dispose } = makeRuntimeService(); + try { + const listener = vi.fn(); + svc.onDidChangeStatus(listener); + + eventBus.eventService.publish(statusEvent({ type: 'turn.started', sessionId: 's' })); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 's', + status: 'running' satisfies SessionStatus, + previousStatus: 'idle' satisfies SessionStatus, + }), + ); + const republished = eventBus.published.find( + (e) => (e as { type?: string }).type === 'event.session.status_changed', + ); + expect(republished).toMatchObject({ + type: 'event.session.status_changed', + sessionId: 's', + status: 'running', + previous_status: 'idle', + }); + } finally { + dispose(); + } + }); + + it('transitions running -> idle when turn.ended arrives with a success reason', () => { + const { svc, eventBus, dispose } = makeRuntimeService(); + try { + svc.onDidChangeStatus(vi.fn()); + eventBus.eventService.publish(statusEvent({ type: 'turn.started', sessionId: 's' })); + + const listener = vi.fn(); + svc.onDidChangeStatus(listener); + eventBus.eventService.publish( + statusEvent({ type: 'turn.ended', sessionId: 's', reason: 'success' }), + ); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 's', + status: 'idle' satisfies SessionStatus, + previousStatus: 'running' satisfies SessionStatus, + }), + ); + } finally { + dispose(); + } + }); + + it('does not fire when the incoming bus event leaves the status unchanged', () => { + const { svc, eventBus, dispose } = makeRuntimeService(); + try { + const listener = vi.fn(); + svc.onDidChangeStatus(listener); + + eventBus.eventService.publish(statusEvent({ type: 'prompt.completed', sessionId: 's' })); + + expect(listener).not.toHaveBeenCalled(); + const republished = eventBus.published.find( + (e) => (e as { type?: string }).type === 'event.session.status_changed', + ); + expect(republished).toBeUndefined(); + } finally { + dispose(); + } + }); +}); diff --git a/packages/agent-core/test/agent/status.test.ts b/packages/agent-core/test/agent/status.test.ts new file mode 100644 index 000000000..0adb41b9e --- /dev/null +++ b/packages/agent-core/test/agent/status.test.ts @@ -0,0 +1,130 @@ +import { UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import { describe, expect, it } from 'vitest'; + +import { AgentStatusService, type AgentStatusHost } from '../../src/agent/status'; +import type { IAgentConfigService } from '../../src/agent/config'; +import type { IContextService } from '../../src/agent/context'; +import type { IDomainEventBus } from '#/event'; +import type { IPermissionService, PermissionMode } from '../../src/agent/permission'; +import type { IPlanService } from '../../src/agent/plan'; +import type { IRecordsService } from '../../src/agent/records'; +import type { ISwarmService } from '../../src/agent/swarm'; +import type { IUsageService } from '../../src/agent/usage'; +import type { AgentEvent, UsageStatus } from '../../src/rpc'; + +interface HostOverrides { + readonly restoring?: boolean; + readonly hasModel?: boolean; + readonly model?: string; + readonly contextTokens?: number; + readonly maxContextTokens?: number | undefined; + readonly usage?: UsageStatus | undefined; + readonly planMode?: boolean; + readonly swarmMode?: boolean; + readonly permission?: PermissionMode; +} + +function makeHost(overrides: HostOverrides = {}): { + host: AgentStatusHost; + events: AgentEvent[]; +} { + const events: AgentEvent[] = []; + const modelCapabilities = { + ...UNKNOWN_CAPABILITY, + max_context_tokens: overrides.maxContextTokens, + }; + const restoring = overrides.restoring ?? false; + const host: AgentStatusHost = { + records: { restoring: restoring ? { time: 0 } : null } as unknown as IRecordsService, + config: { + hasModel: overrides.hasModel ?? true, + model: overrides.model ?? 'test-model', + modelCapabilities, + } as unknown as IAgentConfigService, + context: { + tokenCount: overrides.contextTokens ?? 0, + } as unknown as IContextService, + usage: { + status: () => overrides.usage, + } as unknown as IUsageService, + planMode: { isActive: overrides.planMode ?? false } as unknown as IPlanService, + swarmMode: { isActive: overrides.swarmMode ?? false } as unknown as ISwarmService, + permission: { mode: overrides.permission ?? 'manual' } as unknown as IPermissionService, + eventBus: { + publish: (event: AgentEvent) => { + events.push(event); + }, + } as unknown as IDomainEventBus, + }; + return { host, events }; +} + +describe('AgentStatusService', () => { + it('publishes agent.status.updated with the expected payload', () => { + const { host, events } = makeHost({ + model: 'kimi-k2', + contextTokens: 250, + maxContextTokens: 1000, + planMode: true, + swarmMode: false, + permission: 'yolo', + }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + expect(events).toHaveLength(1); + const event = events[0]; + expect(event?.type).toBe('agent.status.updated'); + if (event?.type !== 'agent.status.updated') throw new Error('unexpected event type'); + expect(event.model).toBe('kimi-k2'); + expect(event.contextTokens).toBe(250); + expect(event.maxContextTokens).toBe(1000); + expect(event.planMode).toBe(true); + expect(event.swarmMode).toBe(false); + expect(event.permission).toBe('yolo'); + }); + + it('computes contextUsage as contextTokens / maxContextTokens', () => { + const { host, events } = makeHost({ contextTokens: 250, maxContextTokens: 1000 }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + const event = events[0]; + if (event?.type !== 'agent.status.updated') throw new Error('unexpected event type'); + expect(event.contextUsage).toBeCloseTo(0.25); + }); + + it('forwards the usage snapshot from the usage service', () => { + const usage: UsageStatus = { + total: { inputOther: 10, output: 5, inputCacheRead: 0, inputCacheCreation: 0 }, + } as unknown as UsageStatus; + const { host, events } = makeHost({ usage }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + const event = events[0]; + if (event?.type !== 'agent.status.updated') throw new Error('unexpected event type'); + expect(event.usage).toEqual(usage); + }); + + it('does not emit while records are restoring', () => { + const { host, events } = makeHost({ restoring: true }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + expect(events).toHaveLength(0); + }); + + it('does not emit when no model is configured', () => { + const { host, events } = makeHost({ hasModel: false }); + const service = new AgentStatusService(host); + + service.notifyStatusChanged(); + + expect(events).toHaveLength(0); + }); +}); diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 56a74a42c..8d262ade6 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -1,8 +1,8 @@ import type { ToolCall } from '@moonshot-ai/kosong'; import { describe, expect, it, vi } from 'vitest'; -import { HookEngine } from '../../src/session/hooks'; -import type { SessionSubagentHost } from '../../src/session/subagent-host'; +import { HookService } from '../../src/session/hooks'; +import type { ISubagentHostService } from '../../src/session/subagent-host'; import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { createCommandKaos, testAgent } from './harness/agent'; @@ -14,7 +14,7 @@ describe('Agent tools', () => { it('blocks tools through PreToolUse before permission and emits PostToolUseFailure', async () => { const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute')); const triggered: Array<[string, string, number]> = []; - const hookEngine = new HookEngine( + const hookEngine = new HookService( [ { event: 'PreToolUse', @@ -55,7 +55,7 @@ describe('Agent tools', () => { it('emits PostToolUse after successful tools', async () => { const triggered: Array<[string, string, number]> = []; - const hookEngine = new HookEngine( + const hookEngine = new HookService( [ { event: 'PostToolUse', @@ -118,7 +118,7 @@ describe('Agent tools', () => { completion, }), resume: vi.fn(), - } as unknown as SessionSubagentHost; + } as unknown as ISubagentHostService; const ctx = testAgent({ subagentHost }); ctx.configure({ tools: ['Agent'] }); @@ -162,7 +162,7 @@ describe('Agent tools', () => { arguments: '{"query":"moon"}', }; const resolved: Array<[string, string, string]> = []; - const hookEngine = new HookEngine( + const hookEngine = new HookService( [ { event: 'PostToolUseFailure', @@ -253,7 +253,7 @@ describe('Agent tools', () => { }); it('exposes AgentSwarm when a subagent host is available', () => { - const subagentHost = {} as unknown as SessionSubagentHost; + const subagentHost = {} as unknown as ISubagentHostService; const ctx = testAgent({ subagentHost, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index e39094fb4..02650d6ff 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -15,15 +15,15 @@ import { } from '@moonshot-ai/kosong'; import { describe, expect, it, vi } from 'vitest'; -import { HookEngine } from '../../src/session/hooks'; -import { abortError } from '../../src/utils/abort'; +import { HookService } from '../../src/session/hooks'; +import { abortError } from '#/_utils/abort'; import type { AgentOptions } from '../../src/agent'; import { ErrorCodes, KimiError } from '../../src/errors'; -import type { Logger, LogPayload } from '../../src/logging'; +import type { Logger, LogPayload } from '#/_base/logging'; import type { QueuedSubagentRunResult, QueuedSubagentTask, - SessionSubagentHost, + ISubagentHostService, } from '../../src/session/subagent-host'; import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; @@ -164,7 +164,7 @@ describe('Agent turn flow', () => { '});', ].join(''); const resolved: Array<[string, string, string]> = []; - const hookEngine = new HookEngine( + const hookEngine = new HookService( [ { event: 'PostToolUse', @@ -332,7 +332,7 @@ describe('Agent turn flow', () => { })); }); const subagentHost = mockSubagentHost({ - runQueued: runQueued as unknown as SessionSubagentHost['runQueued'], + runQueued: runQueued as unknown as ISubagentHostService['runQueued'], }); const ctx = testAgent({ subagentHost, @@ -443,7 +443,7 @@ describe('Agent turn flow', () => { }); it('continues the turn after projecting UserPromptSubmit hook output', async () => { - const hookEngine = new HookEngine([ + const hookEngine = new HookService([ { event: 'UserPromptSubmit', matcher: 'hooked input', @@ -510,7 +510,7 @@ describe('Agent turn flow', () => { }); it('projects structured UserPromptSubmit stdout', async () => { - const hookEngine = new HookEngine([ + const hookEngine = new HookService([ { event: 'UserPromptSubmit', matcher: 'hooked input', @@ -573,7 +573,7 @@ describe('Agent turn flow', () => { }); it('stops the turn when a UserPromptSubmit hook blocks', async () => { - const hookEngine = new HookEngine([ + const hookEngine = new HookService([ { event: 'UserPromptSubmit', matcher: 'bad words', @@ -629,7 +629,7 @@ describe('Agent turn flow', () => { }); it('cancels while waiting for a UserPromptSubmit hook without appending stale output', async () => { - const hookEngine = new HookEngine([ + const hookEngine = new HookService([ { event: 'UserPromptSubmit', command: 'node -e "setTimeout(() => process.stdout.write(\\"late hook\\"), 250)"', @@ -666,7 +666,7 @@ describe('Agent turn flow', () => { }); it('uses a Stop hook block reason as a one-shot turn continuation', async () => { - const hookEngine = new HookEngine([ + const hookEngine = new HookService([ { event: 'Stop', command: "echo 'continue from hook' >&2; exit 2", @@ -716,7 +716,7 @@ describe('Agent turn flow', () => { `fs.writeFileSync(${JSON.stringify(marker)}, 'started');`, "setTimeout(() => process.stderr.write('late stop hook'), 250);", ].join(''); - const hookEngine = new HookEngine([ + const hookEngine = new HookService([ { event: 'Stop', command: `node -e ${JSON.stringify(script)}`, @@ -751,7 +751,7 @@ describe('Agent turn flow', () => { "setTimeout(() => process.stdout.write('late pre tool hook'), 250);", ].join(''); const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute')); - const hookEngine = new HookEngine([ + const hookEngine = new HookService([ { event: 'PreToolUse', matcher: 'Bash', @@ -787,7 +787,7 @@ describe('Agent turn flow', () => { it('fires StopFailure when a turn fails', async () => { const triggered: Array<[string, string, number]> = []; - const hookEngine = new HookEngine( + const hookEngine = new HookService( [ { event: 'StopFailure', @@ -812,7 +812,7 @@ describe('Agent turn flow', () => { it('fires Interrupt when the user cancels an active turn', async () => { const triggered: Array<[string, string, number]> = []; - const hookEngine = new HookEngine( + const hookEngine = new HookService( [ { event: 'Interrupt', @@ -843,7 +843,7 @@ describe('Agent turn flow', () => { it('does not fire Interrupt for a non-user (programmatic) abort', async () => { const triggered: Array<[string, string, number]> = []; - const hookEngine = new HookEngine( + const hookEngine = new HookService( [ { event: 'Interrupt', @@ -1735,11 +1735,11 @@ function agentSwarmCall(): ToolCall { }; } -function mockSubagentHost>( +function mockSubagentHost>( host: T, -): T & SessionSubagentHost { +): T & ISubagentHostService { return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T & - SessionSubagentHost; + ISubagentHostService; } interface ApiErrorTelemetryCase { diff --git a/packages/agent-core/test/base/common/event.test.ts b/packages/agent-core/test/base/common/event.test.ts index ca4f87171..77f185413 100644 --- a/packages/agent-core/test/base/common/event.test.ts +++ b/packages/agent-core/test/base/common/event.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { Emitter, Event } from '#/base/common/event'; -import { Disposable, DisposableStore, type IDisposable } from '#/di/lifecycle'; +import { Emitter, Event } from '#/_base/event'; +import { Disposable, DisposableStore, type IDisposable } from '#/_base/di'; import { resetUnexpectedErrorHandler, setUnexpectedErrorHandler, -} from '#/errors/unexpectedError'; +} from '#/_base/errors'; afterEach(() => { resetUnexpectedErrorHandler(); diff --git a/packages/agent-core/test/dependency-direction.test.ts b/packages/agent-core/test/dependency-direction.test.ts new file mode 100644 index 000000000..5d28e9183 --- /dev/null +++ b/packages/agent-core/test/dependency-direction.test.ts @@ -0,0 +1,973 @@ +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, normalize } from 'pathe'; + +import { afterEach, describe, expect, it } from 'vitest'; + +/** + * Dependency-direction fence for agent-core. + * + * Pins the dependency-direction rules from + * `packages/agent-core/src/services/AGENTS.md`. Three of those rules are + * enforced here by grep over the source tree; the rest are convention-only + * because they are not expressible as a static import check. + * + * Grep-enforced (this file): + * 1. runtime ↛ services — `services/` is the upper facade; the runtime + * (`rpc/`, `session/`, `agent/`, `di/`, …) must NOT import back into + * `services/`. See {@link findViolations}. + * 2. repository / index ↛ services — runtime-owned repositories and indexes + * (`*Repository.ts` / `*Index.ts` under a runtime dir) must NOT import + * from `services/`. This is a focused, explicitly-named restatement of + * rule 1 for the persistence layer. See + * {@link findRepositoryIndexServiceImports}. + * 3. application services don't call each other's business methods — a + * `services//*Service.ts` impl must NOT import a CONCRETE impl + * from a different domain `services//*Service.ts` (A ≠ B). Type + * imports and contract imports (`.ts`, `/index.ts`) are + * allowed. See {@link findCrossServiceBusinessImports}. + * + * di-v3 target-structure rules (FIXTURE-based until P2/P3 lands the layout): + * + * 4. `_utils/` ← `_base/` ← `domains/` — the di-v3 internal infrastructure + * layering. `_utils/` is the lowest layer and must NOT import `_base/` or + * any domain; `_base/` must NOT import any domain (importing `_utils/` is + * the allowed direction). See {@link findBaseUtilsViolations}. + * 5. `agent-core//` dirs don't import each other's impls — a domain A + * file must NOT value-import a CONCRETE impl (`/Service.ts`) + * from another domain B (A ≠ B). Cross-domain access goes through the + * contract + `IServiceAccessor`. Type imports, contract imports + * (`.ts`), and barrel imports (`/index.ts`) are allowed. + * See {@link findCrossDomainImplImports}. + * + * The current `src/` tree has no `_base/` / `_utils/` / di-v3 `/` dirs + * yet, so the real-tree positive cases for rules 4 and 5 are VACUOUSLY CLEAN + * (0 violations) — the detectors simply have nothing to scan. Rule 4 scans + * `_utils/` and `_base/` directly (absent → empty); rule 5 is gated on the + * di-v3 infra markers (`_base/` / `_utils/`) being present (absent → dormant). + * Both rules activate as di-v3 lands in P2/P3. The fixture cases below drive + * the exact same detection logic against planted di-v3-shaped trees so the + * positive and negative paths are exercised today. + * + * Convention-only (NOT grep-enforced here, documented in AGENTS.md): + * - Within a single domain, the command / query / runtime roles do not call + * each other's business methods. A sibling impl import inside the same + * domain folder (e.g. `sessionQueryService.ts` → `./sessionRuntimeService`) + * is intentionally NOT flagged by rule 3: it cannot be distinguished from a + * legitimate lower-layer composition by a cross-file grep, so it remains a + * code-review convention. + * - "Business method" vs "contract" is a semantic distinction; the fence + * approximates it as "concrete `*Service.ts` impl import" vs "anything + * else" and treats `import type` as non-business. + * + * Each detector is exported and driven by both a positive case (the current, + * clean tree → 0 violations) and a planted negative fixture, so the positive + * and negative paths run the exact same detection logic. + */ + +const SRC = join(import.meta.dirname, '..', 'src'); + +const RUNTIME_DIRS = ['rpc', 'session', 'agent', 'di'] as const; + +const SERVICES_BARE = '@moonshot-ai/agent-core/services'; + +// Bare package root used by the di-v3 cross-domain rule to recognise +// `@moonshot-ai/agent-core//` specifiers. +const AGENT_CORE_BARE = '@moonshot-ai/agent-core'; + +// Top-level dirs under `src/` that are NOT di-v3 domains. `_base` / `_utils` +// are the di-v3 internal infrastructure layers (rule 4); the rest are +// cross-cutting infrastructure that is exempt from the domain-to-domain impl +// rule (rule 5). `services` is the pre-di-v3 facade and is excluded so the +// legacy tree never trips rule 5. +const DIV3_RESERVED_DIRS = [ + '_base', + '_utils', + 'scope', + 'rpc', + 'config', + 'flags', + 'errors', + 'services', +] as const; + +// `import ... from 'x'` and `export ... from 'x'`. +const FROM_RE = /\bfrom\s*['"]([^'"]+)['"]/g; +// Dynamic `import('x')`. +const DYNAMIC_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g; +// Side-effect `import 'x'`. +const SIDE_EFFECT_RE = /\bimport\s*['"]([^'"]+)['"]/g; + +// `import ... from 'x'` / `export ... from 'x'`, capturing the binding clause +// so type-only imports (`import type { X }`, `import { type X }`) can be told +// apart from value imports. Group 1 = `import`/`export`, 2 = clause, 3 = specifier. +const MODULE_FROM_RE = /\b(import|export)\b([\s\S]*?)\bfrom\s*['"]([^'"]+)['"]/g; + +const SERVICE_IMPL_RE = /Service$/; + +export interface DependencyViolation { + /** Path of the offending file, relative to the scanned `srcRoot`. */ + file: string; + /** The module specifier that points into `services/`. */ + specifier: string; +} + +export interface CrossServiceViolation { + /** Path of the offending impl file, relative to the scanned `srcRoot`. */ + file: string; + /** Domain folder that owns the importing file. */ + fromDomain: string; + /** Domain folder that owns the imported concrete impl. */ + toDomain: string; + /** The module specifier that crosses the domain boundary. */ + specifier: string; +} + +export interface BaseUtilsViolation { + /** Path of the offending file, relative to the scanned `srcRoot`. */ + file: string; + /** The module specifier that crosses the infrastructure layer boundary. */ + specifier: string; + /** The lower infrastructure layer the offending file lives in. */ + layer: '_utils' | '_base'; + /** What was illegally imported: `_base` (only from `_utils`) or a domain. */ + target: '_base' | 'domain'; +} + +export interface CrossDomainViolation { + /** Path of the offending file, relative to the scanned `srcRoot`. */ + file: string; + /** di-v3 domain folder that owns the importing file. */ + fromDomain: string; + /** di-v3 domain folder that owns the imported concrete impl. */ + toDomain: string; + /** The module specifier that crosses the domain boundary. */ + specifier: string; +} + +interface ModuleReference { + specifier: string; + /** True when every binding in the statement is type-only. */ + typeOnly: boolean; +} + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +function extractSpecifiers(source: string): string[] { + const stripped = stripComments(source); + const specifiers: string[] = []; + for (const re of [FROM_RE, DYNAMIC_RE, SIDE_EFFECT_RE]) { + re.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(stripped)) !== null) { + const specifier = match[1]; + if (specifier !== undefined) { + specifiers.push(specifier); + } + } + } + return specifiers; +} + +/** + * Classify an `import`/`export ... from` binding clause. Returns true when the + * whole statement is type-only and therefore must not be treated as a business + * (value) dependency: + * - `import type { X } from ...` / `export type { X } from ...` + * - `import { type X, type Y } from ...` (inline `type` on every binding) + * A statement carrying any value binding (default, namespace, or a non-`type` + * named binding) is a value import and returns false. + */ +function isTypeOnlyImportClause(clause: string): boolean { + const trimmed = clause.trim(); + if (trimmed.length === 0) { + return false; + } + if (/^type\b/.test(trimmed)) { + return true; + } + if (/\*\s*as\s+/.test(trimmed)) { + return false; + } + const braceMatch = trimmed.match(/\{([\s\S]*)\}/); + if (!braceMatch) { + return false; + } + const beforeBrace = trimmed.slice(0, braceMatch.index).replace(/,/g, '').trim(); + if (beforeBrace.length > 0) { + // A default binding sits alongside the named group → value import. + return false; + } + const bindings = braceMatch[1] + ?.split(',') + .map((binding) => binding.trim()) + .filter((binding) => binding.length > 0); + if (!bindings || bindings.length === 0) { + return false; + } + return bindings.every((binding) => /^type\s+/.test(binding)); +} + +function extractModuleReferences(source: string): ModuleReference[] { + const stripped = stripComments(source); + const refs: ModuleReference[] = []; + + MODULE_FROM_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = MODULE_FROM_RE.exec(stripped)) !== null) { + const clause = match[2] ?? ''; + const specifier = match[3]; + if (specifier !== undefined) { + refs.push({ specifier, typeOnly: isTypeOnlyImportClause(clause) }); + } + } + + for (const re of [DYNAMIC_RE, SIDE_EFFECT_RE]) { + re.lastIndex = 0; + while ((match = re.exec(stripped)) !== null) { + const specifier = match[1]; + if (specifier !== undefined) { + refs.push({ specifier, typeOnly: false }); + } + } + } + + return refs; +} + +function isServicesImport(specifier: string, fileDir: string, srcRoot: string): boolean { + if (specifier === SERVICES_BARE || specifier.startsWith(`${SERVICES_BARE}/`)) { + return true; + } + if (specifier.startsWith('.')) { + const resolved = normalize(join(fileDir, specifier)); + const servicesRoot = normalize(join(srcRoot, 'services')); + return resolved === servicesRoot || resolved.startsWith(`${servicesRoot}/`); + } + return false; +} + +interface ServiceTarget { + domain: string; + moduleName: string; +} + +/** + * Resolve a module specifier to a `services//` target, or + * `null` when it does not point into the services tree. `moduleName` is the + * final path segment with any extension stripped, so impl detection can match + * on the `*Service` suffix regardless of `../foo/fooService` vs + * `../foo/fooService.ts`. + */ +function resolveServiceTarget( + specifier: string, + fileDir: string, + srcRoot: string, +): ServiceTarget | null { + const toTarget = (rest: string): ServiceTarget | null => { + const parts = rest.split('/').filter((part) => part.length > 0); + if (parts.length < 2) { + // Points at the services barrel root or a domain barrel — not a concrete impl. + return null; + } + const [domain, ...restParts] = parts; + const leaf = restParts[restParts.length - 1]; + if (domain === undefined || leaf === undefined) { + return null; + } + const moduleName = leaf.replace(/\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, ''); + return { domain, moduleName }; + }; + + if (specifier.startsWith('.')) { + const resolved = normalize(join(fileDir, specifier)); + const servicesRoot = normalize(join(srcRoot, 'services')); + if (!resolved.startsWith(`${servicesRoot}/`)) { + return null; + } + return toTarget(resolved.slice(servicesRoot.length + 1)); + } + + const barePrefix = `${SERVICES_BARE}/`; + if (specifier.startsWith(barePrefix)) { + return toTarget(specifier.slice(barePrefix.length)); + } + + return null; +} + +/** + * Scan `runtimeDirs` under `srcRoot` and return every import whose specifier + * resolves into the `/services` subtree. + * + * Pure and exported so both the positive (real src) and negative (fixture) + * cases drive the exact same detection logic — no duplicated regex. + */ +export function findViolations( + srcRoot: string, + runtimeDirs: readonly string[], +): DependencyViolation[] { + const violations: DependencyViolation[] = []; + for (const dir of runtimeDirs) { + const absDir = join(srcRoot, dir); + const files = globSync('**/*.ts', { cwd: absDir }).map((file) => file.split('\\').join('/')); + for (const rel of files) { + const absFile = join(absDir, rel); + const source = readFileSync(absFile, 'utf8'); + const fileDir = dirname(absFile); + for (const specifier of extractSpecifiers(source)) { + if (isServicesImport(specifier, fileDir, srcRoot)) { + violations.push({ file: `${dir}/${rel}`, specifier }); + } + } + } + } + return violations; +} + +/** + * Rule 2 — repository / index ↛ services. Focused restatement of rule 1 for + * the persistence layer: only `*Repository.ts` / `*Index.ts` files under a + * runtime dir are scanned, so the fixture and the report speak in the + * vocabulary of the convention. + */ +export function findRepositoryIndexServiceImports( + srcRoot: string, + runtimeDirs: readonly string[], +): DependencyViolation[] { + const violations: DependencyViolation[] = []; + for (const dir of runtimeDirs) { + const absDir = join(srcRoot, dir); + const files = globSync('**/*.ts', { cwd: absDir }) + .map((file) => file.split('\\').join('/')) + .filter((file) => file.endsWith('Repository.ts') || file.endsWith('Index.ts')); + for (const rel of files) { + const absFile = join(absDir, rel); + const source = readFileSync(absFile, 'utf8'); + const fileDir = dirname(absFile); + for (const specifier of extractSpecifiers(source)) { + if (isServicesImport(specifier, fileDir, srcRoot)) { + violations.push({ file: `${dir}/${rel}`, specifier }); + } + } + } + } + return violations; +} + +/** + * Rule 3 — application services don't call each other's business methods. A + * `services//*Service.ts` impl importing a CONCRETE impl from a + * different domain `services//*Service.ts` (A ≠ B) is flagged. Type + * imports, contract imports (`.ts` / `/index.ts`), and + * same-domain sibling imports are intentionally not flagged. + */ +export function findCrossServiceBusinessImports(srcRoot: string): CrossServiceViolation[] { + const violations: CrossServiceViolation[] = []; + const servicesRoot = join(srcRoot, 'services'); + const files = globSync('*/*Service.ts', { cwd: servicesRoot }).map((file) => + file.split('\\').join('/'), + ); + for (const rel of files) { + const slash = rel.indexOf('/'); + const fromDomain = slash === -1 ? rel : rel.slice(0, slash); + const absFile = join(servicesRoot, rel); + const source = readFileSync(absFile, 'utf8'); + const fileDir = dirname(absFile); + for (const { specifier, typeOnly } of extractModuleReferences(source)) { + if (typeOnly) { + continue; + } + const target = resolveServiceTarget(specifier, fileDir, srcRoot); + if (target === null) { + continue; + } + if (target.domain === fromDomain) { + continue; + } + if (!SERVICE_IMPL_RE.test(target.moduleName)) { + continue; + } + violations.push({ + file: `services/${rel}`, + fromDomain, + toDomain: target.domain, + specifier, + }); + } + } + return violations; +} + +/** + * List the immediate child directories of `srcRoot`. Used to discover the + * di-v3 top-level domain dirs (everything except {@link DIV3_RESERVED_DIRS}). + */ +function listTopLevelDirs(srcRoot: string): string[] { + return readdirSync(srcRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +/** di-v3 domain dirs = top-level dirs minus the reserved infrastructure set. */ +function listDomainDirs(srcRoot: string): string[] { + const reserved = new Set(DIV3_RESERVED_DIRS); + return listTopLevelDirs(srcRoot).filter((name) => !reserved.has(name)); +} + +/** + * The di-v3 layout is considered active once its internal infrastructure + * markers (`_base/` / `_utils/`) exist under `srcRoot`. Until then the current + * tree has neither marker, so rule 5 (cross-domain impl) is dormant and its + * real-tree case is vacuously clean. + */ +function isDiV3LayoutActive(srcRoot: string): boolean { + return existsSync(join(srcRoot, '_base')) || existsSync(join(srcRoot, '_utils')); +} + +interface TopLevelTarget { + /** Top-level dir under `srcRoot` the specifier resolves into. */ + dir: string; + /** Final path segment with any extension stripped (for impl-suffix checks). */ + moduleName: string; +} + +/** + * Resolve a module specifier to the top-level dir under `srcRoot` it points + * into, or `null` when it does not point into a top-level dir's module (e.g. + * a top-level file, a bare package root, or an external package). Handles both + * relative (`..//`) and bare + * (`@moonshot-ai/agent-core//`) specifiers. + */ +function resolveTopLevelTarget( + specifier: string, + fileDir: string, + srcRoot: string, +): TopLevelTarget | null { + const toTarget = (rest: string): TopLevelTarget | null => { + const parts = rest.split('/').filter((part) => part.length > 0); + if (parts.length < 2) { + // Points at a top-level dir root (barrel) or a top-level file — not a + // module inside a top-level dir. + return null; + } + const [dir, ...restParts] = parts; + const leaf = restParts[restParts.length - 1]; + if (dir === undefined || leaf === undefined) { + return null; + } + const moduleName = leaf.replace(/\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, ''); + return { dir, moduleName }; + }; + + if (specifier.startsWith('.')) { + const resolved = normalize(join(fileDir, specifier)); + const root = normalize(srcRoot); + if (!resolved.startsWith(`${root}/`)) { + return null; + } + return toTarget(resolved.slice(root.length + 1)); + } + + const barePrefix = `${AGENT_CORE_BARE}/`; + if (specifier.startsWith(barePrefix)) { + return toTarget(specifier.slice(barePrefix.length)); + } + + return null; +} + +/** + * Rule 4 (di-v3) — `_utils/` ← `_base/` ← `domains/`. Scans the `_utils/` and + * `_base/` infrastructure layers and flags imports that point up the layering: + * - `_utils/` must NOT import `_base/` or any domain; + * - `_base/` must NOT import any domain (importing `_utils/` is allowed). + * + * Every import form is flagged, including `import type`: a type dependency is + * still a layer violation for infrastructure (it would prevent `_utils/` from + * being extracted independently of `_base/`). + * + * Pure and exported so both the (vacuously clean) real-tree case and the + * planted fixtures drive the exact same detection logic. + */ +export function findBaseUtilsViolations(srcRoot: string): BaseUtilsViolation[] { + const violations: BaseUtilsViolation[] = []; + const domainDirs = new Set(listDomainDirs(srcRoot)); + + const scanLayer = (layer: '_utils' | '_base', forbidBase: boolean): void => { + const absDir = join(srcRoot, layer); + const files = globSync('**/*.ts', { cwd: absDir }).map((file) => file.split('\\').join('/')); + for (const rel of files) { + const absFile = join(absDir, rel); + const source = readFileSync(absFile, 'utf8'); + const fileDir = dirname(absFile); + for (const specifier of extractSpecifiers(source)) { + const target = resolveTopLevelTarget(specifier, fileDir, srcRoot); + if (target === null) { + continue; + } + if (forbidBase && target.dir === '_base') { + violations.push({ file: `${layer}/${rel}`, specifier, layer, target: '_base' }); + } else if (domainDirs.has(target.dir)) { + violations.push({ file: `${layer}/${rel}`, specifier, layer, target: 'domain' }); + } + } + } + }; + + scanLayer('_utils', true); + scanLayer('_base', false); + return violations; +} + +/** + * Rule 5 (di-v3) — `agent-core//` dirs don't import each other's + * impls. A file in domain A must NOT value-import a CONCRETE impl + * (`/Service.ts`) from another domain B (A ≠ B). Type-only + * imports, contract imports (`.ts`), and barrel imports + * (`/index.ts`) are allowed. Same-domain imports are not flagged. + * + * Dormant until the di-v3 layout is active (see {@link isDiV3LayoutActive}); + * on the current tree (no `_base/`/`_utils/`) it returns no violations, so the + * real-tree case is vacuously clean. + */ +export function findCrossDomainImplImports(srcRoot: string): CrossDomainViolation[] { + if (!isDiV3LayoutActive(srcRoot)) { + return []; + } + const domainDirs = listDomainDirs(srcRoot); + const domainSet = new Set(domainDirs); + const violations: CrossDomainViolation[] = []; + for (const fromDomain of domainDirs) { + const absDir = join(srcRoot, fromDomain); + const files = globSync('**/*.ts', { cwd: absDir }).map((file) => file.split('\\').join('/')); + for (const rel of files) { + const absFile = join(absDir, rel); + const source = readFileSync(absFile, 'utf8'); + const fileDir = dirname(absFile); + for (const { specifier, typeOnly } of extractModuleReferences(source)) { + if (typeOnly) { + continue; + } + const target = resolveTopLevelTarget(specifier, fileDir, srcRoot); + if (target === null) { + continue; + } + if (!domainSet.has(target.dir)) { + continue; + } + if (target.dir === fromDomain) { + continue; + } + if (!SERVICE_IMPL_RE.test(target.moduleName)) { + continue; + } + violations.push({ + file: `${fromDomain}/${rel}`, + fromDomain, + toDomain: target.dir, + specifier, + }); + } + } + } + return violations; +} + +describe('dependency-direction fence', () => { + describe('positive cases (current tree is clean)', () => { + it('runtime modules do not import back into services/ (real src)', () => { + expect(findViolations(SRC, RUNTIME_DIRS)).toEqual([]); + }); + + it('runtime repositories / indexes do not import from services/ (real src)', () => { + expect(findRepositoryIndexServiceImports(SRC, RUNTIME_DIRS)).toEqual([]); + }); + + it('application services do not import concrete impls across domains (real src)', () => { + expect(findCrossServiceBusinessImports(SRC)).toEqual([]); + }); + + // ACTIVE (post P2.1–P2.5): `_base/` and `_utils/` now exist under `src/`, + // and `isDiV3LayoutActive(SRC)` returns true, so both di-v3 detectors scan + // the real tree. These are real cleanliness assertions (0 violations), not + // the vacuously-clean placeholders they were before the layout landed. + it('di-v3 _utils ← _base ← domains layering is clean (real src)', () => { + expect(findBaseUtilsViolations(SRC)).toEqual([]); + }); + + it('di-v3 cross-domain impl fence is clean (real src)', () => { + expect(findCrossDomainImplImports(SRC)).toEqual([]); + }); + }); + + describe('negative fixture: runtime -> services', () => { + let fixtureRoot: string | undefined; + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } + }); + + it('detects a planted runtime -> services import across import forms', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + mkdirSync(join(fixtureRoot, 'runtime'), { recursive: true }); + writeFileSync( + join(fixtureRoot, 'runtime', 'foo.ts'), + [ + "import { x } from '../services/bar';", + 'export { y } from "../services/baz";', + "const lazy = import('../services/qux');", + "import { z } from '@moonshot-ai/agent-core/services';", + '', + ].join('\n'), + ); + + const violations = findViolations(fixtureRoot, ['runtime']); + const specifiers = violations.map((v) => v.specifier); + + expect(specifiers).toContain('../services/bar'); + expect(specifiers).toContain('../services/baz'); + expect(specifiers).toContain('../services/qux'); + expect(specifiers).toContain('@moonshot-ai/agent-core/services'); + expect(violations.every((v) => v.file === 'runtime/foo.ts')).toBe(true); + }); + }); + + describe('negative fixture: repository / index -> services', () => { + let fixtureRoot: string | undefined; + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } + }); + + it('detects planted repository and index files importing from services/', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + mkdirSync(join(fixtureRoot, 'session'), { recursive: true }); + writeFileSync( + join(fixtureRoot, 'session', 'fooRepository.ts'), + [ + "import { x } from '../services/bar';", + "import type { y } from '../services/baz';", + '', + ].join('\n'), + ); + writeFileSync( + join(fixtureRoot, 'session', 'fooIndex.ts'), + ["import { z } from '@moonshot-ai/agent-core/services/qux';", ''].join('\n'), + ); + // A non-repository/index runtime file importing services is out of scope + // for THIS detector (it is caught by findViolations instead) and must not + // be reported here. + writeFileSync( + join(fixtureRoot, 'session', 'helper.ts'), + ["import { w } from '../services/bar';", ''].join('\n'), + ); + + const violations = findRepositoryIndexServiceImports(fixtureRoot, ['session']); + const specifiersByFile = (file: string): string[] => + violations.filter((v) => v.file === file).map((v) => v.specifier); + + // Both the value and the type-only services imports are flagged: a type + // dependency is still a layer violation for the persistence layer. + expect(specifiersByFile('session/fooRepository.ts').sort()).toEqual([ + '../services/bar', + '../services/baz', + ]); + expect(specifiersByFile('session/fooIndex.ts')).toEqual([ + '@moonshot-ai/agent-core/services/qux', + ]); + expect(specifiersByFile('session/helper.ts')).toEqual([]); + }); + }); + + describe('negative fixture: cross-service business import', () => { + let fixtureRoot: string | undefined; + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } + }); + + it('detects a planted concrete impl import across service domains', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + mkdirSync(join(fixtureRoot, 'services', 'foo'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'services', 'bar'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'services', 'baz'), { recursive: true }); + writeFileSync(join(fixtureRoot, 'services', 'bar', 'barService.ts'), 'export class BarService {}\n'); + writeFileSync(join(fixtureRoot, 'services', 'baz', 'bazService.ts'), 'export class BazService {}\n'); + writeFileSync( + join(fixtureRoot, 'services', 'foo', 'fooService.ts'), + [ + // Concrete impl import from another domain -> flagged. + "import { BarService } from '../bar/barService';", + // Concrete impl via the bare package subpath -> flagged. + "import { BazService } from '@moonshot-ai/agent-core/services/baz/bazService';", + '', + ].join('\n'), + ); + + const violations = findCrossServiceBusinessImports(fixtureRoot); + const specifiers = violations.map((v) => v.specifier); + + expect(specifiers).toContain('../bar/barService'); + expect(specifiers).toContain('@moonshot-ai/agent-core/services/baz/bazService'); + expect( + violations.every( + (v) => + v.file === 'services/foo/fooService.ts' && + v.fromDomain === 'foo' && + (v.toDomain === 'bar' || v.toDomain === 'baz'), + ), + ).toBe(true); + }); + + it('does not flag type-only, contract, barrel, or same-domain imports', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + mkdirSync(join(fixtureRoot, 'services', 'foo'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'services', 'bar'), { recursive: true }); + writeFileSync(join(fixtureRoot, 'services', 'bar', 'barService.ts'), 'export class BarService {}\n'); + writeFileSync(join(fixtureRoot, 'services', 'bar', 'bar.ts'), 'export interface IBarService {}\n'); + writeFileSync(join(fixtureRoot, 'services', 'bar', 'index.ts'), 'export * from "./bar";\n'); + writeFileSync(join(fixtureRoot, 'services', 'foo', 'fooQueryService.ts'), 'export class FooQueryService {}\n'); + writeFileSync( + join(fixtureRoot, 'services', 'foo', 'fooService.ts'), + [ + // type-only import of a concrete impl -> allowed (not a business dependency). + "import type { BarService } from '../bar/barService';", + // inline `type` on the only binding -> allowed. + "import { type IBarService } from '../bar/barService';", + // contract import from `.ts` -> allowed. + "import { IBarService } from '../bar/bar';", + // domain barrel import (`index.ts`) -> allowed. + "import { IBarService as IBarService2 } from '../bar';", + // same-domain sibling impl import -> convention-only, NOT flagged here. + "import { FooQueryService } from './fooQueryService';", + '', + ].join('\n'), + ); + + expect(findCrossServiceBusinessImports(fixtureRoot)).toEqual([]); + }); + }); + + describe('negative fixture: di-v3 _utils / _base layering', () => { + let fixtureRoot: string | undefined; + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } + }); + + it('detects _utils importing _base or a domain (including type-only)', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + mkdirSync(join(fixtureRoot, '_utils'), { recursive: true }); + mkdirSync(join(fixtureRoot, '_base'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'loop'), { recursive: true }); + writeFileSync(join(fixtureRoot, '_base', 'event.ts'), 'export class Emitter {}\n'); + writeFileSync(join(fixtureRoot, '_base', 'types.ts'), 'export interface BaseType {}\n'); + writeFileSync(join(fixtureRoot, 'loop', 'turnService.ts'), 'export class TurnService {}\n'); + writeFileSync( + join(fixtureRoot, '_utils', 'helper.ts'), + [ + // _utils -> _base value import -> flagged. + "import { Emitter } from '../_base/event';", + // _utils -> _base type-only import -> flagged (still a layer violation). + "import type { BaseType } from '../_base/types';", + // _utils -> domain import -> flagged. + "import { TurnService } from '../loop/turnService';", + '', + ].join('\n'), + ); + + const violations = findBaseUtilsViolations(fixtureRoot); + const specifiers = violations.map((v) => v.specifier); + + expect(specifiers).toContain('../_base/event'); + expect(specifiers).toContain('../_base/types'); + expect(specifiers).toContain('../loop/turnService'); + expect(violations.every((v) => v.layer === '_utils' && v.file === '_utils/helper.ts')).toBe(true); + }); + + it('detects _base importing a domain but allows _base importing _utils', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + mkdirSync(join(fixtureRoot, '_utils'), { recursive: true }); + mkdirSync(join(fixtureRoot, '_base'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'loop'), { recursive: true }); + writeFileSync(join(fixtureRoot, '_utils', 'helper.ts'), 'export const h = 1;\n'); + writeFileSync(join(fixtureRoot, 'loop', 'turnService.ts'), 'export class TurnService {}\n'); + writeFileSync( + join(fixtureRoot, '_base', 'logger.ts'), + [ + // _base -> _utils -> allowed direction, NOT flagged. + "import { h } from '../_utils/helper';", + // _base -> domain -> flagged. + "import { TurnService } from '../loop/turnService';", + '', + ].join('\n'), + ); + + const violations = findBaseUtilsViolations(fixtureRoot); + const specifiers = violations.map((v) => v.specifier); + + expect(specifiers).toContain('../loop/turnService'); + expect(specifiers).not.toContain('../_utils/helper'); + expect( + violations.every( + (v) => v.layer === '_base' && v.target === 'domain' && v.file === '_base/logger.ts', + ), + ).toBe(true); + }); + }); + + describe('positive fixture: di-v3 _utils / _base layering compliant', () => { + let fixtureRoot: string | undefined; + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } + }); + + it('does not flag a compliant base/utils structure', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + mkdirSync(join(fixtureRoot, '_utils'), { recursive: true }); + mkdirSync(join(fixtureRoot, '_base'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'loop'), { recursive: true }); + // _utils imports nothing internal. + writeFileSync(join(fixtureRoot, '_utils', 'helper.ts'), 'export const h = 1;\n'); + // _base only imports _utils (allowed direction). + writeFileSync( + join(fixtureRoot, '_base', 'logger.ts'), + ["import { h } from '../_utils/helper';", ''].join('\n'), + ); + // Domains may freely import _base and _utils (the allowed direction); + // findBaseUtilsViolations only scans _utils/_base, so these are never checked. + writeFileSync( + join(fixtureRoot, 'loop', 'turnService.ts'), + [ + "import { h } from '../_utils/helper';", + "import { Logger } from '../_base/logger';", + '', + ].join('\n'), + ); + + expect(findBaseUtilsViolations(fixtureRoot)).toEqual([]); + }); + }); + + describe('negative fixture: di-v3 cross-domain impl import', () => { + let fixtureRoot: string | undefined; + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } + }); + + it('detects a domain importing another domain concrete impl', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + // Plant the di-v3 infra marker so the cross-domain rule activates. + mkdirSync(join(fixtureRoot, '_base'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'loop'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'kosong'), { recursive: true }); + writeFileSync( + join(fixtureRoot, 'kosong', 'chatProviderService.ts'), + 'export class ChatProviderService {}\n', + ); + writeFileSync(join(fixtureRoot, 'kosong', 'tokenizerService.ts'), 'export class TokenizerService {}\n'); + writeFileSync( + join(fixtureRoot, 'loop', 'turnService.ts'), + [ + // Cross-domain concrete impl import via relative specifier -> flagged. + "import { ChatProviderService } from '../kosong/chatProviderService';", + // Cross-domain concrete impl import via bare package subpath -> flagged. + "import { TokenizerService } from '@moonshot-ai/agent-core/kosong/tokenizerService';", + '', + ].join('\n'), + ); + + const violations = findCrossDomainImplImports(fixtureRoot); + const specifiers = violations.map((v) => v.specifier); + + expect(specifiers).toContain('../kosong/chatProviderService'); + expect(specifiers).toContain('@moonshot-ai/agent-core/kosong/tokenizerService'); + expect( + violations.every( + (v) => + v.file === 'loop/turnService.ts' && v.fromDomain === 'loop' && v.toDomain === 'kosong', + ), + ).toBe(true); + }); + }); + + describe('positive fixture: di-v3 cross-domain impl compliant', () => { + let fixtureRoot: string | undefined; + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } + }); + + it('does not flag type-only, contract, barrel, or same-domain imports across domains', () => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'agent-core-dep-fence-')); + // Plant the di-v3 infra marker so the cross-domain rule activates. + mkdirSync(join(fixtureRoot, '_base'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'loop'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'kosong'), { recursive: true }); + writeFileSync( + join(fixtureRoot, 'kosong', 'chatProviderService.ts'), + 'export class ChatProviderService {}\n', + ); + writeFileSync(join(fixtureRoot, 'kosong', 'chatProvider.ts'), 'export interface IChatProvider {}\n'); + writeFileSync(join(fixtureRoot, 'kosong', 'index.ts'), 'export * from "./chatProvider";\n'); + writeFileSync(join(fixtureRoot, 'loop', 'toolService.ts'), 'export class ToolService {}\n'); + writeFileSync( + join(fixtureRoot, 'loop', 'turnService.ts'), + [ + // type-only import of a concrete impl -> allowed. + "import type { ChatProviderService } from '../kosong/chatProviderService';", + // inline `type` on the only binding -> allowed. + "import { type IChatProvider } from '../kosong/chatProviderService';", + // contract import from `.ts` -> allowed. + "import { IChatProvider } from '../kosong/chatProvider';", + // domain barrel import (`index.ts`) -> allowed. + "import { IChatProvider as IChatProvider2 } from '../kosong';", + // explicit barrel import -> allowed. + "import { IChatProvider as IChatProvider3 } from '../kosong/index';", + // same-domain sibling impl import -> allowed. + "import { ToolService } from './toolService';", + '', + ].join('\n'), + ); + + expect(findCrossDomainImplImports(fixtureRoot)).toEqual([]); + }); + }); +}); diff --git a/packages/agent-core/test/di/auto-inject.test.ts b/packages/agent-core/test/di/auto-inject.test.ts index 26b85c36f..efc4f6584 100644 --- a/packages/agent-core/test/di/auto-inject.test.ts +++ b/packages/agent-core/test/di/auto-inject.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { SyncDescriptor } from '#/di/descriptors'; -import { CyclicDependencyError } from '#/di/errors'; -import { InstantiationService } from '#/di/instantiationService'; -import { IInstantiationService, createDecorator } from '#/di/instantiation'; -import { ServiceCollection } from '#/di/serviceCollection'; +import { SyncDescriptor } from '#/_base/di'; +import { CyclicDependencyError } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import { IInstantiationService, createDecorator } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; /** * P1.1 — `@IFoo` constructor-parameter auto-injection. diff --git a/packages/agent-core/test/di/child.test.ts b/packages/agent-core/test/di/child.test.ts index f7b8ebb6f..2235f7bf0 100644 --- a/packages/agent-core/test/di/child.test.ts +++ b/packages/agent-core/test/di/child.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { SyncDescriptor } from '#/di/descriptors'; -import { InstantiationService } from '#/di/instantiationService'; +import { SyncDescriptor } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; import { IInstantiationService, createDecorator, type IInstantiationService as IInstantiationServiceType, -} from '#/di/instantiation'; -import { Disposable, type IDisposable } from '#/di/lifecycle'; -import { ServiceCollection } from '#/di/serviceCollection'; +} from '#/_base/di'; +import { Disposable, type IDisposable } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; interface ILogger { log(msg: string): void; diff --git a/packages/agent-core/test/di/collection.test.ts b/packages/agent-core/test/di/collection.test.ts index cff5a70af..832ee54e1 100644 --- a/packages/agent-core/test/di/collection.test.ts +++ b/packages/agent-core/test/di/collection.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { SyncDescriptor } from '#/di/descriptors'; -import { createDecorator } from '#/di/instantiation'; -import { ServiceCollection } from '#/di/serviceCollection'; +import { SyncDescriptor } from '#/_base/di'; +import { createDecorator } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; interface ILogger { log(msg: string): void; diff --git a/packages/agent-core/test/di/cyclic.test.ts b/packages/agent-core/test/di/cyclic.test.ts index 909029c1e..d9550a0ce 100644 --- a/packages/agent-core/test/di/cyclic.test.ts +++ b/packages/agent-core/test/di/cyclic.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { SyncDescriptor } from '#/di/descriptors'; -import { CyclicDependencyError } from '#/di/errors'; -import { InstantiationService } from '#/di/instantiationService'; -import { createDecorator, type ServicesAccessor } from '#/di/instantiation'; -import { ServiceCollection } from '#/di/serviceCollection'; +import { SyncDescriptor } from '#/_base/di'; +import { CyclicDependencyError } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import { createDecorator, type ServicesAccessor } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; /** * Cycle-detection tests trigger cycles by capturing the accessor (or the diff --git a/packages/agent-core/test/di/decorator.test.ts b/packages/agent-core/test/di/decorator.test.ts index 3b0dfd75d..b9d5cadb4 100644 --- a/packages/agent-core/test/di/decorator.test.ts +++ b/packages/agent-core/test/di/decorator.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { _util, createDecorator } from '#/di/instantiation'; +import { createDecorator } from '#/_base/di'; +import { _util } from '#/_base/di/test'; /** * P0.3 updates `createDecorator`: diff --git a/packages/agent-core/test/di/delayed.test.ts b/packages/agent-core/test/di/delayed.test.ts index ae794d387..5b67c407a 100644 --- a/packages/agent-core/test/di/delayed.test.ts +++ b/packages/agent-core/test/di/delayed.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { SyncDescriptor } from '#/di/descriptors'; -import { InstantiationService } from '#/di/instantiationService'; -import { IInstantiationService, createDecorator, type IInstantiationService as IInstantiationServiceType } from '#/di/instantiation'; -import { ServiceCollection } from '#/di/serviceCollection'; +import { SyncDescriptor } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import { IInstantiationService, createDecorator, type IInstantiationService as IInstantiationServiceType } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; /** * P1.2 — `supportsDelayedInstantiation: true` returns a Proxy that defers diff --git a/packages/agent-core/test/di/descriptor.test.ts b/packages/agent-core/test/di/descriptor.test.ts index 7004d158d..643a05ae1 100644 --- a/packages/agent-core/test/di/descriptor.test.ts +++ b/packages/agent-core/test/di/descriptor.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import * as descriptorsModule from '#/di/descriptors'; -import { SyncDescriptor, type SyncDescriptor0 } from '#/di/descriptors'; -import { InstantiationType } from '#/di/extensions'; +import * as descriptorsModule from '#/_base/di'; +import { SyncDescriptor, type SyncDescriptor0 } from '#/_base/di'; +import { InstantiationType } from '#/_base/di'; class MyClass { constructor( @@ -57,8 +57,4 @@ describe('InstantiationType', () => { expect(InstantiationType.Eager).toBe(0); expect(InstantiationType.Delayed).toBe(1); }); - - it('is not exported as a runtime value from descriptors', () => { - expect('InstantiationType' in descriptorsModule).toBe(false); - }); }); diff --git a/packages/agent-core/test/di/extensions.test.ts b/packages/agent-core/test/di/extensions.test.ts index 4dfbf7ea1..ffd4b4626 100644 --- a/packages/agent-core/test/di/extensions.test.ts +++ b/packages/agent-core/test/di/extensions.test.ts @@ -1,15 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SyncDescriptor } from '#/di/descriptors'; +import { SyncDescriptor } from '#/_base/di'; import { InstantiationType, _clearRegistryForTests, getSingletonServiceDescriptors, registerSingleton, -} from '#/di/extensions'; -import { createDecorator } from '#/di/instantiation'; -import { InstantiationService } from '#/di/instantiationService'; -import { ServiceCollection } from '#/di/serviceCollection'; +} from '#/_base/di'; +import { createDecorator } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; describe('registerSingleton / getSingletonServiceDescriptors', () => { beforeEach(() => { diff --git a/packages/agent-core/test/di/graph.test.ts b/packages/agent-core/test/di/graph.test.ts index b54f5617b..adb5af1d5 100644 --- a/packages/agent-core/test/di/graph.test.ts +++ b/packages/agent-core/test/di/graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { Graph } from '#/di/graph'; +import { Graph } from '#/_base/di'; /** * Pure data-structure tests for the vendored `Graph` (no DI container diff --git a/packages/agent-core/test/di/instantiation.test.ts b/packages/agent-core/test/di/instantiation.test.ts index a06e678b1..e01d7a867 100644 --- a/packages/agent-core/test/di/instantiation.test.ts +++ b/packages/agent-core/test/di/instantiation.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; -import { SyncDescriptor } from '#/di/descriptors'; -import { InstantiationService } from '#/di/instantiationService'; +import { SyncDescriptor } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; import { createDecorator, type BrandedService, type IConstructorSignature, type ServicesAccessor, -} from '#/di/instantiation'; -import type { IDisposable } from '#/di/lifecycle'; -import { ServiceCollection } from '#/di/serviceCollection'; +} from '#/_base/di'; +import type { IDisposable } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; interface ILogger { log(msg: string): void; diff --git a/packages/agent-core/test/di/lifecycle.test.ts b/packages/agent-core/test/di/lifecycle.test.ts index 72ca0b539..94991e67e 100644 --- a/packages/agent-core/test/di/lifecycle.test.ts +++ b/packages/agent-core/test/di/lifecycle.test.ts @@ -19,11 +19,11 @@ import { toDisposable, type IReference, type IDisposable, -} from '#/di/lifecycle'; +} from '#/_base/di'; import { resetUnexpectedErrorHandler, setUnexpectedErrorHandler, -} from '#/errors/unexpectedError'; +} from '#/_base/errors'; function makeRecorder(label: string, store: string[]): IDisposable { return { diff --git a/packages/agent-core/test/di/self-register.test.ts b/packages/agent-core/test/di/self-register.test.ts index d32c9739f..c9505fd8d 100644 --- a/packages/agent-core/test/di/self-register.test.ts +++ b/packages/agent-core/test/di/self-register.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { IInstantiationService } from '#/di/instantiation'; -import { InstantiationService } from '#/di/instantiationService'; -import { ServiceCollection } from '#/di/serviceCollection'; +import { IInstantiationService } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; /** * P0.5 — the container self-registers under `IInstantiationService`. Any diff --git a/packages/agent-core/test/di/test-instantiation.test.ts b/packages/agent-core/test/di/test-instantiation.test.ts index 44fbe1748..bdd8adf1c 100644 --- a/packages/agent-core/test/di/test-instantiation.test.ts +++ b/packages/agent-core/test/di/test-instantiation.test.ts @@ -1,12 +1,12 @@ import * as sinon from 'sinon'; import { describe, expect, it } from 'vitest'; -import * as mainBarrel from '#/di/index'; -import { createServices, TestInstantiationService } from '#/di/test'; -import { createDecorator } from '#/di/instantiation'; -import { SyncDescriptor } from '#/di/descriptors'; -import { ServiceCollection } from '#/di/serviceCollection'; -import { DisposableStore } from '#/di/lifecycle'; +import * as mainBarrel from '#/_base/di'; +import { createServices, TestInstantiationService } from '#/_base/di/test'; +import { createDecorator } from '#/_base/di'; +import { SyncDescriptor } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; +import { DisposableStore } from '#/_base/di'; interface ILogger { log(msg: string): void; @@ -82,7 +82,7 @@ describe('TestInstantiationService (P1.3)', () => { expect(ctorCount).toBe(1); }); - it('main barrel `#/di/index` does NOT re-export `TestInstantiationService`', () => { + it('main barrel `#/_base/di` does NOT re-export `TestInstantiationService`', () => { expect((mainBarrel as Record)['TestInstantiationService']).toBeUndefined(); }); diff --git a/packages/agent-core/test/di/trace.test.ts b/packages/agent-core/test/di/trace.test.ts index e3f486104..e7268c50e 100644 --- a/packages/agent-core/test/di/trace.test.ts +++ b/packages/agent-core/test/di/trace.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { InstantiationService, Trace } from '#/di/instantiationService'; -import { ServiceCollection } from '#/di/serviceCollection'; +import { InstantiationService, Trace } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; /** * P0.2: `Trace` class + `_enableTracing` ctor param installed. These diff --git a/packages/agent-core/test/errors/unexpectedError.test.ts b/packages/agent-core/test/errors/unexpectedError.test.ts index ca30b85f1..da8284b94 100644 --- a/packages/agent-core/test/errors/unexpectedError.test.ts +++ b/packages/agent-core/test/errors/unexpectedError.test.ts @@ -5,7 +5,7 @@ import { resetUnexpectedErrorHandler, safelyCallListener, setUnexpectedErrorHandler, -} from '#/errors/unexpectedError'; +} from '#/_base/errors'; describe('onUnexpectedError + setUnexpectedErrorHandler', () => { afterEach(() => { diff --git a/packages/agent-core/test/event/event-bus.test.ts b/packages/agent-core/test/event/event-bus.test.ts new file mode 100644 index 000000000..224bf6b8e --- /dev/null +++ b/packages/agent-core/test/event/event-bus.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentEvent } from '#/rpc'; +import { DomainEventBus } from '#/event'; + +const warningEvent = (message: string): AgentEvent => + ({ type: 'warning', message }) satisfies AgentEvent; + +describe('DomainEventBus', () => { + it('delivers to a typed subscriber only when the type matches', () => { + const bus = new DomainEventBus(); + const warningHandler = vi.fn(); + const errorHandler = vi.fn(); + bus.subscribe('warning', warningHandler); + bus.subscribe('error', errorHandler); + + const event = warningEvent('boom'); + bus.publish(event); + + expect(warningHandler).toHaveBeenCalledOnce(); + expect(warningHandler).toHaveBeenCalledWith(event); + expect(errorHandler).not.toHaveBeenCalled(); + }); + + it('delivers every published event to subscribeAll', () => { + const bus = new DomainEventBus(); + const handler = vi.fn(); + bus.subscribeAll(handler); + + const first = warningEvent('one'); + const second = warningEvent('two'); + bus.publish(first); + bus.publish(second); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith(1, first); + expect(handler).toHaveBeenNthCalledWith(2, second); + }); + + it('invokes the forward callback once per publish with the same event', () => { + const forward = vi.fn(); + const bus = new DomainEventBus(forward); + + const event = warningEvent('fwd'); + bus.publish(event); + + expect(forward).toHaveBeenCalledOnce(); + expect(forward).toHaveBeenCalledWith(event); + }); + + it('does not throw when constructed without a forward callback', () => { + const bus = new DomainEventBus(); + expect(() => bus.publish(warningEvent('no-forward'))).not.toThrow(); + }); + + it('stops typed delivery after the subscription is disposed', () => { + const bus = new DomainEventBus(); + const handler = vi.fn(); + const subscription = bus.subscribe('warning', handler); + + bus.publish(warningEvent('before')); + subscription.dispose(); + bus.publish(warningEvent('after')); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('stops subscribeAll delivery after the subscription is disposed', () => { + const bus = new DomainEventBus(); + const handler = vi.fn(); + const subscription = bus.subscribeAll(handler); + + bus.publish(warningEvent('before')); + subscription.dispose(); + bus.publish(warningEvent('after')); + + expect(handler).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/agent-core/test/event/projection.test.ts b/packages/agent-core/test/event/projection.test.ts new file mode 100644 index 000000000..b09d1e068 --- /dev/null +++ b/packages/agent-core/test/event/projection.test.ts @@ -0,0 +1,134 @@ +import type { Event as ProtocolEvent } from '@moonshot-ai/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { DomainEventBus } from '#/event'; +import { shouldProjectToProtocol } from '#/event'; +import type { AgentEvent } from '#/rpc'; +import type { IEventService } from '#/event'; + +// ─── projection boundary model ────────────────────────────────────────────── +// +// `IDomainEventBus` carries bare `AgentEvent`s (no agentId/sessionId). The +// `forward` callback it is constructed with is the projection onto the +// protocol transport bus (`IEventService`): in production it calls +// `agent.rpc.emitEvent`, which the daemon's `BridgeClientAPI.emitEvent` +// re-publishes to `IEventService.publish`. `WSBroadcastService` subscribes to +// `IEventService.onDidPublish`. +// +// These tests pin the boundary encoded by `shouldProjectToProtocol`: +// - which representative domain events project (current policy: all), and +// - that the projection still feeds `IEventService.onDidPublish` so +// `WSBroadcastService`'s input stream is unchanged. + +const AGENT_ID = 'agent-1'; +const SESSION_ID = 'session-1'; + +const statusEvent = (): AgentEvent => + ({ type: 'agent.status.updated', planMode: false }) satisfies AgentEvent; + +const turnStartedEvent = (): AgentEvent => + ({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }) satisfies AgentEvent; + +const warningEvent = (message: string): AgentEvent => + ({ type: 'warning', message }) satisfies AgentEvent; + +/** Stamp a bare domain event with the agent/session ids the protocol bus requires. */ +function toProtocol(event: AgentEvent): ProtocolEvent { + return { ...event, agentId: AGENT_ID, sessionId: SESSION_ID } as ProtocolEvent; +} + +interface EventServiceStub extends IEventService { + readonly published: ProtocolEvent[]; +} + +function makeEventService(): EventServiceStub { + const listeners = new Set<(event: ProtocolEvent) => void>(); + const published: ProtocolEvent[] = []; + return { + _serviceBrand: undefined, + published, + publish(event: ProtocolEvent): void { + published.push(event); + for (const listener of listeners) listener(event); + }, + onDidPublish(listener) { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + }; +} + +/** + * Wire a `DomainEventBus` the way `agent/factory.ts` does, but route the + * projection through `shouldProjectToProtocol` so the helper is the single + * source of truth for what reaches `IEventService`. + */ +function makeProjectingBus(eventService: IEventService): DomainEventBus { + return new DomainEventBus((event: AgentEvent) => { + if (shouldProjectToProtocol(event)) { + eventService.publish(toProtocol(event)); + } + }); +} + +describe('shouldProjectToProtocol (domain → protocol projection boundary)', () => { + it('projects an agent status event', () => { + expect(shouldProjectToProtocol(statusEvent())).toBe(true); + }); + + it('projects a turn lifecycle event', () => { + expect(shouldProjectToProtocol(turnStartedEvent())).toBe(true); + }); + + it('projects a diagnostic warning event', () => { + expect(shouldProjectToProtocol(warningEvent('boom'))).toBe(true); + }); + + it('keeps WSBroadcastService input unchanged: the projection still feeds IEventService.onDidPublish', () => { + const eventService = makeEventService(); + const bus = makeProjectingBus(eventService); + const received = vi.fn(); + const subscription = eventService.onDidPublish(received); + + const status = statusEvent(); + const turn = turnStartedEvent(); + const warning = warningEvent('fwd'); + bus.publish(status); + bus.publish(turn); + bus.publish(warning); + + // Every published domain event reaches the protocol bus exactly once, + // stamped with agentId/sessionId — i.e. the stream WSBroadcastService + // subscribes to is unchanged. + expect(received).toHaveBeenCalledTimes(3); + expect(eventService.published).toEqual([ + toProtocol(status), + toProtocol(turn), + toProtocol(warning), + ]); + for (const event of eventService.published) { + expect(event.agentId).toBe(AGENT_ID); + expect(event.sessionId).toBe(SESSION_ID); + } + + subscription.dispose(); + }); + + it('keeps in-process subscribers unaffected by the projection gate', () => { + // Projection is orthogonal to in-process delivery: gating the `forward` + // callback must not swallow events from `IDomainEventBus` subscribers. + const eventService = makeEventService(); + const bus = makeProjectingBus(eventService); + const inProcess = vi.fn(); + const subscription = bus.subscribe('warning', inProcess); + + const warning = warningEvent('local'); + bus.publish(warning); + + expect(inProcess).toHaveBeenCalledOnce(); + expect(inProcess).toHaveBeenCalledWith(warning); + expect(eventService.published).toEqual([toProtocol(warning)]); + + subscription.dispose(); + }); +}); diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 84e0edffe..3fffbc3d1 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -5,7 +5,7 @@ import { join } from 'pathe'; import { APIConnectionError, APIStatusError, type ProviderConfig } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ProviderManager } from '../../src/session/provider-manager'; +import { ProviderService, type IProviderService } from '../../src/session/provider-manager'; import type { AgentOptions } from '../../src/agent'; import type { KimiConfig } from '../../src/config'; import { ErrorCodes, KimiError } from '../../src/errors'; @@ -42,8 +42,8 @@ async function makeTempDir(): Promise { return dir; } -function testProviderManager(): ProviderManager { - return new ProviderManager({ +function testProviderManager() : IProviderService { + return new ProviderService({ config: { providers: { test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey } }, models: { [MOCK_PROVIDER.model]: { provider: 'test', model: MOCK_PROVIDER.model, maxContextSize: 1_000_000 } }, diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 2884099e4..73d14645a 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -14,8 +14,8 @@ import { import { __resetRootLoggerForTest, getRootLogger, -} from '../../src/logging/logger'; -import { resolveLoggingConfig } from '../../src/logging/resolve-config'; +} from '#/_base/logging'; +import { resolveLoggingConfig } from '#/_base/logging'; import { recordingContextTelemetry, type TelemetryContextRecord, diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index f660e2e99..ce4d63c34 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -20,8 +20,8 @@ import { __resetRootLoggerForTest, getRootLogger, resolveGlobalLogPath, -} from '../../src/logging/logger'; -import { resolveLoggingConfig } from '../../src/logging/resolve-config'; +} from '#/_base/logging'; +import { resolveLoggingConfig } from '#/_base/logging'; import type { OAuthTokenProviderResolver } from '../../src/session/provider-manager'; import { testKaos } from '../fixtures/test-kaos'; @@ -168,7 +168,7 @@ micro_compaction = false workDir, model: 'default-mock', }); - const session = core.sessions.get(created.id); + const session = core.sessions.get(created.id)?.session; const mainAgent = session?.getReadyAgent('main'); expect(session?.experimentalFlags.enabled('micro_compaction')).toBe(false); @@ -234,7 +234,7 @@ custom_headers = { "X-Test" = "1" } }); const created = await rpc.createSession({ id: 'ses_runtime_service_oauth', workDir }); - const session = core.sessions.get(created.id); + const session = core.sessions.get(created.id)?.session; expect(resolveOAuthTokenProvider).toHaveBeenCalledWith('managed:kimi-code', { storage: 'file', @@ -383,7 +383,7 @@ max_context_size = 100000 workDir, model: 'default-mock', }); - const before = core.sessions.get(created.id); + const before = core.sessions.get(created.id)?.session; expect(before?.options.toolServices?.webSearcher).toBeUndefined(); await writeFile( @@ -395,7 +395,7 @@ base_url = "https://search.example.test/v1" ); const reloaded = await rpc.reloadSession({ sessionId: created.id }); - const after = core.sessions.get(created.id); + const after = core.sessions.get(created.id)?.session; expect(after).toBeDefined(); expect(after).not.toBe(before); diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index 38e1f4236..66d978854 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -162,7 +162,7 @@ describe('HarnessAPI session skills', () => { args: 'src/app.ts', }); await waitForEvent(events, (event) => event.type === 'skill.activated'); - await core.sessions.get(created.id)?.flushMetadata(); + await core.sessions.get(created.id)?.session.flushMetadata(); const skillEvent = events.find((event) => event.type === 'skill.activated'); expect(skillEvent).toMatchObject({ @@ -279,7 +279,7 @@ describe('HarnessAPI session skills', () => { name: 'templated-review', args: '"src/app.ts" careful', }); - await core.sessions.get(created.id)?.flushMetadata(); + await core.sessions.get(created.id)?.session.flushMetadata(); const records = await readMainWire(created.sessionDir); const prompt = records.find((record) => record['type'] === 'turn.prompt'); @@ -324,7 +324,7 @@ describe('HarnessAPI session skills', () => { agentId: 'main', name: 'brainstorm', }); - await core.sessions.get(created.id)?.flushMetadata(); + await core.sessions.get(created.id)?.session.flushMetadata(); const records = await readMainWire(created.sessionDir); const prompt = records.find((record) => record['type'] === 'turn.prompt'); @@ -357,7 +357,7 @@ describe('HarnessAPI session skills', () => { name: 'unsafe-args', args: '', }); - await core.sessions.get(created.id)?.flushMetadata(); + await core.sessions.get(created.id)?.session.flushMetadata(); const records = await readMainWire(created.sessionDir); const prompt = records.find((record) => record['type'] === 'turn.prompt'); @@ -428,7 +428,7 @@ describe('HarnessAPI session skills', () => { args: 'src/app.ts', }); await waitForEvent(first.events, (event) => event.type === 'skill.activated'); - await first.core.sessions.get(created.id)?.flushMetadata(); + await first.core.sessions.get(created.id)?.session.flushMetadata(); const second = await createTestRpc(); const resumed = await second.rpc.resumeSession({ sessionId: created.id }); @@ -506,7 +506,7 @@ describe('HarnessAPI session skills', () => { name: 'bundled-tool', }); await waitForEvent(first.events, (event) => event.type === 'skill.activated'); - await first.core.sessions.get(created.id)?.flushMetadata(); + await first.core.sessions.get(created.id)?.session.flushMetadata(); // Resume in a completely fresh runtime — nothing in memory, the context is // rebuilt from disk exactly as the model would see it on the next turn. @@ -547,7 +547,7 @@ describe('HarnessAPI session skills', () => { disableModelInvocation: true, }); - const session = core.sessions.get(created.id); + const session = core.sessions.get(created.id)?.session; expect(session).toBeDefined(); const invocable = session!.skills.listInvocableSkills(); expect(invocable.some((skill) => skill.name === 'mcp-config')).toBe(false); @@ -614,7 +614,7 @@ describe('HarnessAPI session skills', () => { code: 'skill.not_found', }); - const session = core.sessions.get(created.id); + const session = core.sessions.get(created.id)?.session; session?.skills.registerBuiltinSkill({ name: 'forked', description: 'Forked skill', diff --git a/packages/agent-core/test/logging/formatter.test.ts b/packages/agent-core/test/logging/formatter.test.ts index 401b7567e..55dcadd1a 100644 --- a/packages/agent-core/test/logging/formatter.test.ts +++ b/packages/agent-core/test/logging/formatter.test.ts @@ -8,8 +8,8 @@ import { extractError, formatEntry, redactCtx, -} from '#/logging/formatter'; -import type { LogEntry } from '#/logging/types'; +} from '#/_base/logging'; +import type { LogEntry } from '#/_base/logging'; const FIXED_TIME = Date.UTC(2026, 4, 19, 10, 12, 30, 123); diff --git a/packages/agent-core/test/logging/logger.test.ts b/packages/agent-core/test/logging/logger.test.ts index 2b70d2011..609ed808f 100644 --- a/packages/agent-core/test/logging/logger.test.ts +++ b/packages/agent-core/test/logging/logger.test.ts @@ -10,7 +10,7 @@ import { log, redact, resolveGlobalLogPath, -} from '#/logging/logger'; +} from '#/_base/logging'; let homeDir: string; diff --git a/packages/agent-core/test/logging/sinks.test.ts b/packages/agent-core/test/logging/sinks.test.ts index 48a199f21..e2fa494da 100644 --- a/packages/agent-core/test/logging/sinks.test.ts +++ b/packages/agent-core/test/logging/sinks.test.ts @@ -4,7 +4,7 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PENDING_MAX, RotatingFileSink } from '#/logging/sinks'; +import { PENDING_MAX, RotatingFileSink } from '#/_base/logging'; let workDir: string; diff --git a/packages/agent-core/test/loop/abort.e2e.test.ts b/packages/agent-core/test/loop/abort.e2e.test.ts index c0f8708fd..95dbd29f2 100644 --- a/packages/agent-core/test/loop/abort.e2e.test.ts +++ b/packages/agent-core/test/loop/abort.e2e.test.ts @@ -11,7 +11,7 @@ import { inputTotal } from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; import type { LLMChatResponse, LoopHooks } from '../../src/loop/index'; -import { userCancellationReason } from '../../src/utils/abort'; +import { userCancellationReason } from '#/_utils/abort'; import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm'; import { runTurn } from './fixtures/helpers'; import { EchoTool, GatedTool, markReadFileAccesses, SlowTool } from './fixtures/tools'; diff --git a/packages/agent-core/test/loop/error-paths.e2e.test.ts b/packages/agent-core/test/loop/error-paths.e2e.test.ts index 66bd88cb4..556d7c238 100644 --- a/packages/agent-core/test/loop/error-paths.e2e.test.ts +++ b/packages/agent-core/test/loop/error-paths.e2e.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'; import { ErrorCodes, KimiError } from '../../src/errors'; -import type { Logger, LogPayload } from '../../src/logging'; +import type { Logger, LogPayload } from '#/_base/logging'; import type { LoopHooks } from '../../src/loop/index'; import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm'; import { runTurn, runTurnExpectingThrow } from './fixtures/helpers'; diff --git a/packages/agent-core/test/loop/fixtures/helpers.ts b/packages/agent-core/test/loop/fixtures/helpers.ts index f8c1a2036..d57b46d0c 100644 --- a/packages/agent-core/test/loop/fixtures/helpers.ts +++ b/packages/agent-core/test/loop/fixtures/helpers.ts @@ -5,7 +5,7 @@ import type { LoopLiveEventEmitter, TurnResult, } from '../../../src/loop/index'; -import type { Logger } from '../../../src/logging'; +import type { Logger } from '#/_base/logging'; import { createLoopEventDispatcher, runTurn as runTurnImpl } from '../../../src/loop/index'; import { CollectingSink, type SinkErrorMode } from './collecting-sink'; import { FakeLLM, type FakeLLMResponse } from './fake-llm'; diff --git a/packages/agent-core/test/loop/tool-call.e2e.test.ts b/packages/agent-core/test/loop/tool-call.e2e.test.ts index 32dfe34d6..b717e3ab4 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -12,7 +12,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; import { ToolAccesses } from '../../src/loop'; -import type { Logger } from '../../src/logging'; +import type { Logger } from '#/_base/logging'; import type { ExecutableTool, ExecutableToolResult, LoopHooks, ToolExecution } from '../../src/loop'; import { PathSecurityError } from '../../src/tools/policies/path-access'; import { diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index a6930b948..32678651a 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -21,7 +21,7 @@ import type { import { z } from 'zod'; import { KimiError } from '../../src/errors'; -import { ProviderManager } from '../../src/session/provider-manager'; +import { ProviderService, type IProviderService } from '../../src/session/provider-manager'; import { McpConnectionManager, type McpServerEntry } from '../../src/mcp/connection-manager'; import { JsonFileStore, McpOAuthService } from '../../src/mcp/oauth'; import type { AgentEvent, SDKSessionRPC } from '../../src/rpc'; @@ -921,8 +921,8 @@ describe('Session MCP startup', () => { }, 10_000); }); -function testProviderManager(): ProviderManager { - return new ProviderManager({ +function testProviderManager() : IProviderService { + return new ProviderService({ config: { providers: { test: { diff --git a/packages/agent-core/test/rpc/core-impl.test.ts b/packages/agent-core/test/rpc/core-impl.test.ts new file mode 100644 index 000000000..babd545b7 --- /dev/null +++ b/packages/agent-core/test/rpc/core-impl.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { PluginRecord, PluginSummary } from '../../src/plugin'; +import { KimiCore } from '../../src/rpc/core-impl'; +import { getCoreVersion } from '../../src/version'; + +const tempDirs: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeHome(configToml?: string): Promise { + const home = await mkdtemp(path.join(tmpdir(), 'kimi-core-wire-')); + tempDirs.push(home); + if (configToml !== undefined) { + await writeFile(path.join(home, 'config.toml'), configToml, 'utf-8'); + } + return home; +} + +function makeCore(home: string): KimiCore { + return new KimiCore(async () => ({}) as never, { homeDir: home }); +} + +const VALID_TOML = ` +default_model = "k2" + +[providers.kimi] +type = "kimi" +api_key = "sk-good" + +[models.k2] +provider = "kimi" +model = "kimi-for-coding" +max_context_size = 128000 +`; + +function makePluginRecord(id: string): PluginRecord { + return { + id, + root: `/tmp/${id}`, + source: 'local-path', + enabled: true, + state: 'ok', + installedAt: '2026-06-21T00:00:00.000Z', + skillCount: 0, + diagnostics: [], + }; +} + +function makePluginSummary(id: string): PluginSummary { + return { + id, + displayName: id, + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hasErrors: false, + source: 'local-path', + }; +} + +describe('KimiCore wire controller delegates CoreAPI methods to domain services', () => { + it('listSessions delegates to the session store', async () => { + const core = makeCore(await makeHome()); + await expect(core.listSessions({})).resolves.toEqual([]); + }); + + it('getKimiConfig / getConfigDiagnostics delegate to the loaded config', async () => { + const core = makeCore(await makeHome(VALID_TOML)); + const config = await core.getKimiConfig({}); + expect(config.providers['kimi']).toBeDefined(); + await expect(core.getConfigDiagnostics({})).resolves.toEqual({ warnings: [] }); + }); + + it('getCoreInfo returns the core version', async () => { + const core = makeCore(await makeHome()); + expect(core.getCoreInfo()).toEqual({ version: getCoreVersion() }); + }); + + it('session-scoped methods route through the session registry before SessionAPIImpl', async () => { + const core = makeCore(await makeHome()); + // No session registered -> the sessionApi() lookup fails before reaching + // SessionAPIImpl, proving the registry is the wire seam for per-session calls. + expect(() => core.getModel({ sessionId: 'missing', agentId: 'main' })).toThrow(/was not found/); + }); + + it('installPlugin delegates to the plugin service install + summaries', async () => { + const core = makeCore(await makeHome()); + // Settle the initial plugin load so assertPluginsLoaded() passes. + await expect(core.listPlugins({})).resolves.toEqual([]); + + const record = makePluginRecord('demo'); + const summary = makePluginSummary('demo'); + const installSpy = vi.spyOn(core.plugins, 'install').mockResolvedValue(record); + const summariesSpy = vi.spyOn(core.plugins, 'summaries').mockReturnValue([summary]); + + const result = await core.installPlugin({ source: '/tmp/whatever' }); + + expect(installSpy).toHaveBeenCalledWith('/tmp/whatever'); + expect(summariesSpy).toHaveBeenCalled(); + expect(result).toBe(summary); + }); + + it('listPlugins delegates to the plugin service summaries', async () => { + const core = makeCore(await makeHome()); + await expect(core.listPlugins({})).resolves.toEqual([]); + + const summary = makePluginSummary('demo'); + vi.spyOn(core.plugins, 'summaries').mockReturnValue([summary]); + + await expect(core.listPlugins({})).resolves.toEqual([summary]); + }); +}); diff --git a/packages/agent-core/test/scope/builder.test.ts b/packages/agent-core/test/scope/builder.test.ts new file mode 100644 index 000000000..8e7ca977e --- /dev/null +++ b/packages/agent-core/test/scope/builder.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InstantiationType, _clearRegistryForTests } from '#/_base/di'; +import { IInstantiationService, createDecorator } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; +import { AgentScopeBuilder, SessionScopeBuilder } from '#/scope/builder'; +import { IAgentContext, ISessionContext } from '#/scope/context/index'; +import { LifecycleScope } from '#/scope/lifecycle'; +import { + _resetScopeRegistryForTests, + isBuilt, + registerScopedService, +} from '#/scope/registry'; + +interface IPinger { + ping(): string; +} + +class Pinger implements IPinger { + static constructed = 0; + constructor() { + Pinger.constructed += 1; + } + ping(): string { + return 'pong'; + } +} + +function sessionContext(id: string): ISessionContext { + return { + id, + abortSignal: new AbortController().signal, + executionScope: undefined, + }; +} + +function agentContext(id: string, parentId: string): IAgentContext { + return { + id, + parentId, + abortSignal: new AbortController().signal, + executionScope: undefined, + }; +} + +describe('ScopeBuilder + IScopeHandle', () => { + beforeEach(() => { + _resetScopeRegistryForTests(); + _clearRegistryForTests(); + Pinger.constructed = 0; + }); + + afterEach(() => { + _resetScopeRegistryForTests(); + _clearRegistryForTests(); + vi.restoreAllMocks(); + }); + + it('builds a Session scope: identity context injected, Pattern-1 services installed, parent.createChild called', () => { + const IPingerId = createDecorator('p13-session-pinger'); + registerScopedService( + LifecycleScope.Session, + IPingerId, + Pinger, + InstantiationType.Delayed, + ); + + const parent = new InstantiationService(); + const createChild = vi.spyOn(parent, 'createChild'); + + const handle = new SessionScopeBuilder().build(parent, sessionContext('s1')); + + expect(handle.id).toBe('s1'); + expect(handle.scope).toBe(LifecycleScope.Session); + expect(createChild).toHaveBeenCalledTimes(1); + expect(createChild.mock.calls[0]![0]).toBeInstanceOf(ServiceCollection); + + // Identity context injected into the child container. + const ctx = handle.accessor.get(ISessionContext); + expect(ctx.id).toBe('s1'); + expect(ctx.parentId).toBeUndefined(); + + // Pattern-1 service installed and resolvable through the accessor. + expect(handle.accessor.get(IPingerId).ping()).toBe('pong'); + }); + + it('builds an Agent scope as a child of Session (parentId + DI parent chain)', () => { + const root = new InstantiationService(); + const session = new SessionScopeBuilder().build(root, sessionContext('s1')); + + // The container self-registers under IInstantiationService, so the + // Session's child container is reachable through its accessor and becomes + // the Agent builder's parent. + const sessionContainer = session.accessor.get(IInstantiationService); + const agent = new AgentScopeBuilder().build( + sessionContainer, + agentContext('a1', 's1'), + ); + + expect(agent.id).toBe('a1'); + expect(agent.scope).toBe(LifecycleScope.Agent); + + const aCtx = agent.accessor.get(IAgentContext); + expect(aCtx.id).toBe('a1'); + expect(aCtx.parentId).toBe('s1'); + + // Agent resolves the Session identity through the DI parent chain. + expect(agent.accessor.get(ISessionContext).id).toBe('s1'); + }); + + it('resolves Pattern-1 services lazily (not instantiated at build)', () => { + const IPingerId = createDecorator('p13-lazy-pinger'); + registerScopedService( + LifecycleScope.Session, + IPingerId, + Pinger, + InstantiationType.Delayed, + ); + + const handle = new SessionScopeBuilder().build( + new InstantiationService(), + sessionContext('s1'), + ); + + // Not instantiated at build time. + expect(Pinger.constructed).toBe(0); + + // get() returns a lazy proxy; the ctor still has not run. + const proxy = handle.accessor.get(IPingerId); + expect(Pinger.constructed).toBe(0); + + // First real use triggers construction. + expect(proxy.ping()).toBe('pong'); + expect(Pinger.constructed).toBe(1); + + // The proxy is cached on subsequent access. + expect(handle.accessor.get(IPingerId)).toBe(proxy); + }); + + it('dispose fires onWillDispose → disposes child services → onDidDispose in order', async () => { + const order: string[] = []; + interface ITracked { + readonly marker: string; + touch(): void; + } + class Tracked implements ITracked { + readonly marker = 'tracked'; + touch(): void { + // no-op; realizing the lazy proxy forces construction. + } + dispose(): void { + order.push('service.dispose'); + } + } + const ITrackedId = createDecorator('p13-order-tracked'); + registerScopedService( + LifecycleScope.Session, + ITrackedId, + Tracked, + InstantiationType.Delayed, + ); + + const handle = new SessionScopeBuilder().build( + new InstantiationService(), + sessionContext('s1'), + ); + // Realize the lazy proxy so the instance is constructed and tracked for + // disposal during teardown. + handle.accessor.get(ITrackedId).touch(); + + handle.onWillDispose(() => order.push('onWillDispose')); + handle.onDidDispose(() => order.push('onDidDispose')); + + await handle.dispose(); + + expect(order).toEqual(['onWillDispose', 'service.dispose', 'onDidDispose']); + }); + + it('onWillDispose listeners can read child services; onDidDispose listeners cannot', async () => { + const IPingerId = createDecorator('p13-dispose-pinger'); + registerScopedService( + LifecycleScope.Session, + IPingerId, + Pinger, + InstantiationType.Delayed, + ); + + const handle = new SessionScopeBuilder().build( + new InstantiationService(), + sessionContext('s1'), + ); + + let willRead: string | undefined; + let didThrew = false; + + handle.onWillDispose(() => { + // Data still present — resolves successfully. + willRead = handle.accessor.get(IPingerId).ping(); + }); + handle.onDidDispose(() => { + // Data gone — container disposed, resolving throws. + try { + handle.accessor.get(IPingerId); + } catch { + didThrew = true; + } + }); + + await handle.dispose(); + + expect(willRead).toBe('pong'); + expect(didThrew).toBe(true); + }); + + it('awaits async onWillDispose listeners before disposing child services', async () => { + const order: string[] = []; + const handle = new SessionScopeBuilder().build( + new InstantiationService(), + sessionContext('s1'), + ); + + handle.onWillDispose(async () => { + await Promise.resolve(); + order.push('onWillDispose.async'); + }); + handle.onDidDispose(() => order.push('onDidDispose')); + + await handle.dispose(); + + expect(order).toEqual(['onWillDispose.async', 'onDidDispose']); + }); + + it('calls markBuilt() on the first build; later registrations warn and are ignored', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + expect(isBuilt()).toBe(false); + new SessionScopeBuilder().build(new InstantiationService(), sessionContext('s1')); + expect(isBuilt()).toBe(true); + + const ILateId = createDecorator('p13-late-pinger'); + registerScopedService( + LifecycleScope.Session, + ILateId, + Pinger, + InstantiationType.Delayed, + ); + + expect(warn).toHaveBeenCalled(); + expect(String(warn.mock.calls[0]![0])).toMatch(/after the first/); + }); +}); diff --git a/packages/agent-core/test/scope/context.test.ts b/packages/agent-core/test/scope/context.test.ts new file mode 100644 index 000000000..62a31eb56 --- /dev/null +++ b/packages/agent-core/test/scope/context.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { _util } from '#/_base/di/test'; +import { + IAgentContext, + ISessionContext, + IToolCallContext, + ITurnContext, +} from '#/scope/context/index'; + +describe('scope identity contexts (I*Context)', () => { + it('each context decorator is a distinct ServiceIdentifier with the canonical name', () => { + const decorators = [ + { id: ISessionContext, name: 'sessionContext' }, + { id: IAgentContext, name: 'agentContext' }, + { id: ITurnContext, name: 'turnContext' }, + { id: IToolCallContext, name: 'toolCallContext' }, + ]; + + // Each is a callable ServiceIdentifier whose toString() is the decorator name. + for (const { id, name } of decorators) { + expect(typeof id).toBe('function'); + expect(id.toString()).toBe(name); + } + + // All four are distinct objects (createDecorator produced separate ids). + const unique = new Set(decorators.map((d) => d.id)); + expect(unique.size).toBe(decorators.length); + }); + + it('ISessionContext has id / abortSignal / executionScope and parentId undefined', () => { + const ctx: ISessionContext = { + id: 'session-1', + parentId: undefined, + abortSignal: new AbortController().signal, + executionScope: undefined, + }; + + expect(ctx.id).toBe('session-1'); + expect(ctx.parentId).toBeUndefined(); + expect(ctx.abortSignal).toBeInstanceOf(AbortSignal); + expect('executionScope' in ctx).toBe(true); + }); + + it('IAgentContext has id / abortSignal / executionScope and string parentId (sessionId)', () => { + const ctx: IAgentContext = { + id: 'agent-1', + parentId: 'session-1', + abortSignal: new AbortController().signal, + executionScope: undefined, + }; + + expect(ctx.id).toBe('agent-1'); + expect(typeof ctx.parentId).toBe('string'); + expect(ctx.parentId).toBe('session-1'); + expect(ctx.abortSignal).toBeInstanceOf(AbortSignal); + expect('executionScope' in ctx).toBe(true); + }); + + it('ITurnContext has id / abortSignal / executionScope and string parentId (agentId)', () => { + const ctx: ITurnContext = { + id: 'turn-1', + parentId: 'agent-1', + abortSignal: new AbortController().signal, + executionScope: undefined, + }; + + expect(ctx.id).toBe('turn-1'); + expect(typeof ctx.parentId).toBe('string'); + expect(ctx.parentId).toBe('agent-1'); + expect(ctx.abortSignal).toBeInstanceOf(AbortSignal); + expect('executionScope' in ctx).toBe(true); + }); + + it('IToolCallContext has id / abortSignal / executionScope and string parentId (turnId)', () => { + const ctx: IToolCallContext = { + id: 'toolCall-1', + parentId: 'turn-1', + abortSignal: new AbortController().signal, + executionScope: undefined, + }; + + expect(ctx.id).toBe('toolCall-1'); + expect(typeof ctx.parentId).toBe('string'); + expect(ctx.parentId).toBe('turn-1'); + expect(ctx.abortSignal).toBeInstanceOf(AbortSignal); + expect('executionScope' in ctx).toBe(true); + }); + + it('decorators register a constructor parameter dependency in the DI metadata', () => { + // Simulate what `class C { constructor(@IAgentContext ctx) {} }` emits: + // the decorator is invoked as (ctor, undefined, paramIndex). + class Consumer { + public constructor(_ctx: IAgentContext) { + // identity-only; no behavior needed for this assertion + } + } + + IAgentContext(Consumer, undefined, 0); + + const deps = _util.getServiceDependencies( + Consumer as unknown as _util.DI_TARGET_OBJ, + ); + expect(deps).toEqual([{ id: IAgentContext, index: 0 }]); + }); +}); diff --git a/packages/agent-core/test/scope/index.test.ts b/packages/agent-core/test/scope/index.test.ts new file mode 100644 index 000000000..feabf9af8 --- /dev/null +++ b/packages/agent-core/test/scope/index.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import * as scope from '#/scope/index'; +import type { + IChildLifecycleEvent, + IManagerEventBus, + IManagerService, + IScopeHandle, + IServiceAccessor, +} from '#/scope/index'; +import * as root from '#/index'; + +// Compile-time helper: referencing a type parameter inside a call expression +// proves the type is exported and resolves, without emitting runtime logic or +// triggering unused-binding warnings. +function assertType(): void { + // intentionally empty — the type parameter is the assertion. +} + +describe('scope barrel (#/scope)', () => { + it('exports the scope surface as values', () => { + // lifecycle enum (Core → Session → Agent → Turn → ToolCall) + expect(scope.LifecycleScope).toBeDefined(); + expect(scope.LifecycleScope.Core).toBe('core'); + expect(scope.LifecycleScope.Session).toBe('session'); + expect(scope.LifecycleScope.Agent).toBe('agent'); + expect(scope.LifecycleScope.Turn).toBe('turn'); + expect(scope.LifecycleScope.ToolCall).toBe('toolCall'); + + // Pattern-1 registry entry points + expect(typeof scope.registerScopedService).toBe('function'); + expect(typeof scope.getScopedServiceDescriptors).toBe('function'); + expect(typeof scope.markBuilt).toBe('function'); + expect(typeof scope.isBuilt).toBe('function'); + + // builders + expect(typeof scope.ScopeBuilder).toBe('function'); + expect(typeof scope.SessionScopeBuilder).toBe('function'); + expect(typeof scope.AgentScopeBuilder).toBe('function'); + expect(typeof scope.TurnScopeBuilder).toBe('function'); + + // manager base + expect(typeof scope.ScopeManager).toBe('function'); + + // identity contexts (declaration-merged interface + decorator value) + expect(typeof scope.ISessionContext).toBe('function'); + expect(typeof scope.IAgentContext).toBe('function'); + expect(typeof scope.ITurnContext).toBe('function'); + expect(typeof scope.IToolCallContext).toBe('function'); + }); + + it('exports the scope contract types', () => { + assertType(); + assertType(); + assertType>(); + assertType>(); + assertType(); + }); + + it('constructs concrete builders from the barrel', () => { + expect(new scope.SessionScopeBuilder()).toBeInstanceOf(scope.ScopeBuilder); + expect(new scope.AgentScopeBuilder()).toBeInstanceOf(scope.ScopeBuilder); + expect(new scope.TurnScopeBuilder()).toBeInstanceOf(scope.ScopeBuilder); + }); +}); + +describe('top-level barrel (#/index)', () => { + it('re-exports the scope surface from @moonshot-ai/agent-core', () => { + // representative symbols from each scope module must be the SAME bindings + // reachable through the top-level barrel. + expect(root.LifecycleScope).toBe(scope.LifecycleScope); + expect(root.registerScopedService).toBe(scope.registerScopedService); + expect(root.getScopedServiceDescriptors).toBe(scope.getScopedServiceDescriptors); + expect(root.markBuilt).toBe(scope.markBuilt); + expect(root.isBuilt).toBe(scope.isBuilt); + expect(root.ScopeBuilder).toBe(scope.ScopeBuilder); + expect(root.SessionScopeBuilder).toBe(scope.SessionScopeBuilder); + expect(root.AgentScopeBuilder).toBe(scope.AgentScopeBuilder); + expect(root.TurnScopeBuilder).toBe(scope.TurnScopeBuilder); + expect(root.ScopeManager).toBe(scope.ScopeManager); + expect(root.ISessionContext).toBe(scope.ISessionContext); + expect(root.IAgentContext).toBe(scope.IAgentContext); + expect(root.ITurnContext).toBe(scope.ITurnContext); + expect(root.IToolCallContext).toBe(scope.IToolCallContext); + }); +}); diff --git a/packages/agent-core/test/scope/manager.test.ts b/packages/agent-core/test/scope/manager.test.ts new file mode 100644 index 000000000..207744fe0 --- /dev/null +++ b/packages/agent-core/test/scope/manager.test.ts @@ -0,0 +1,351 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Emitter, type Event } from '#/_base/event'; +import { type ServiceIdentifier, createDecorator } from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import type { IDisposable } from '#/_base/di'; +import type { IScopeHandle, IServiceAccessor } from '#/scope/handle'; +import { LifecycleScope } from '#/scope/lifecycle'; +import { + type IManagerEventBus, + ScopeManager, +} from '#/scope/manager'; + +// --------------------------------------------------------------------------- +// Test doubles: FakeManager (parent-scope manager) + FakeChild (child scope). +// These validate the manager pattern; they are NOT real domain managers. +// --------------------------------------------------------------------------- + +/** A per-child event source that lives inside the child scope. */ +interface IFakeChildSource { + readonly _serviceBrand: undefined; + readonly onDidChange: Event<{ value: number }>; + setValue(value: number): void; +} +const IFakeChildSource = createDecorator('p14-fake-child-source'); + +/** A handle to the manager itself — used to prove a child cannot resolve it. */ +interface IFakeManagerHandle { + readonly _serviceBrand: undefined; +} +const IFakeManagerHandle = createDecorator('p14-fake-manager'); + +class FakeChildSource implements IFakeChildSource { + readonly _serviceBrand: undefined; + private readonly _onDidChange = new Emitter<{ value: number }>(); + readonly onDidChange = this._onDidChange.event; + private _disposed = false; + + setValue(value: number): void { + if (this._disposed) { + throw new Error('FakeChildSource disposed'); + } + this._onDidChange.fire({ value }); + } + + dispose(): void { + this._disposed = true; + this._onDidChange.dispose(); + } +} + +interface FakeChildOptions { + /** When set, `dispose()` rejects with this error (to exercise try/finally). */ + readonly disposeError?: Error; +} + +/** + * A child scope handle carrying a fake per-scope event source. Its `accessor` + * resolves only `IFakeChildSource`; after `dispose()` it throws (data gone), + * mirroring `ScopeHandle` semantics. + */ +class FakeChild implements IScopeHandle { + readonly scope = LifecycleScope.Agent; + readonly accessor: IServiceAccessor; + readonly onWillDispose: Event; + readonly onDidDispose: Event; + + private readonly _onWillDispose = new Emitter(); + private readonly _onDidDispose = new Emitter(); + private readonly source: FakeChildSource; + private readonly disposeError?: Error; + private _disposed = false; + + constructor(readonly id: string, options: FakeChildOptions = {}) { + this.disposeError = options.disposeError; + this.source = new FakeChildSource(); + this.onWillDispose = this._onWillDispose.event; + this.onDidDispose = this._onDidDispose.event; + this.accessor = { + get: (serviceId: ServiceIdentifier): T => { + if (this._disposed) { + throw new Error(`FakeChild "${this.id}" already disposed`); + } + if (serviceId === IFakeChildSource) { + return this.source as unknown as T; + } + throw new Error(`FakeChild "${this.id}" cannot resolve ${String(serviceId)}`); + }, + }; + } + + /** Test hook: drive the child event source directly. */ + emit(value: number): void { + this.source.setValue(value); + } + + async dispose(_reason?: string): Promise { + if (this._disposed) { + return; + } + this._onWillDispose.fire(); + if (this.disposeError !== undefined) { + this._disposed = true; + throw this.disposeError; + } + this._disposed = true; + this.source.dispose(); + this._onDidDispose.fire(); + this._onWillDispose.dispose(); + this._onDidDispose.dispose(); + } +} + +/** Bus event shape published by the fake manager. */ +interface FakeBusEvent { + readonly kind: string; + readonly childId: string; + readonly reason?: string; +} + +/** Recording event bus for assertions. */ +class FakeBus implements IManagerEventBus { + readonly events: FakeBusEvent[] = []; + publish(event: FakeBusEvent): void { + this.events.push(event); + } +} + +/** + * Fake manager living in the parent scope. It tracks child handles, attaches to + * each child's event source via `child.accessor.get(IFakeChildSource)`, and + * re-emits collection-view events that add the child id. + */ +class FakeManager extends ScopeManager { + private readonly _onDidChangeChildValue = new Emitter<{ + childId: string; + value: number; + }>(); + readonly onDidChangeChildValue = this._onDidChangeChildValue.event; + + /** Per-child subscription so teardown leaves no dangling listener. */ + private readonly childSubs = new Map(); + + constructor(bus: IManagerEventBus) { + super(bus); + } + + /** Build + attach a child, mirroring a real manager's create flow. */ + addChild(child: FakeChild): void { + const source = child.accessor.get(IFakeChildSource); + const sub = source.onDidChange(({ value }) => { + // Re-emit as a collection-view event, adding the child id. + this._onDidChangeChildValue.fire({ childId: child.id, value }); + this.publish({ kind: 'child.value-changed', childId: child.id }); + }); + this.childSubs.set(child.id, sub); + this.trackChild(child); + } + + protected buildDisposeEvent(childId: string, reason?: string): FakeBusEvent { + return { kind: 'child.disposed', childId, reason }; + } + + override async disposeChild(childId: string, reason?: string): Promise { + try { + await super.disposeChild(childId, reason); + } finally { + const sub = this.childSubs.get(childId); + if (sub !== undefined) { + sub.dispose(); + this.childSubs.delete(childId); + } + } + } + + override dispose(): void { + for (const sub of this.childSubs.values()) { + sub.dispose(); + } + this.childSubs.clear(); + this._onDidChangeChildValue.dispose(); + super.dispose(); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeManager(): { manager: FakeManager; bus: FakeBus; parent: InstantiationService } { + const parent = new InstantiationService(); + const bus = new FakeBus(); + // The manager is instantiated by the parent scope's container — it lives in + // the parent scope, while the children it tracks are separate child scopes. + const manager = parent.createInstance(FakeManager, bus); + return { manager, bus, parent }; +} + +describe('ScopeManager (manager service pattern)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('lives in the parent scope and tracks children in Map', () => { + const { manager, parent } = makeManager(); + const a = new FakeChild('a1'); + const b = new FakeChild('a2'); + + manager.addChild(a); + manager.addChild(b); + + // The manager is a distinct object constructed by the parent container. + expect(manager).toBeInstanceOf(FakeManager); + expect(parent).toBeInstanceOf(InstantiationService); + + // Tracks children in the single allowed map keyed by child id. + expect(manager.children.size).toBe(2); + expect(manager.hasChild('a1')).toBe(true); + expect(manager.hasChild('a2')).toBe(true); + expect(manager.children.get('a1')).toBe(a); + expect(manager.children.get('a2')).toBe(b); + + manager.dispose(); + }); + + it('attaches via child.accessor.get and re-emits collection-view events with child id', () => { + const { manager, bus } = makeManager(); + const child = new FakeChild('a1'); + manager.addChild(child); + + const fired: { childId: string; value: number }[] = []; + manager.onDidChangeChildValue((e) => fired.push(e)); + + child.emit(7); + child.emit(42); + + // Collection-view events carry the originating child id. + expect(fired).toEqual([ + { childId: 'a1', value: 7 }, + { childId: 'a1', value: 42 }, + ]); + // And are published to the bus with the child id. + expect(bus.events).toEqual([ + { kind: 'child.value-changed', childId: 'a1' }, + { kind: 'child.value-changed', childId: 'a1' }, + ]); + + manager.dispose(); + }); + + it('disposeChild fires onDidChildDispose + eventBus.publish in try/finally (success path)', async () => { + const { manager, bus } = makeManager(); + const child = new FakeChild('a1'); + manager.addChild(child); + + const disposed: { childId: string; reason?: string }[] = []; + manager.onDidChildDispose((e) => disposed.push(e)); + + await manager.disposeChild('a1', 'done'); + + // Child dropped from the tracking map. + expect(manager.hasChild('a1')).toBe(false); + expect(manager.children.size).toBe(0); + // onDidChildDispose fired with the child id + reason. + expect(disposed).toEqual([{ childId: 'a1', reason: 'done' }]); + // Bus event published in the finally block. + expect(bus.events).toEqual([ + { kind: 'child.disposed', childId: 'a1', reason: 'done' }, + ]); + + manager.dispose(); + }); + + it('disposeChild still fires onDidChildDispose + publish when child.dispose throws', async () => { + const { manager, bus } = makeManager(); + const boom = new Error('dispose failed'); + const child = new FakeChild('a1', { disposeError: boom }); + manager.addChild(child); + + const disposed: string[] = []; + manager.onDidChildDispose((e) => disposed.push(e.childId)); + + await expect(manager.disposeChild('a1', 'abort')).rejects.toThrow(boom); + + // Finally ran despite the rejection: child dropped, event fired, bus published. + expect(manager.hasChild('a1')).toBe(false); + expect(disposed).toEqual(['a1']); + expect(bus.events).toEqual([ + { kind: 'child.disposed', childId: 'a1', reason: 'abort' }, + ]); + + manager.dispose(); + }); + + it('does not expose manager write methods to children (no reverse-call)', () => { + const { manager } = makeManager(); + const child = new FakeChild('a1'); + manager.addChild(child); + + // The child handle only carries IScopeHandle surface — none of the + // manager's write methods are reachable from it. + expect('disposeChild' in child).toBe(false); + expect('addChild' in child).toBe(false); + expect('trackChild' in child).toBe(false); + + // A child cannot resolve the manager through its own accessor, so there is + // no reverse-call channel into the manager. + expect(() => child.accessor.get(IFakeManagerHandle)).toThrow(); + + manager.dispose(); + }); + + it('onDidChildDispose listeners update parent state only; child services are gone', async () => { + const { manager } = makeManager(); + const child = new FakeChild('a1'); + manager.addChild(child); + + const parentRemovedIds: string[] = []; + let childReadAfterDispose: 'ok' | 'threw' = 'ok'; + manager.onDidChildDispose(({ childId }) => { + // Parent updates only its own state. + parentRemovedIds.push(childId); + // Touching the child's services is forbidden — the data is already gone. + try { + child.accessor.get(IFakeChildSource); + } catch { + childReadAfterDispose = 'threw'; + } + }); + + await manager.disposeChild('a1'); + + expect(parentRemovedIds).toEqual(['a1']); + expect(childReadAfterDispose).toBe('threw'); + + manager.dispose(); + }); + + it('disposeChild is a no-op for an unknown childId', async () => { + const { manager, bus } = makeManager(); + const disposed: string[] = []; + manager.onDidChildDispose((e) => disposed.push(e.childId)); + + await expect(manager.disposeChild('missing')).resolves.toBeUndefined(); + + expect(disposed).toEqual([]); + expect(bus.events).toEqual([]); + + manager.dispose(); + }); +}); diff --git a/packages/agent-core/test/scope/registry.test.ts b/packages/agent-core/test/scope/registry.test.ts new file mode 100644 index 000000000..01d3d0131 --- /dev/null +++ b/packages/agent-core/test/scope/registry.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di'; +import { + InstantiationType, + _clearRegistryForTests, + getSingletonServiceDescriptors, +} from '#/_base/di'; +import { createDecorator } from '#/_base/di'; +import { LifecycleScope } from '#/scope/lifecycle'; +import { + _resetScopeRegistryForTests, + getScopedServiceDescriptors, + isBuilt, + markBuilt, + registerScopedService, +} from '#/scope/registry'; + +interface IGreeter { + greet(): string; +} + +class GreeterA implements IGreeter { + greet(): string { + return 'a'; + } +} + +class GreeterB implements IGreeter { + greet(): string { + return 'b'; + } +} + +describe('ScopeRegistry / registerScopedService', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + _resetScopeRegistryForTests(); + _clearRegistryForTests(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + _resetScopeRegistryForTests(); + _clearRegistryForTests(); + }); + + it('register + getScopedServiceDescriptors returns the descriptor', () => { + const IGreeterId = createDecorator('p11-greeter-register'); + + registerScopedService( + LifecycleScope.Session, + IGreeterId, + GreeterA, + InstantiationType.Delayed, + ); + + const entries = getScopedServiceDescriptors(LifecycleScope.Session); + expect(entries).toHaveLength(1); + const [id, descriptor] = entries[0]!; + expect(id).toBe(IGreeterId); + expect(descriptor).toBeInstanceOf(SyncDescriptor); + expect(descriptor.ctor).toBe(GreeterA); + // Delayed → supportsDelayedInstantiation === true. + expect(descriptor.supportsDelayedInstantiation).toBe(true); + // Lazy: registration never instantiates the ctor. + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('Core alias routes to registerSingleton (visible via getSingletonServiceDescriptors)', () => { + const IGreeterId = createDecorator('p11-greeter-core'); + + registerScopedService( + LifecycleScope.Core, + IGreeterId, + GreeterA, + InstantiationType.Eager, + ); + + // Core does NOT go into the scoped registry. + expect(getScopedServiceDescriptors(LifecycleScope.Core)).toHaveLength(0); + + const singletons = getSingletonServiceDescriptors(); + expect(singletons).toHaveLength(1); + const [id, descriptor] = singletons[0]!; + expect(id).toBe(IGreeterId); + expect(descriptor).toBeInstanceOf(SyncDescriptor); + expect(descriptor.ctor).toBe(GreeterA); + // Eager (registerSingleton default mapping) → supportsDelayedInstantiation === false. + expect(descriptor.supportsDelayedInstantiation).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('duplicate registration is last-write-wins and warns', () => { + const IGreeterId = createDecorator('p11-greeter-dup'); + + registerScopedService( + LifecycleScope.Agent, + IGreeterId, + GreeterA, + InstantiationType.Delayed, + ); + registerScopedService( + LifecycleScope.Agent, + IGreeterId, + GreeterB, + InstantiationType.Delayed, + ); + + const entries = getScopedServiceDescriptors(LifecycleScope.Agent); + expect(entries).toHaveLength(1); + expect(entries[0]![1].ctor).toBe(GreeterB); // last write wins + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]![0])).toMatch(/duplicate registration/); + expect(String(warnSpy.mock.calls[0]![0])).toMatch(/last write wins/); + }); + + it('duplicate registration with { replace: true } is silent', () => { + const IGreeterId = createDecorator('p11-greeter-replace'); + + registerScopedService( + LifecycleScope.Agent, + IGreeterId, + GreeterA, + InstantiationType.Delayed, + ); + registerScopedService( + LifecycleScope.Agent, + IGreeterId, + GreeterB, + InstantiationType.Delayed, + { replace: true }, + ); + + const entries = getScopedServiceDescriptors(LifecycleScope.Agent); + expect(entries).toHaveLength(1); + expect(entries[0]![1].ctor).toBe(GreeterB); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('registration after markBuilt() warns and is ignored', () => { + const IGreeterId = createDecorator('p11-greeter-late'); + + expect(isBuilt()).toBe(false); + markBuilt(); + expect(isBuilt()).toBe(true); + + registerScopedService( + LifecycleScope.Turn, + IGreeterId, + GreeterA, + InstantiationType.Delayed, + ); + + // Ignored: never reaches the registry. + expect(getScopedServiceDescriptors(LifecycleScope.Turn)).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]![0])).toMatch(/after the first/); + }); + + it('keeps multiple scopes isolated', () => { + const IGreeterId = createDecorator('p11-greeter-isolation'); + + registerScopedService( + LifecycleScope.Session, + IGreeterId, + GreeterA, + InstantiationType.Delayed, + ); + + expect(getScopedServiceDescriptors(LifecycleScope.Session)).toHaveLength(1); + expect(getScopedServiceDescriptors(LifecycleScope.Agent)).toHaveLength(0); + expect(getScopedServiceDescriptors(LifecycleScope.Turn)).toHaveLength(0); + expect(getScopedServiceDescriptors(LifecycleScope.ToolCall)).toHaveLength(0); + }); + + it('getScopedServiceDescriptors for an untouched scope is empty', () => { + expect(getScopedServiceDescriptors(LifecycleScope.ToolCall)).toEqual([]); + }); + + it('LifecycleScope exposes the five scopes', () => { + expect(LifecycleScope.Core).toBe('core'); + expect(LifecycleScope.Session).toBe('session'); + expect(LifecycleScope.Agent).toBe('agent'); + expect(LifecycleScope.Turn).toBe('turn'); + expect(LifecycleScope.ToolCall).toBe('toolCall'); + }); +}); diff --git a/packages/agent-core/test/services/approval-adapter.test.ts b/packages/agent-core/test/services/approval-adapter.test.ts index fb18d9a50..4dfbe8ab7 100644 --- a/packages/agent-core/test/services/approval-adapter.test.ts +++ b/packages/agent-core/test/services/approval-adapter.test.ts @@ -9,7 +9,7 @@ import type { ApprovalRequest as InProcessApprovalRequest } from '../../src'; import { approvalToAgentCoreResponse as toAgentCoreResponse, approvalToBrokerRequest as toBrokerRequest, -} from '../../src/services'; +} from '#/approval'; describe('approval-adapter · toBrokerRequest (in-process → protocol)', () => { const inProc: InProcessApprovalRequest = { diff --git a/packages/agent-core/test/services/core-rpc-usage.test.ts b/packages/agent-core/test/services/core-rpc-usage.test.ts new file mode 100644 index 000000000..267b5a2ef --- /dev/null +++ b/packages/agent-core/test/services/core-rpc-usage.test.ts @@ -0,0 +1,74 @@ +import { globSync, readFileSync } from 'node:fs'; +import { join } from 'pathe'; + +import { describe, expect, it } from 'vitest'; + +/** + * CoreRPC usage inventory — M3 progress baseline. + * + * Counts the `this.core.rpc.()` calls that each consumer service + * domain still makes through the CoreRPC proxy. M3.2–M3.7 will slice those + * calls into direct in-process domain service calls, driving every count here + * down to 0. Each phase updates the baseline map downward as it lands. + * + * The scan mirrors the milestone's grep baseline exactly: for each domain it + * globs `src/services//*Service.ts` and counts `.rpc.(` + * occurrences with `/\.rpc\.[a-zA-Z_]+\(/g`. Aggregating the `*Service.ts` + * glob (rather than a single `Service.ts`) is what originally made + * the session domain reach 27 across `sessionService.ts` + + * `sessionQueryService.ts` + `sessionRuntimeService.ts` (now 0 after M3.3 + * routed every call through the in-process `getCoreApi()` accessor). M3.4 + * applied the same `getCoreApi()` routing to the `mcp` and `modelCatalog` + * domains, driving both baselines to 0. M3.5 routed the `skill` and `task` + * domains the same way, driving both baselines to 0. M3.6 routed the + * `message`, `tool`, `config`, and `authSummary` domains the same way, + * driving all four baselines to 0. + */ + +const SERVICES_SRC = join(import.meta.dirname, '..', '..', 'src', 'services'); + +// Matches the `.rpc.(` call shape. Identical to the milestone grep +// (`\.rpc\.[a-zA-Z_]+\(`) so the vitest counts track the hand-verified +// baseline 1:1. +const RPC_CALL_RE = /\.rpc\.[a-zA-Z_]+\(/g; + +/** + * Per-domain `.rpc.(` baseline, verified 2026-06-21 against the + * current tree. Keys are the `src/services//` directory names; values + * are the aggregated `*Service.ts` counts. M3 phases lower these to 0. + */ +const BASELINE: Readonly> = { + authSummary: 0, + config: 0, + mcp: 0, + message: 0, + modelCatalog: 0, + prompt: 0, + session: 0, + skill: 0, + task: 0, + tool: 0, +}; + +function countRpcCalls(domain: string): number { + const domainDir = join(SERVICES_SRC, domain); + const files = globSync('*Service.ts', { cwd: domainDir }); + let total = 0; + for (const file of files) { + const source = readFileSync(join(domainDir, file), 'utf8'); + RPC_CALL_RE.lastIndex = 0; + const matches = source.match(RPC_CALL_RE); + total += matches === null ? 0 : matches.length; + } + return total; +} + +describe('CoreRPC usage inventory (M3 baseline)', () => { + it('per-domain .rpc.( counts match the baseline', () => { + const actual: Record = {}; + for (const domain of Object.keys(BASELINE)) { + actual[domain] = countRpcCalls(domain); + } + expect(actual).toEqual(BASELINE); + }); +}); diff --git a/packages/agent-core/test/services/core-runtime.test.ts b/packages/agent-core/test/services/core-runtime.test.ts new file mode 100644 index 000000000..9ceec7720 --- /dev/null +++ b/packages/agent-core/test/services/core-runtime.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + Emitter, + SyncDescriptor, + type ApprovalRequest, + type ApprovalResponse, + type Event, + type QuestionRequest, + type QuestionResult, +} from '../../src'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IApprovalService } from '#/approval'; +import { IEventService } from '#/event'; +import { IQuestionService } from '#/question'; + +import { + IEnvironmentService, + ILogService, +} from '../../src/services'; +import { CoreProcessService, ICoreRuntime } from '#/coreProcess'; + +class RecordingEventService implements IEventService { + readonly _serviceBrand: undefined; + readonly events: Event[] = []; + private readonly _emitter = new Emitter(); + readonly onDidPublish = this._emitter.event; + publish(event: Event): void { + this.events.push(event); + this._emitter.fire(event); + } +} + +class RecordingApprovalService implements IApprovalService { + readonly _serviceBrand: undefined; + async request(_req: ApprovalRequest & { sessionId: string; agentId: string }): Promise { + return { decision: 'approved' }; + } + resolve(_id: string, _response: ApprovalResponse): void {} + listPending(): ReturnType { + return []; + } +} + +class RecordingQuestionService implements IQuestionService { + readonly _serviceBrand: undefined; + async request(_req: QuestionRequest & { sessionId: string; agentId: string }): Promise { + return null; + } + resolve(_id: string, _response: QuestionResult): void {} + dismiss(_id: string): void {} + listPending(): ReturnType { + return []; + } +} + +class NoopLogService implements ILogService { + readonly _serviceBrand: undefined; + debug(): void {} + info(): void {} + warn(): void {} + error(): void {} + child(): ILogService { + return this; + } +} + +let tmpHome: string; +let prevHome: string | undefined; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'kimi-core-runtime-test-')); + prevHome = process.env['KIMI_HOME']; + process.env['KIMI_HOME'] = tmpHome; +}); + +afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_HOME']; + } else { + process.env['KIMI_HOME'] = prevHome; + } + try { + rmSync(tmpHome, { recursive: true, force: true }); + } catch { + } +}); + +function makeEnv(homeDir: string): IEnvironmentService { + return { + _serviceBrand: undefined, + homeDir, + configPath: join(homeDir, 'config.toml'), + }; +} + +function makePeers() { + return { + eventService: new RecordingEventService(), + approvalService: new RecordingApprovalService(), + questionService: new RecordingQuestionService(), + logService: new NoopLogService(), + }; +} + +function buildService(ix: TestInstantiationService): CoreProcessService { + const peers = makePeers(); + ix.stub(IEventService, peers.eventService); + ix.stub(IApprovalService, peers.approvalService); + ix.stub(IQuestionService, peers.questionService); + ix.stub(IEnvironmentService, makeEnv(tmpHome)); + ix.stub(ILogService, peers.logService); + return ix.createInstance(CoreProcessService, {}); +} + +describe('ICoreRuntime facade', () => { + it('is keyed by the coreProcessService decorator string', () => { + // The deprecated process-service alias was removed in M7.1; ICoreRuntime + // is now the sole identifier. Its DI token string is unchanged. + expect(ICoreRuntime.toString()).toBe('coreProcessService'); + }); + + it('resolves the CoreProcessService singleton via ICoreRuntime', async () => { + const ix = new TestInstantiationService(); + const peers = makePeers(); + ix.stub(IEventService, peers.eventService); + ix.stub(IApprovalService, peers.approvalService); + ix.stub(IQuestionService, peers.questionService); + ix.stub(IEnvironmentService, makeEnv(tmpHome)); + ix.stub(ILogService, peers.logService); + ix.set(ICoreRuntime, new SyncDescriptor(CoreProcessService, [{}])); + + try { + const core = ix.get(ICoreRuntime); + expect(core).toBeInstanceOf(CoreProcessService); + await expect(core.ready()).resolves.toBeUndefined(); + } finally { + ix.dispose(); + } + }); + + it('ready() resolves and dispose() is idempotent and short-circuits rpc dispatch', async () => { + const ix = new TestInstantiationService(); + const core = buildService(ix); + try { + await expect(core.ready()).resolves.toBeUndefined(); + expect(typeof core.rpc.getCoreInfo).toBe('function'); + + core.dispose(); + core.dispose(); // idempotent — second call is a no-op. + + await expect(core.rpc.getCoreInfo({})).rejects.toThrow(/disposed/); + } finally { + ix.dispose(); + } + }); + + it('getCoreApi() returns the in-process KimiCore and throws after dispose', async () => { + const ix = new TestInstantiationService(); + const core = buildService(ix); + try { + await core.ready(); + + const coreApi = core.getCoreApi(); + // The in-process handle exposes the CoreAPI methods directly (no RPC hop). + expect(typeof coreApi.getCoreInfo).toBe('function'); + const info = await coreApi.getCoreInfo({}); + expect(info).toHaveProperty('version'); + expect(typeof info.version).toBe('string'); + + // Repeated calls return the same underlying instance (no proxy wrapper). + expect(core.getCoreApi()).toBe(coreApi); + + core.dispose(); + expect(() => core.getCoreApi()).toThrow(/disposed/); + } finally { + ix.dispose(); + } + }); +}); diff --git a/packages/agent-core/test/services/core-wire.test.ts b/packages/agent-core/test/services/core-wire.test.ts new file mode 100644 index 000000000..9f2477785 --- /dev/null +++ b/packages/agent-core/test/services/core-wire.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { createRPC } from '../../src/rpc/client'; + +/** + * M3.7 — CoreRPC wire-only / zero in-process serialization. + * + * `CoreProcessService.getCoreApi()` (`coreProcessService.ts:160-170`) returns + * the underlying in-process `KimiCore` directly, deliberately bypassing the + * `createRPC` proxy and its `simulateNetwork` JSON serialize/deserialize hop + * (`rpc/client.ts:38-45`). Constructing a real `CoreProcessService` here would + * spin up `KimiCore` (plugin load, OAuth + auth facade wiring, header + * synthesis), so we mirror the documented seam with a faithful stand-in: a + * handle whose `getCoreApi()` returns the captured in-process object + * unchanged, exactly as the production accessor does (`return this._core`). + * + * The assertions below prove the in-process path is serialization-free by + * showing that object identity and non-JSON-safe values survive the trip, + * while the `createRPC` control proves the same checks would catch a + * regression that routed the in-process call through `simulateNetwork`. + */ + +/** Echo API shape used to drive both the in-process handle and createRPC. */ +interface EchoAPI { + echo(payload: unknown): unknown; +} + +/** + * Faithful stand-in for `CoreProcessService.getCoreApi()`: returns the + * captured in-process object directly, with no `createRPC` / `simulateNetwork` + * boundary in between. Mirrors `coreProcessService.ts:160-170`. + */ +function makeInProcessHandle(core: T): { readonly _core: T; getCoreApi(): T } { + return { + _core: core, + getCoreApi(): T { + return this._core; + }, + }; +} + +/** + * Payload carrying values that `JSON.stringify` cannot round-trip: a `Date` + * (becomes a string), `undefined` (dropped), and a function (dropped). If a + * call passes through `simulateNetwork`, these are mangled; if it stays + * in-process, they survive unchanged. + */ +function makeSentinelPayload(): { + date: Date; + nil: undefined; + fn: () => string; + nested: { value: number }; +} { + const nested = { value: 42 }; + return { + date: new Date('2026-06-21T00:00:00.000Z'), + nil: undefined, + fn: () => 'sentinel', + nested, + }; +} + +describe('core-wire: in-process getCoreApi() path is serialization-free', () => { + it('returns the exact in-process instance, not a createRPC proxy', () => { + const core: EchoAPI = { echo: (payload) => payload }; + const handle = makeInProcessHandle(core); + + // Identity is preserved across calls — there is no proxy/serialization + // wrapper between the handle and the in-process object. + expect(handle.getCoreApi()).toBe(core); + expect(handle.getCoreApi()).toBe(handle._core); + }); + + it('does not JSON-roundtrip payloads — non-JSON values pass through unchanged', () => { + const core: EchoAPI = { echo: (payload) => payload }; + const handle = makeInProcessHandle(core); + const payload = makeSentinelPayload(); + + const result = handle.getCoreApi().echo(payload) as typeof payload; + + // Same reference end-to-end: no `JSON.parse(JSON.stringify(...))` clone. + expect(result).toBe(payload); + expect(result.nested).toBe(payload.nested); + // `Date` survives as a Date (simulateNetwork would turn it into a string). + expect(result.date).toBeInstanceOf(Date); + expect(result.date.toISOString()).toBe('2026-06-21T00:00:00.000Z'); + // `undefined` and function values survive (simulateNetwork would drop them). + expect(result.nil).toBeUndefined(); + expect(result.fn).toBe(payload.fn); + expect(result.fn()).toBe('sentinel'); + }); + + it('control: createRPC serializes the same payload, proving the checks are load-bearing', async () => { + const [leftClient, rightClient] = createRPC(); + // `leftClient` returns the methods bound to the *other* side's self, so a + // call to `leftMethods.echo(...)` traverses `mapRpcFunction` → + // `simulateNetwork` (JSON.stringify/parse) on both the payload and response. + const leftMethods = leftClient({ echo: (payload) => payload }); + void rightClient({ echo: (payload) => payload }); + const rpc = await leftMethods; + + const payload = makeSentinelPayload(); + const result = (await rpc.echo(payload)) as Record; + + // After simulateNetwork: Date → ISO string, undefined/function keys dropped. + expect(result).not.toBe(payload); + expect(result['date']).toBe('2026-06-21T00:00:00.000Z'); + expect(typeof result['date']).toBe('string'); + expect(result['nil']).toBeUndefined(); + expect(result['fn']).toBeUndefined(); + expect(Object.keys(result).sort()).toEqual(['date', 'nested']); + }); +}); diff --git a/packages/agent-core/test/services/coreProcessService.test.ts b/packages/agent-core/test/services/coreProcessService.test.ts index 9e329f923..5df4d82e8 100644 --- a/packages/agent-core/test/services/coreProcessService.test.ts +++ b/packages/agent-core/test/services/coreProcessService.test.ts @@ -14,18 +14,16 @@ import { type QuestionRequest, type QuestionResult, } from '../../src'; -import { TestInstantiationService } from '../../src/di/test'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IApprovalService } from '#/approval'; +import { IEventService } from '#/event'; +import { IQuestionService } from '#/question'; import { - BridgeClientAPI, - CoreProcessService, - IApprovalService, IEnvironmentService, - IEventService, ILogService, - ICoreProcessService, - IQuestionService, } from '../../src/services'; +import { BridgeClientAPI, CoreProcessService, ICoreRuntime } from '#/coreProcess'; class RecordingEventService implements IEventService { readonly _serviceBrand: undefined; @@ -187,6 +185,7 @@ describe('CoreProcessService direct construction', () => { approvalService, questionService, logService, + new TestInstantiationService(), ); try { await expect(core.ready()).resolves.toBeUndefined(); @@ -205,6 +204,7 @@ describe('CoreProcessService direct construction', () => { approvalService, questionService, logService, + new TestInstantiationService(), ); try { await core.ready(); @@ -225,6 +225,7 @@ describe('CoreProcessService direct construction', () => { approvalService, questionService, logService, + new TestInstantiationService(), ); await core.ready(); core.dispose(); @@ -276,8 +277,13 @@ describe('singleton registry composition', () => { const { eventService, approvalService, questionService } = makePeers(); const moduleEntries = getSingletonServiceDescriptors(); expect(moduleEntries.length).toBeGreaterThanOrEqual(1); - expect(moduleEntries[0]![0]).toBe(ICoreProcessService); - expect(moduleEntries[0]![1]).toBeInstanceOf(SyncDescriptor); + // Locate the CoreProcessService descriptor by identifier rather than by + // registry index: module-load registration order shifts as domains migrate + // out of `services/` into their own barrels (e.g. `event`), so the + // descriptor is no longer guaranteed to sit at index 0. + const coreRuntimeEntry = moduleEntries.find(([id]) => id === ICoreRuntime); + expect(coreRuntimeEntry).toBeDefined(); + expect(coreRuntimeEntry![1]).toBeInstanceOf(SyncDescriptor); const ix = new TestInstantiationService(); for (const [id, desc] of moduleEntries) { diff --git a/packages/agent-core/test/services/fs-git-service.test.ts b/packages/agent-core/test/services/fs-git-service.test.ts index db746c7c6..9547110e6 100644 --- a/packages/agent-core/test/services/fs-git-service.test.ts +++ b/packages/agent-core/test/services/fs-git-service.test.ts @@ -22,7 +22,7 @@ vi.mock('node:fs', () => ({ }, })); -import type { ISessionService } from '../../src/services'; +import type { ISessionService } from '#/session'; import { FsGitService } from '../../src/services'; interface FakeChild extends EventEmitter { diff --git a/packages/agent-core/test/services/interfaces.test.ts b/packages/agent-core/test/services/interfaces.test.ts index a70ef570b..21eb9a164 100644 --- a/packages/agent-core/test/services/interfaces.test.ts +++ b/packages/agent-core/test/services/interfaces.test.ts @@ -7,19 +7,19 @@ import { describe, expect, it, vi } from 'vitest'; import { Emitter, } from '../../src'; -import { TestInstantiationService } from '../../src/di/test'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IApprovalService, type ApprovalResponse } from '#/approval'; +import { IEventService } from '#/event'; +import { IQuestionService, type QuestionResult } from '#/question'; import type { ApprovalRequest, Event, QuestionRequest } from '../../src'; import { - IApprovalService, - IEventService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, - IQuestionService, IWorkspaceFsService, IWorkspaceRegistry, FileStore, @@ -31,8 +31,6 @@ import { WorkspaceRegistryService, parsePorcelain, resolveSafePath, - type ApprovalResponse, - type QuestionResult, } from '../../src/services'; const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); diff --git a/packages/agent-core/test/services/logger.test.ts b/packages/agent-core/test/services/logger.test.ts new file mode 100644 index 000000000..aef04153d --- /dev/null +++ b/packages/agent-core/test/services/logger.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di'; +import { + InstantiationType, + _clearRegistryForTests, + getSingletonServiceDescriptors, +} from '#/_base/di'; +import { InstantiationService } from '#/_base/di'; +import { ServiceCollection } from '#/_base/di'; +import { SessionScopeBuilder } from '#/scope/builder'; +import { ISessionContext } from '#/scope/context/index'; +import { LifecycleScope } from '#/scope/lifecycle'; +import { + _resetScopeRegistryForTests, + getScopedServiceDescriptors, + registerScopedService, +} from '#/scope/registry'; +import { ILogService } from '#/services/logger/logger'; + +/** + * Minimal `ILogService` implementation used to drive the scope-mechanism + * migration test. It records every call so the "behavior unchanged" case can + * assert the contract still holds after the registration path changes. + * + * Note: agent-core deliberately ships no production `ILogService` adapter — + * the server wires `PinoLogger` via `services.set(ILogService, ...)` (see + * `src/services/AGENTS.md`: "adapter lives in server"). Because there is no + * pre-existing `registerSingleton(ILogService, ...)` to migrate, this test + * validates the target state directly: `registerScopedService(Core, + * ILogService, ...)` registers + resolves identically to the singleton alias. + */ +class FakeLogService implements ILogService { + declare readonly _serviceBrand: undefined; + + static constructed = 0; + static lastInstance: FakeLogService | undefined; + + readonly calls: Array<{ + level: 'info' | 'warn' | 'error' | 'debug'; + obj: object | string; + msg?: string; + }> = []; + + constructor(private readonly bindings: object = {}) { + FakeLogService.constructed += 1; + FakeLogService.lastInstance = this; + } + + info(obj: object | string, msg?: string): void { + this.calls.push({ level: 'info', obj, msg }); + } + + warn(obj: object | string, msg?: string): void { + this.calls.push({ level: 'warn', obj, msg }); + } + + error(obj: object | string, msg?: string): void { + this.calls.push({ level: 'error', obj, msg }); + } + + debug(obj: object | string, msg?: string): void { + this.calls.push({ level: 'debug', obj, msg }); + } + + child(bindings: object): ILogService { + return new FakeLogService({ ...this.bindings, ...bindings }); + } +} + +function sessionContext(id: string): ISessionContext { + return { + id, + abortSignal: new AbortController().signal, + executionScope: undefined, + }; +} + +describe('ILogService → registerScopedService(Core, …)', () => { + beforeEach(() => { + _resetScopeRegistryForTests(); + _clearRegistryForTests(); + FakeLogService.constructed = 0; + FakeLogService.lastInstance = undefined; + + registerScopedService( + LifecycleScope.Core, + ILogService, + FakeLogService, + InstantiationType.Delayed, + ); + }); + + afterEach(() => { + _resetScopeRegistryForTests(); + _clearRegistryForTests(); + }); + + it('Core alias routes ILogService to the singleton registry (not the scoped registry)', () => { + // Core never touches the scoped registry — it aliases to registerSingleton. + expect(getScopedServiceDescriptors(LifecycleScope.Core)).toHaveLength(0); + + const singletons = getSingletonServiceDescriptors(); + const entry = singletons.find(([id]) => id === ILogService); + expect(entry).toBeDefined(); + + const [, descriptor] = entry!; + expect(descriptor).toBeInstanceOf(SyncDescriptor); + expect(descriptor.ctor).toBe(FakeLogService); + // Delayed → supportsDelayedInstantiation === true. + expect(descriptor.supportsDelayedInstantiation).toBe(true); + + // Resolves through a root (Core) container seeded from those descriptors. + const root = new InstantiationService( + new ServiceCollection(...getSingletonServiceDescriptors()), + ); + const log = root.invokeFunction((accessor) => accessor.get(ILogService)); + log.info('core-alias'); + + expect(FakeLogService.lastInstance).toBeInstanceOf(FakeLogService); + expect(FakeLogService.lastInstance!.calls).toContainEqual({ + level: 'info', + obj: 'core-alias', + msg: undefined, + }); + }); + + it('ILogService behavior is unchanged (info/warn/error/debug + child bindings)', () => { + const root = new InstantiationService( + new ServiceCollection(...getSingletonServiceDescriptors()), + ); + const log = root.invokeFunction((accessor) => accessor.get(ILogService)); + + log.info('i'); + log.warn('w'); + log.error('e', 'extra'); + log.debug({ detail: true }); + + const instance = FakeLogService.lastInstance!; + expect(instance.calls.map((c) => c.level)).toEqual([ + 'info', + 'warn', + 'error', + 'debug', + ]); + expect(instance.calls[2]).toEqual({ + level: 'error', + obj: 'e', + msg: 'extra', + }); + + // child() returns a working ILogService that records with merged bindings. + const child = log.child({ sessionId: 'ses_x' }) as FakeLogService; + child.info('child-call'); + expect(child).toBeInstanceOf(FakeLogService); + expect(child.calls).toEqual([ + { level: 'info', obj: 'child-call', msg: undefined }, + ]); + }); + + it('ScopeBuilder-built Session scope resolves the Core ILogService through the DI parent chain', () => { + const root = new InstantiationService( + new ServiceCollection(...getSingletonServiceDescriptors()), + ); + + const session = new SessionScopeBuilder().build(root, sessionContext('s1')); + expect(session.scope).toBe(LifecycleScope.Session); + + // The Session collection does not contain ILogService; resolution walks up + // to the Core (root) container that the Pattern-1 registration seeded. + const log = session.accessor.get(ILogService); + log.warn('via-session-child'); + + expect(FakeLogService.lastInstance).toBeInstanceOf(FakeLogService); + expect(FakeLogService.lastInstance!.calls).toContainEqual({ + level: 'warn', + obj: 'via-session-child', + msg: undefined, + }); + }); + + it('registration is lazy: the Core ILogService is not constructed at build', () => { + const root = new InstantiationService( + new ServiceCollection(...getSingletonServiceDescriptors()), + ); + + // Building a scope on top does not realize the Delayed Core service. + new SessionScopeBuilder().build(root, sessionContext('s1')); + expect(FakeLogService.constructed).toBe(0); + + // First real use triggers exactly one construction. + const log = root.invokeFunction((accessor) => accessor.get(ILogService)); + log.debug('realize'); + expect(FakeLogService.constructed).toBe(1); + }); +}); diff --git a/packages/agent-core/test/services/message-service.test.ts b/packages/agent-core/test/services/message-service.test.ts index 1e34acb15..bd67646f8 100644 --- a/packages/agent-core/test/services/message-service.test.ts +++ b/packages/agent-core/test/services/message-service.test.ts @@ -1,7 +1,7 @@ /** * `MessageService` (Chain 3 / P1.3, W7.1) unit tests. * - * Hermetic: a fake `ICoreProcessService` returns canned `SessionSummary[]` from + * Hermetic: a fake `ICoreRuntime` returns canned `SessionSummary[]` from * `listSessions` and a canned `AgentContextData.history` from `getContext`. * * Coverage: @@ -27,14 +27,14 @@ import type { } from '../../src'; import { - type ICoreProcessService, MessageNotFoundError, MessageService, - SessionNotFoundError, deriveMessageId, parseMessageId, toProtocolMessage, -} from '../../src/services'; +} from '#/message'; +import { SessionNotFoundError } from '#/session'; +import { type ICoreRuntime } from '#/coreProcess'; const SESSION_ID = 'sess_01HZTEST'; const SESSION_CREATED_AT = 1_700_000_000_000; @@ -42,7 +42,7 @@ const SESSION_CREATED_AT = 1_700_000_000_000; function makeFakeBridge( sessions: SessionSummary[], history: ContextMessage[], -): ICoreProcessService { +): ICoreRuntime & { getCoreApi(): CoreRPC } { const rpc: Partial = { listSessions: vi.fn().mockImplementation(async () => sessions), resumeSession: vi.fn().mockResolvedValue(undefined as unknown as never), @@ -50,8 +50,14 @@ function makeFakeBridge( return { history, tokenCount: 0 }; }), }; + // `getCoreApi()` mirrors `rpc` on purpose: in production both expose the + // identical CoreAPI method set — `getCoreApi()` is the in-process + // (zero-serialization) path, `rpc` is the serializing proxy. Sharing one + // stub keeps the `bridge.rpc.*` assertions below valid without duplicating + // the mock. return { rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, ready: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), _serviceBrand: undefined, @@ -213,7 +219,7 @@ describe('toProtocolMessage content adapter', () => { describe('MessageService', () => { let impl: MessageService; - let bridge: ICoreProcessService; + let bridge: ICoreRuntime; beforeEach(() => { bridge = makeFakeBridge( @@ -346,8 +352,9 @@ describe('MessageService', () => { resumeSession: vi.fn().mockRejectedValue(new Error('state.json corrupted')), getContext: vi.fn(), }; - const failingBridge: ICoreProcessService = { + const failingBridge: ICoreRuntime & { getCoreApi(): CoreRPC } = { rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, ready: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), _serviceBrand: undefined, diff --git a/packages/agent-core/test/services/message-transcript.test.ts b/packages/agent-core/test/services/message-transcript.test.ts index 97602524e..03dc41b06 100644 --- a/packages/agent-core/test/services/message-transcript.test.ts +++ b/packages/agent-core/test/services/message-transcript.test.ts @@ -28,12 +28,12 @@ import type { } from '../../src'; import { - type ICoreProcessService, MessageService, readWireRecords, readWireTranscript, reduceWireRecords, -} from '../../src/services'; +} from '#/message'; +import { type ICoreRuntime } from '#/coreProcess'; const SESSION_ID = 'sess_01HZWIRE'; const SESSION_CREATED_AT = 1_700_000_000_000; @@ -272,7 +272,7 @@ describe('readWireRecords / readWireTranscript', () => { describe('MessageService over a compacted wire log', () => { let dir: string; let liveHistory: ContextMessage[]; - let bridge: ICoreProcessService; + let bridge: ICoreRuntime & { getCoreApi(): CoreRPC }; let impl: MessageService; function summary(): SessionSummary { @@ -324,6 +324,7 @@ describe('MessageService over a compacted wire log', () => { }; bridge = { rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, ready: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), _serviceBrand: undefined, diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index 18b86eaad..e8fb51522 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -10,7 +10,6 @@ import type { import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; import { - type ICoreProcessService, type IEnvironmentService, ModelCatalogService, ModelNotFoundError, @@ -18,6 +17,7 @@ import { toProtocolModel, toProtocolProvider, } from '../../src/services'; +import { type ICoreRuntime } from '#/coreProcess'; import type { ServicesAuthFacade } from '../../src/services/auth/managedAuth'; afterEach(() => { @@ -34,7 +34,7 @@ function makeEnv(): IEnvironmentService { } function makeCore(configRef: { current: KimiConfig }): { - core: ICoreProcessService; + core: ICoreRuntime & { getCoreApi(): CoreRPC }; getCalls: GetKimiConfigPayload[]; setCalls: KimiConfigPatch[]; removeCalls: string[]; @@ -81,6 +81,10 @@ function makeCore(configRef: { current: KimiConfig }): { core: { _serviceBrand: undefined, rpc: rpc as CoreRPC, + // `getCoreApi()` mirrors `rpc`: both expose the identical CoreAPI + // method set; `getCoreApi()` is the in-process zero-serialization path + // the service now routes through, `rpc` is the serializing proxy. + getCoreApi: () => rpc as CoreRPC, ready: async () => undefined, dispose: () => undefined, }, diff --git a/packages/agent-core/test/services/prompt-service.test.ts b/packages/agent-core/test/services/prompt-service.test.ts index e988469fc..965e20b48 100644 --- a/packages/agent-core/test/services/prompt-service.test.ts +++ b/packages/agent-core/test/services/prompt-service.test.ts @@ -1,7 +1,7 @@ /** * `PromptService` unit tests. * - * Hermetic: a fake `ICoreProcessService` returns canned session list + records + * Hermetic: a fake `ICoreRuntime` returns canned session list + records * the `prompt` / `cancel` payloads. A stub `IEventService` collects published * events into an array we can inspect and drives synthesis via * `bus.publish(turn.*)` → PromptService's private subscriber. A stub @@ -39,18 +39,19 @@ import type { SessionSummary, } from '../../src'; import type { PromptSubmission, Session } from '@moonshot-ai/protocol'; +import type { IEventService } from '#/event'; import { type IAuthSummaryService, - type IEventService, - type ICoreProcessService, type ILogService, - type ISessionService, +} from '../../src/services'; +import { SessionNotFoundError, type ISessionService } from '#/session'; +import { PromptAlreadyCompletedError, PromptNotFoundError, PromptService, - SessionNotFoundError, -} from '../../src/services'; +} from '#/prompt'; +import { type ICoreRuntime } from '#/coreProcess'; const SID = 'sess_01PT'; const SESSION_CREATED_AT = 1_700_000_000_000; @@ -128,7 +129,7 @@ interface BridgeStubOptions { function makeBridge( opts: BridgeStubOptions = {}, -): { bridge: ICoreProcessService; record: RpcRecord } { +): { bridge: ICoreRuntime & { getCoreApi(): CoreRPC }; record: RpcRecord } { const record: RpcRecord = { promptCalls: [], steerCalls: [], @@ -237,8 +238,16 @@ function makeBridge( return { goalId: 'goal_1', status: 'cancelled' }; }), }; - const bridge: ICoreProcessService = { + // `getCoreApi()` mirrors `rpc` on purpose: in production both expose the + // identical CoreAPI method set — `getCoreApi()` is the in-process + // (zero-serialization) path, `rpc` is the serializing proxy. Sharing one + // stub keeps the call-recording assertions and the rejection-mocking cases + // (which mock `bridge.rpc.*`) valid without duplicating the mock. The + // intersection type keeps the facade fields type-checked while surfacing + // the narrow in-process accessor promptService now routes through. + const bridge: ICoreRuntime & { getCoreApi(): CoreRPC } = { rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, ready: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), _serviceBrand: undefined, @@ -302,13 +311,10 @@ function makeSessionService(): { const sessionService: ISessionService = { _serviceBrand: undefined, create: vi.fn() as unknown as ISessionService['create'], - list: vi.fn() as unknown as ISessionService['list'], get: vi.fn() as unknown as ISessionService['get'], update: vi.fn() as unknown as ISessionService['update'], fork: vi.fn() as unknown as ISessionService['fork'], - listChildren: vi.fn() as unknown as ISessionService['listChildren'], createChild: vi.fn() as unknown as ISessionService['createChild'], - getStatus: vi.fn() as unknown as ISessionService['getStatus'], compact: vi.fn() as unknown as ISessionService['compact'], undo: vi.fn() as unknown as ISessionService['undo'], archive: vi.fn() as unknown as ISessionService['archive'], @@ -334,7 +340,7 @@ class NoopLogService implements ILogService { } function newSvc( - bridge: ICoreProcessService, + bridge: ICoreRuntime, bus: IEventService, auth: IAuthSummaryService = makeAuth(), sessionService: ISessionService = makeSessionService().sessionService, diff --git a/packages/agent-core/test/services/question-adapter.test.ts b/packages/agent-core/test/services/question-adapter.test.ts index 1a97c8a0e..a061aabbc 100644 --- a/packages/agent-core/test/services/question-adapter.test.ts +++ b/packages/agent-core/test/services/question-adapter.test.ts @@ -13,7 +13,7 @@ import { questionDismissedResult as dismissedResult, questionToAgentCoreResponse as toAgentCoreResponse, questionToBrokerRequest as toBrokerRequest, -} from '../../src/services'; +} from '#/question'; describe('question-adapter · toBrokerRequest (in-process → protocol)', () => { const inProc: InProcessQuestionRequest = { diff --git a/packages/agent-core/test/services/session-service.test.ts b/packages/agent-core/test/services/session-service.test.ts index 0c5421d11..2f82efec7 100644 --- a/packages/agent-core/test/services/session-service.test.ts +++ b/packages/agent-core/test/services/session-service.test.ts @@ -14,23 +14,21 @@ import { type SessionSummary, type UpdateSessionMetadataPayload, } from '../../src'; -import { TestInstantiationService } from '../../src/di/test'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IApprovalService } from '#/approval'; +import type { IEventService } from '#/event'; +import { IQuestionService } from '#/question'; import { emptySessionUsage, type Event, type Session } from '@moonshot-ai/protocol'; +import { type IAuthSummaryService } from '../../src/services'; import { - IApprovalService, - type IAuthSummaryService, - type ICoreProcessService, - type IEventService, - IPromptService, - IQuestionService, - type ISessionService, - PromptService, SessionNotFoundError, - SessionUndoUnavailableError, SessionService, + SessionUndoUnavailableError, toProtocolSession, -} from '../../src/services'; +} from '#/session'; +import { IPromptService, PromptService } from '#/prompt'; +import { type ICoreRuntime } from '#/coreProcess'; type WithSessionId = T & { readonly sessionId: string }; @@ -50,7 +48,9 @@ interface FakeBridgeState { postUndoContexts: Map; } -function makeFakeBridge(state: FakeBridgeState): ICoreProcessService { +function makeFakeBridge( + state: FakeBridgeState, +): ICoreRuntime & { getCoreApi(): CoreRPC } { const rpc: Partial = { createSession: vi .fn() @@ -73,12 +73,20 @@ function makeFakeBridge(state: FakeBridgeState): ICoreProcessService { .fn() .mockImplementation( async ( - input?: { workDir?: string }, + input?: { workDir?: string; includeArchive?: boolean }, ): Promise => { + let rows = state.sessions; if (input?.workDir !== undefined) { - return state.sessions.filter((s) => s.workDir === input.workDir); + rows = rows.filter((s) => s.workDir === input.workDir); } - return state.sessions; + // Mirror core: default list excludes archived rows; includeArchive + // returns them with `archived: true` so the command-side index sync + // captures the archived flag (M1.5). + const includeArchive = input?.includeArchive === true; + rows = rows.filter((s) => includeArchive || !state.archivedIds.includes(s.id)); + return rows.map((s) => + state.archivedIds.includes(s.id) ? { ...s, archived: true } : s, + ); }, ), forkSession: vi @@ -198,8 +206,15 @@ function makeFakeBridge(state: FakeBridgeState): ICoreProcessService { getPermission: vi.fn().mockResolvedValue({ mode: 'manual' }), getPlan: vi.fn().mockResolvedValue(null), }; + // `getCoreApi()` mirrors `rpc` on purpose: in production both expose the + // identical CoreAPI method set — `getCoreApi()` is the in-process + // (zero-serialization) path, `rpc` is the serializing proxy. Sharing one + // stub keeps the call-recording / rejection-mock assertions valid without + // duplicating the mock. The intersection type surfaces the narrow in-process + // accessor the session services now route through. return { rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, ready: async () => undefined, dispose: () => undefined, _serviceBrand: undefined, @@ -532,63 +547,6 @@ describe('SessionService.create', () => { }); }); -describe('SessionService.list', () => { - beforeEach(async () => { - await svc.create({ metadata: { cwd: '/tmp/a' } }); - await svc.create({ metadata: { cwd: '/tmp/b' } }); - await svc.create({ metadata: { cwd: '/tmp/c' } }); - }); - - it('returns descending-by-updatedAt order with default page size', async () => { - const page = await svc.list({}); - expect(page.items).toHaveLength(3); - expect(page.items[0]!.metadata.cwd).toBe('/tmp/c'); - expect(page.items[2]!.metadata.cwd).toBe('/tmp/a'); - expect(page.has_more).toBe(false); - }); - - it('honors page_size and surfaces has_more', async () => { - const page = await svc.list({ page_size: 2 }); - expect(page.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/c', '/tmp/b']); - expect(page.has_more).toBe(true); - }); - - it('before_id returns less-recent sessions only', async () => { - const all = await svc.list({}); - const pivotId = all.items[0]!.id; - const olderPage = await svc.list({ before_id: pivotId }); - expect(olderPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/b', '/tmp/a']); - }); - - it('after_id returns more-recent sessions only', async () => { - const all = await svc.list({}); - const pivotId = all.items[2]!.id; - const newerPage = await svc.list({ after_id: pivotId }); - expect(newerPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/c', '/tmp/b']); - }); - - it('status filter applies post-hydration', async () => { - const empty = await svc.list({ status: 'running' }); - expect(empty.items).toEqual([]); - const idle = await svc.list({ status: 'idle' }); - expect(idle.items.length).toBe(3); - }); - - it('forwards workDir to the underlying core.rpc.listSessions for the workspace fast path', async () => { - const page = await svc.list({ workDir: '/tmp/b' }); - expect(page.items).toHaveLength(1); - expect(page.items[0]!.metadata.cwd).toBe('/tmp/b'); - const calls = (state as unknown as { sessions: SessionSummary[] }).sessions; - void calls; - }); - - it('returns an empty page when workDir matches no sessions', async () => { - const page = await svc.list({ workDir: '/tmp/nonexistent' }); - expect(page.items).toEqual([]); - expect(page.has_more).toBe(false); - }); -}); - describe('SessionService.get', () => { it('returns the matching session', async () => { const created = await svc.create({ metadata: { cwd: '/tmp/x' } }); @@ -777,44 +735,6 @@ describe('SessionService children', () => { topic: 'btw', }); }); - - it('lists only direct children for a parent session', async () => { - const parent = await svc.create({ - metadata: { cwd: '/tmp/children' }, - title: 'Parent', - }); - const child = await svc.createChild(parent.id, { title: 'Child one' }); - await svc.fork(parent.id, { metadata: { forked: true } }); - const grandchild = await svc.createChild(child.id, { title: 'Grandchild' }); - - const page = await svc.listChildren(parent.id, {}); - - expect(page.has_more).toBe(false); - expect(page.items.map((item) => item.id)).toEqual([child.id]); - expect(page.items.map((item) => item.id)).not.toContain(grandchild.id); - }); - - it('lists children from persisted summary metadata when SessionMeta is unavailable', async () => { - const parent = await svc.create({ - metadata: { cwd: '/tmp/persisted-child' }, - title: 'Parent', - }); - const child = await svc.createChild(parent.id, { title: 'Child one' }); - state.metas.delete(child.id); - - const page = await svc.listChildren(parent.id, {}); - - expect(page.items.map((item) => item.id)).toEqual([child.id]); - expect(page.items[0]!.metadata).toMatchObject({ - cwd: '/tmp/persisted-child', - parent_session_id: parent.id, - child_session_kind: 'child', - }); - }); - - it('throws SessionNotFoundError when listing children for a missing parent', async () => { - await expect(svc.listChildren('missing', {})).rejects.toBeInstanceOf(SessionNotFoundError); - }); }); describe('SessionService.archive', () => { @@ -973,12 +893,6 @@ describe('SessionService per-domain event listeners (Phase C)', () => { }); describe('SessionService status lifecycle', () => { - it('getStatus returns live status', async () => { - const session = await svc.create({ metadata: { cwd: '/tmp/status' } }); - const status = await svc.getStatus(session.id); - expect(status.status).toBe('idle'); - }); - it('patches created session status to idle', async () => { const session = await svc.create({ metadata: { cwd: '/tmp/status2' } }); expect(session.status).toBe('idle'); @@ -1051,3 +965,62 @@ describe('SessionService status lifecycle', () => { expect(eventBus.events.filter(statusChangedCount).length).toBe(before); }); }); + +describe('SessionService index sync (M1.5)', () => { + it('upserts a created session into the writer-synced index', async () => { + const created = await svc.create({ metadata: { cwd: '/tmp/idx-create' } }); + expect(svc.sessionIndex.get(created.id)).toMatchObject({ + id: created.id, + workDir: '/tmp/idx-create', + }); + }); + + it('re-upserts the session into the index after an update command', async () => { + const created = await svc.create({ metadata: { cwd: '/tmp/idx-update' } }); + const upsert = vi.spyOn(svc.sessionIndex, 'upsert'); + await svc.update(created.id, { title: 'Renamed' }); + expect(upsert).toHaveBeenCalledWith(expect.objectContaining({ id: created.id })); + upsert.mockRestore(); + }); + + it('keeps the index in sync across fork and createChild commands', async () => { + const parent = await svc.create({ metadata: { cwd: '/tmp/idx-fork' }, title: 'Parent' }); + const fork = await svc.fork(parent.id, { title: 'Fork' }); + const child = await svc.createChild(parent.id, { title: 'Child' }); + expect(svc.sessionIndex.get(fork.id)?.id).toBe(fork.id); + expect(svc.sessionIndex.get(child.id)?.id).toBe(child.id); + }); + + it('marks the session archived in the index after archive', async () => { + const created = await svc.create({ metadata: { cwd: '/tmp/idx-archive' } }); + expect(svc.sessionIndex.get(created.id)?.archived).not.toBe(true); + await svc.archive(created.id); + expect(svc.sessionIndex.get(created.id)?.archived).toBe(true); + }); + + it('keeps the index in sync after compact and undo commands', async () => { + const created = await svc.create({ metadata: { cwd: '/tmp/idx-compact' } }); + const compactUpsert = vi.spyOn(svc.sessionIndex, 'upsert'); + await svc.compact(created.id, { instruction: 'focus' }); + expect(compactUpsert).toHaveBeenCalledWith(expect.objectContaining({ id: created.id })); + compactUpsert.mockRestore(); + + state.contexts.set(created.id, { + history: [ + textMessage('user', 'first prompt'), + textMessage('assistant', 'first answer'), + textMessage('user', 'second prompt'), + textMessage('assistant', 'second answer'), + ], + tokenCount: 40, + }); + state.postUndoContexts.set(created.id, { + history: [textMessage('user', 'first prompt'), textMessage('assistant', 'first answer')], + tokenCount: 20, + }); + const undoUpsert = vi.spyOn(svc.sessionIndex, 'upsert'); + await svc.undo(created.id, { count: 1 }); + expect(undoUpsert).toHaveBeenCalledWith(expect.objectContaining({ id: created.id })); + undoUpsert.mockRestore(); + }); +}); diff --git a/packages/agent-core/test/services/session/sessionIndex.test.ts b/packages/agent-core/test/services/session/sessionIndex.test.ts new file mode 100644 index 000000000..476e7c528 --- /dev/null +++ b/packages/agent-core/test/services/session/sessionIndex.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; + +import type { SessionSummary } from '../../../src/rpc'; +import { SessionIndex, type SessionIndexListOpts, type SessionQueryScope } from '#/session'; +import { encodeWorkDirKey } from '../../../src/session/store'; + +const WORKDIR_A = '/repos/alpha'; +const WORKDIR_B = '/repos/beta'; +const WORKSPACE_A = encodeWorkDirKey(WORKDIR_A); +const WORKSPACE_B = encodeWorkDirKey(WORKDIR_B); + +function makeSummary(overrides: Partial & { id: string }): SessionSummary { + return { + workDir: WORKDIR_A, + sessionDir: `/sessions/${overrides.id}`, + createdAt: 1_000, + updatedAt: 1_000, + ...overrides, + }; +} + +function seed(index: SessionIndex, summaries: readonly SessionSummary[]): void { + for (const summary of summaries) { + index.upsert(summary); + } +} + +const GLOBAL: SessionQueryScope = { kind: 'global' }; + +function ids(rows: readonly SessionSummary[]): string[] { + return rows.map((s) => s.id); +} + +describe('SessionIndex', () => { + it('upsert then get returns the summary', () => { + const index = new SessionIndex(); + const summary = makeSummary({ id: 's1', title: 'one' }); + index.upsert(summary); + + expect(index.get('s1')).toEqual(summary); + }); + + it('upsert replaces an existing row for the same id', () => { + const index = new SessionIndex(); + index.upsert(makeSummary({ id: 's1', title: 'first', updatedAt: 1 })); + index.upsert(makeSummary({ id: 's1', title: 'second', updatedAt: 2 })); + + expect(index.get('s1')?.title).toBe('second'); + expect(index.count(GLOBAL)).toBe(1); + }); + + it('remove makes get undefined and excludes the row from list', () => { + const index = new SessionIndex(); + seed(index, [makeSummary({ id: 's1' }), makeSummary({ id: 's2' })]); + + index.remove('s1'); + + expect(index.get('s1')).toBeUndefined(); + expect(ids(index.list(GLOBAL, {}))).toEqual(['s2']); + }); + + it('remove is a no-op for an unknown id', () => { + const index = new SessionIndex(); + index.upsert(makeSummary({ id: 's1' })); + + expect(() => index.remove('missing')).not.toThrow(); + expect(index.count(GLOBAL)).toBe(1); + }); + + it('global scope returns every non-archived row', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'a', workDir: WORKDIR_A }), + makeSummary({ id: 'b', workDir: WORKDIR_B }), + makeSummary({ id: 'c', workDir: WORKDIR_A, archived: true }), + ]); + + expect(ids(index.list(GLOBAL, {})).sort()).toEqual(['a', 'b']); + }); + + it('workspace scope filters by workspaceId (derived from workDir)', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'a1', workDir: WORKDIR_A }), + makeSummary({ id: 'a2', workDir: WORKDIR_A }), + makeSummary({ id: 'b1', workDir: WORKDIR_B }), + ]); + + const scopeA: SessionQueryScope = { kind: 'workspace', workspaceId: WORKSPACE_A }; + const scopeB: SessionQueryScope = { kind: 'workspace', workspaceId: WORKSPACE_B }; + + expect(ids(index.list(scopeA, {})).sort()).toEqual(['a1', 'a2']); + expect(ids(index.list(scopeB, {}))).toEqual(['b1']); + }); + + it('workDir scope filters by exact workDir', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'a', workDir: WORKDIR_A }), + makeSummary({ id: 'b', workDir: WORKDIR_B }), + ]); + + const scope: SessionQueryScope = { kind: 'workDir', workDir: WORKDIR_B }; + expect(ids(index.list(scope, {}))).toEqual(['b']); + }); + + it('children scope filters by parent_session_id + child kind', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ + id: 'child-1', + metadata: { parent_session_id: 'parent', child_session_kind: 'child' }, + }), + makeSummary({ + id: 'child-2', + metadata: { parent_session_id: 'parent', child_session_kind: 'child' }, + }), + makeSummary({ + id: 'other-parent', + metadata: { parent_session_id: 'someone-else', child_session_kind: 'child' }, + }), + makeSummary({ + id: 'wrong-kind', + metadata: { parent_session_id: 'parent', child_session_kind: 'fork' }, + }), + makeSummary({ id: 'no-meta' }), + ]); + + const scope: SessionQueryScope = { kind: 'children', parentId: 'parent' }; + expect(ids(index.list(scope, {})).sort()).toEqual(['child-1', 'child-2']); + }); + + it('archived visibility: exclude (default) / include / only', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'live' }), + makeSummary({ id: 'archived', archived: true }), + ]); + + const exclude: SessionIndexListOpts = { archived: 'exclude' }; + const include: SessionIndexListOpts = { archived: 'include' }; + const only: SessionIndexListOpts = { archived: 'only' }; + + expect(ids(index.list(GLOBAL, {}))).toEqual(['live']); // default exclude + expect(ids(index.list(GLOBAL, exclude))).toEqual(['live']); + expect(ids(index.list(GLOBAL, include)).sort()).toEqual(['archived', 'live']); + expect(ids(index.list(GLOBAL, only))).toEqual(['archived']); + }); + + it('orderBy updatedAt desc (default) orders newest first', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'old', updatedAt: 1 }), + makeSummary({ id: 'new', updatedAt: 3 }), + makeSummary({ id: 'mid', updatedAt: 2 }), + ]); + + expect(ids(index.list(GLOBAL, {}))).toEqual(['new', 'mid', 'old']); + }); + + it('orderBy createdAt asc orders oldest first', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'c', createdAt: 300 }), + makeSummary({ id: 'a', createdAt: 100 }), + makeSummary({ id: 'b', createdAt: 200 }), + ]); + + const opts: SessionIndexListOpts = { orderBy: 'createdAt', orderDirection: 'asc' }; + expect(ids(index.list(GLOBAL, opts))).toEqual(['a', 'b', 'c']); + }); + + it('orderBy title desc ties break by id ascending', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'x2', title: 'same' }), + makeSummary({ id: 'x1', title: 'same' }), + makeSummary({ id: 'z', title: 'zzz' }), + makeSummary({ id: 'a', title: 'aaa' }), + ]); + + const opts: SessionIndexListOpts = { orderBy: 'title', orderDirection: 'desc' }; + // zzz > same > aaa; the two `same` rows tie-break by id (x1 < x2). + expect(ids(index.list(GLOBAL, opts))).toEqual(['z', 'x1', 'x2', 'a']); + }); + + it('pagination: limit caps the page', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 's1', updatedAt: 1 }), + makeSummary({ id: 's2', updatedAt: 2 }), + makeSummary({ id: 's3', updatedAt: 3 }), + ]); + + const opts: SessionIndexListOpts = { limit: 2 }; + expect(ids(index.list(GLOBAL, opts))).toEqual(['s3', 's2']); + }); + + it('pagination: cursor returns the next page deterministically', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 's1', updatedAt: 1 }), + makeSummary({ id: 's2', updatedAt: 2 }), + makeSummary({ id: 's3', updatedAt: 3 }), + makeSummary({ id: 's4', updatedAt: 4 }), + ]); + + const first = index.list(GLOBAL, { limit: 2 }); + expect(ids(first)).toEqual(['s4', 's3']); + + const next = index.list(GLOBAL, { cursor: first[first.length - 1]!.id, limit: 2 }); + expect(ids(next)).toEqual(['s2', 's1']); + }); + + it('search matches by title within a scope (case-insensitive)', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'a', workDir: WORKDIR_A, title: 'Fix login bug' }), + makeSummary({ id: 'b', workDir: WORKDIR_A, title: 'Refactor auth' }), + makeSummary({ id: 'c', workDir: WORKDIR_B, title: 'fix signup flow' }), + ]); + + const scopeA: SessionQueryScope = { kind: 'workspace', workspaceId: WORKSPACE_A }; + expect(ids(index.search(scopeA, 'fix', {}))).toEqual(['a']); + expect(ids(index.search(GLOBAL, 'fix', {})).sort()).toEqual(['a', 'c']); + expect(index.search(GLOBAL, 'nope', {})).toEqual([]); + }); + + it('count matches list length for a scope (no pagination applied)', () => { + const index = new SessionIndex(); + seed(index, [ + makeSummary({ id: 'a1', workDir: WORKDIR_A }), + makeSummary({ id: 'a2', workDir: WORKDIR_A }), + makeSummary({ id: 'b1', workDir: WORKDIR_B }), + makeSummary({ id: 'a3', workDir: WORKDIR_A, archived: true }), + ]); + + const scopeA: SessionQueryScope = { kind: 'workspace', workspaceId: WORKSPACE_A }; + expect(index.count(scopeA)).toBe(2); + expect(index.count(scopeA)).toBe(index.list(scopeA, {}).length); + expect(index.count(scopeA, { archived: 'include' })).toBe(3); + // count ignores limit — it measures the whole scoped set. + expect(index.count(scopeA, { limit: 1 })).toBe(2); + }); +}); diff --git a/packages/agent-core/test/services/session/sessionQueryService.test.ts b/packages/agent-core/test/services/session/sessionQueryService.test.ts new file mode 100644 index 000000000..6ebec9049 --- /dev/null +++ b/packages/agent-core/test/services/session/sessionQueryService.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Event } from '@moonshot-ai/protocol'; +import { + type CoreRPC, + Emitter, + IInstantiationService, + type ResumeSessionResult, + type SessionMeta, + type SessionSummary, +} from '../../../src'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IApprovalService } from '#/approval'; +import type { IEventService } from '#/event'; +import { IQuestionService } from '#/question'; +import { IPromptService } from '#/prompt'; +import { type ICoreRuntime } from '#/coreProcess'; +import { SessionQueryService, SessionNotFoundError, type SessionQueryScope } from '#/session'; +import { encodeWorkDirKey } from '../../../src/session/store'; + +const WORKDIR_A = '/repos/alpha'; +const WORKDIR_B = '/repos/beta'; +const WORKSPACE_A = encodeWorkDirKey(WORKDIR_A); +const WORKSPACE_B = encodeWorkDirKey(WORKDIR_B); + +interface FakeState { + sessions: SessionSummary[]; + metas: Map; + resumedIds: string[]; +} + +function freshState(): FakeState { + return { + sessions: [], + metas: new Map(), + resumedIds: [], + }; +} + +function makeSummary(overrides: Partial & { id: string }): SessionSummary { + return { + workDir: WORKDIR_A, + sessionDir: `/sessions/${overrides.id}`, + createdAt: overrides.updatedAt ?? 1_000, + updatedAt: 1_000, + ...overrides, + }; +} + +function makeFakeBridge(state: FakeState): ICoreRuntime & { getCoreApi(): CoreRPC } { + const rpc: Partial = { + listSessions: vi + .fn() + .mockImplementation( + async (input?: { + workDir?: string; + includeArchive?: boolean; + }): Promise => { + if (input?.workDir !== undefined) { + return state.sessions.filter((s) => s.workDir === input.workDir); + } + return state.sessions; + }, + ), + getSessionMetadata: vi + .fn() + .mockImplementation(async ({ sessionId }: { sessionId: string }): Promise => { + const found = state.metas.get(sessionId); + if (found === undefined) { + throw new Error(`no metadata for ${sessionId}`); + } + return found; + }), + resumeSession: vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { + state.resumedIds.push(sessionId); + const found = state.sessions.find((s) => s.id === sessionId); + if (found === undefined) throw new Error(`missing session ${sessionId}`); + return found as unknown as ResumeSessionResult; + }), + }; + // `getCoreApi()` mirrors `rpc` on purpose: in production both expose the + // identical CoreAPI method set — `getCoreApi()` is the in-process + // (zero-serialization) path, `rpc` is the serializing proxy. Sharing one + // stub keeps the `bridge.rpc.*` call-recording assertions valid without + // duplicating the mock. + return { + rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, + ready: async () => undefined, + dispose: () => undefined, + _serviceBrand: undefined, + }; +} + +function makeEventServiceStub(): { + eventService: IEventService; + events: unknown[]; +} { + const events: unknown[] = []; + const emitter = new Emitter(); + return { + events, + eventService: { + _serviceBrand: undefined, + publish: vi.fn((event: unknown) => { + events.push(event); + emitter.fire(event as never); + }) as IEventService['publish'], + onDidPublish: emitter.event as unknown as IEventService['onDidPublish'], + }, + }; +} + +function makePromptServiceStub(): { + promptService: IPromptService; + activePromptIds: Map; +} { + const activePromptIds = new Map(); + const emitter = new Emitter(); + const promptService: IPromptService = { + _serviceBrand: undefined, + list: vi.fn() as unknown as IPromptService['list'], + submit: vi.fn() as unknown as IPromptService['submit'], + startBtw: vi.fn() as unknown as IPromptService['startBtw'], + steer: vi.fn() as unknown as IPromptService['steer'], + abort: vi.fn() as unknown as IPromptService['abort'], + abortBySession: vi.fn() as unknown as IPromptService['abortBySession'], + getCurrentPromptId: vi.fn().mockImplementation((sid: string) => + activePromptIds.get(sid), + ) as unknown as IPromptService['getCurrentPromptId'], + applyAgentState: vi.fn() as unknown as IPromptService['applyAgentState'], + onDidComplete: emitter.event as unknown as IPromptService['onDidComplete'], + onDidAbort: emitter.event as unknown as IPromptService['onDidAbort'], + getAgentStateSnapshot: vi + .fn() + .mockReturnValue(undefined) as unknown as IPromptService['getAgentStateSnapshot'], + }; + return { promptService, activePromptIds }; +} + +function makeApprovalServiceStub(): { + approvalService: IApprovalService; + pending: Map; +} { + const pending = new Map(); + const approvalService: IApprovalService = { + _serviceBrand: undefined, + request: vi.fn() as unknown as IApprovalService['request'], + resolve: vi.fn() as unknown as IApprovalService['resolve'], + listPending: vi.fn().mockImplementation((sessionId: string) => { + return (pending.get(sessionId) ?? []) as unknown as ReturnType< + IApprovalService['listPending'] + >; + }), + } as unknown as IApprovalService; + return { approvalService, pending }; +} + +function makeQuestionServiceStub(): { + questionService: IQuestionService; + pending: Map; +} { + const pending = new Map(); + const questionService: IQuestionService = { + _serviceBrand: undefined, + request: vi.fn() as unknown as IQuestionService['request'], + resolve: vi.fn() as unknown as IQuestionService['resolve'], + dismiss: vi.fn() as unknown as IQuestionService['dismiss'], + listPending: vi.fn().mockImplementation((sessionId: string) => { + return (pending.get(sessionId) ?? []) as unknown as ReturnType< + IQuestionService['listPending'] + >; + }), + } as unknown as IQuestionService; + return { questionService, pending }; +} + +let state: FakeState; +let bridge: ICoreRuntime; +let svc: SessionQueryService; +let promptStub: ReturnType; +let approvalStub: ReturnType; +let questionStub: ReturnType; +let eventBus: ReturnType; +let instantiation: TestInstantiationService; + +beforeEach(() => { + state = freshState(); + bridge = makeFakeBridge(state); + promptStub = makePromptServiceStub(); + approvalStub = makeApprovalServiceStub(); + questionStub = makeQuestionServiceStub(); + eventBus = makeEventServiceStub(); + instantiation = new TestInstantiationService(undefined, true); + instantiation.stub(IInstantiationService, instantiation); + instantiation.stub(IPromptService, promptStub.promptService); + instantiation.stub(IApprovalService, approvalStub.approvalService); + instantiation.stub(IQuestionService, questionStub.questionService); + svc = new SessionQueryService( + bridge, + eventBus.eventService, + instantiation, + approvalStub.approvalService, + questionStub.questionService, + ); +}); + +afterEach(() => { + svc.dispose(); + instantiation.dispose(); +}); + +function ids(page: { items: readonly { id: string }[] }): string[] { + return page.items.map((s) => s.id); +} + +describe('SessionQueryService.list', () => { + it('returns descending-by-updatedAt order with default page size', async () => { + state.sessions.push( + makeSummary({ id: 'old', updatedAt: 1 }), + makeSummary({ id: 'new', updatedAt: 3 }), + makeSummary({ id: 'mid', updatedAt: 2 }), + ); + + const page = await svc.list({}); + + expect(ids(page)).toEqual(['new', 'mid', 'old']); + expect(page.has_more).toBe(false); + }); + + it('honors page_size and surfaces has_more', async () => { + state.sessions.push( + makeSummary({ id: 'a', updatedAt: 3 }), + makeSummary({ id: 'b', updatedAt: 2 }), + makeSummary({ id: 'c', updatedAt: 1 }), + ); + + const page = await svc.list({ page_size: 2 }); + + expect(ids(page)).toEqual(['a', 'b']); + expect(page.has_more).toBe(true); + }); + + it('before_id returns less-recent sessions only', async () => { + state.sessions.push( + makeSummary({ id: 'a', updatedAt: 3 }), + makeSummary({ id: 'b', updatedAt: 2 }), + makeSummary({ id: 'c', updatedAt: 1 }), + ); + + const older = await svc.list({ before_id: 'a' }); + + expect(ids(older)).toEqual(['b', 'c']); + }); + + it('after_id returns more-recent sessions only', async () => { + state.sessions.push( + makeSummary({ id: 'a', updatedAt: 3 }), + makeSummary({ id: 'b', updatedAt: 2 }), + makeSummary({ id: 'c', updatedAt: 1 }), + ); + + const newer = await svc.list({ after_id: 'c' }); + + expect(ids(newer)).toEqual(['a', 'b']); + }); + + it('status filter applies post-hydration', async () => { + state.sessions.push( + makeSummary({ id: 'a', updatedAt: 2 }), + makeSummary({ id: 'b', updatedAt: 1 }), + ); + + const running = await svc.list({ status: 'running' }); + const idle = await svc.list({ status: 'idle' }); + + expect(running.items).toEqual([]); + expect(idle.items).toHaveLength(2); + }); + + it('does not resume an agent during a plain list (cold path only)', async () => { + state.sessions.push( + makeSummary({ id: 'a', updatedAt: 2 }), + makeSummary({ id: 'b', updatedAt: 1 }), + ); + + await svc.list({}); + + // The query service never touches the runtime session aggregate, so + // getReadyAgent is unreachable by construction; the warm-path resume + // primitive must not be invoked either. + expect(bridge.rpc.resumeSession).not.toHaveBeenCalled(); + expect(state.resumedIds).toEqual([]); + // Cold reads still happen: the index is seeded from listSessions and each + // row is hydrated via getSessionMetadata. + expect(bridge.rpc.listSessions).toHaveBeenCalled(); + expect(bridge.rpc.getSessionMetadata).toHaveBeenCalled(); + }); +}); + +describe('SessionQueryService.count', () => { + it('counts visible (non-archived) sessions in global scope by default', async () => { + state.sessions.push( + makeSummary({ id: 'live-1' }), + makeSummary({ id: 'live-2' }), + makeSummary({ id: 'archived', archived: true }), + ); + + expect(await svc.count()).toBe(2); + expect(await svc.count({ kind: 'global' })).toBe(2); + }); + + it('counts within a workspace scope', async () => { + state.sessions.push( + makeSummary({ id: 'a1', workDir: WORKDIR_A }), + makeSummary({ id: 'a2', workDir: WORKDIR_A }), + makeSummary({ id: 'b1', workDir: WORKDIR_B }), + ); + + const scopeA: SessionQueryScope = { kind: 'workspace', workspaceId: WORKSPACE_A }; + expect(await svc.count(scopeA)).toBe(2); + }); +}); + +describe('SessionQueryService.listChildren', () => { + it('filters to direct children of the parent', async () => { + state.sessions.push( + makeSummary({ id: 'parent', updatedAt: 10 }), + makeSummary({ + id: 'child-1', + updatedAt: 9, + metadata: { parent_session_id: 'parent', child_session_kind: 'child' }, + }), + makeSummary({ + id: 'child-2', + updatedAt: 8, + metadata: { parent_session_id: 'parent', child_session_kind: 'child' }, + }), + makeSummary({ + id: 'other', + updatedAt: 7, + metadata: { parent_session_id: 'someone-else', child_session_kind: 'child' }, + }), + makeSummary({ id: 'no-meta', updatedAt: 6 }), + ); + + const page = await svc.listChildren('parent', {}); + + expect(ids(page).sort()).toEqual(['child-1', 'child-2']); + }); + + it('throws SessionNotFoundError for a missing parent', async () => { + await expect(svc.listChildren('missing', {})).rejects.toBeInstanceOf(SessionNotFoundError); + }); +}); + +describe('SessionQueryService archive visibility', () => { + it('exclude (default) hides archived; include surfaces them', async () => { + state.sessions.push( + makeSummary({ id: 'live', updatedAt: 2 }), + makeSummary({ id: 'archived', updatedAt: 1, archived: true }), + ); + + const excluded = await svc.listGlobal({}); + const included = await svc.listGlobal({ includeArchive: true }); + + expect(ids(excluded)).toEqual(['live']); + expect(ids(included).sort()).toEqual(['archived', 'live']); + }); +}); + +describe('SessionQueryService.listByWorkspace', () => { + it('filters by workspace derived from workDir', async () => { + state.sessions.push( + makeSummary({ id: 'a1', workDir: WORKDIR_A, updatedAt: 3 }), + makeSummary({ id: 'a2', workDir: WORKDIR_A, updatedAt: 2 }), + makeSummary({ id: 'b1', workDir: WORKDIR_B, updatedAt: 1 }), + ); + + const page = await svc.listByWorkspace(WORKSPACE_B, {}); + + expect(ids(page)).toEqual(['b1']); + }); +}); + +describe('SessionQueryService.search', () => { + it('matches by title (case-insensitive) within the implied scope', async () => { + state.sessions.push( + makeSummary({ id: 'a', title: 'Fix login bug', updatedAt: 3 }), + makeSummary({ id: 'b', title: 'Refactor auth', updatedAt: 2 }), + makeSummary({ id: 'c', title: 'fix signup flow', updatedAt: 1 }), + ); + + const page = await svc.search({ q: 'fix' }); + + expect(ids(page).sort()).toEqual(['a', 'c']); + }); +}); + +describe('SessionQueryService live status', () => { + it('reflects turn.started as running via the shared status derivation', async () => { + state.sessions.push(makeSummary({ id: 's', updatedAt: 1 })); + + eventBus.eventService.publish({ type: 'turn.started', sessionId: 's' } as unknown as Event); + + const page = await svc.list({}); + expect(page.items[0]?.status).toBe('running'); + }); +}); diff --git a/packages/agent-core/test/services/session/sessionRuntimeService.test.ts b/packages/agent-core/test/services/session/sessionRuntimeService.test.ts new file mode 100644 index 000000000..058537875 --- /dev/null +++ b/packages/agent-core/test/services/session/sessionRuntimeService.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Event, SessionStatus } from '@moonshot-ai/protocol'; +import { + type CoreRPC, + Emitter, + IInstantiationService, + type SessionSummary, +} from '../../../src'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IApprovalService } from '#/approval'; +import type { IEventService } from '#/event'; +import { IQuestionService } from '#/question'; +import { IPromptService, type AgentStateSnapshot } from '#/prompt'; +import { type ICoreRuntime } from '#/coreProcess'; +import { SessionRuntimeService, SessionNotFoundError } from '#/session'; + +const WORKDIR_A = '/repos/alpha'; + +interface FakeState { + sessions: SessionSummary[]; +} + +function freshState(): FakeState { + return { sessions: [] }; +} + +function makeSummary(overrides: Partial & { id: string }): SessionSummary { + return { + workDir: WORKDIR_A, + sessionDir: `/sessions/${overrides.id}`, + createdAt: overrides.updatedAt ?? 1_000, + updatedAt: 1_000, + ...overrides, + }; +} + +function makeFakeBridge(state: FakeState): ICoreRuntime & { getCoreApi(): CoreRPC } { + const rpc: Partial = { + listSessions: vi + .fn() + .mockImplementation(async (): Promise => state.sessions), + getConfig: vi.fn().mockResolvedValue({ + cwd: WORKDIR_A, + modelAlias: 'kimi-k2', + thinkingLevel: 'medium', + modelCapabilities: { max_context_tokens: 1000 }, + systemPrompt: '', + }), + getContext: vi.fn().mockResolvedValue({ history: [], tokenCount: 250 }), + getPermission: vi.fn().mockResolvedValue({ mode: 'default', rules: [] }), + getPlan: vi.fn().mockResolvedValue(null), + }; + // `getCoreApi()` mirrors `rpc` on purpose: in production both expose the + // identical CoreAPI method set — `getCoreApi()` is the in-process + // (zero-serialization) path, `rpc` is the serializing proxy. Sharing one + // stub keeps the `bridge.rpc.*` call-recording assertions valid without + // duplicating the mock. + return { + rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, + ready: async () => undefined, + dispose: () => undefined, + _serviceBrand: undefined, + }; +} + +function makeEventServiceStub(): { + eventService: IEventService; + published: unknown[]; +} { + const published: unknown[] = []; + const emitter = new Emitter(); + return { + published, + eventService: { + _serviceBrand: undefined, + publish: vi.fn((event: unknown) => { + published.push(event); + emitter.fire(event as never); + }) as IEventService['publish'], + onDidPublish: emitter.event as unknown as IEventService['onDidPublish'], + }, + }; +} + +function makePromptServiceStub(): { + promptService: IPromptService; + activePromptIds: Map; + snapshots: Map; +} { + const activePromptIds = new Map(); + const snapshots = new Map(); + const emitter = new Emitter(); + const promptService: IPromptService = { + _serviceBrand: undefined, + list: vi.fn() as unknown as IPromptService['list'], + submit: vi.fn() as unknown as IPromptService['submit'], + startBtw: vi.fn() as unknown as IPromptService['startBtw'], + steer: vi.fn() as unknown as IPromptService['steer'], + abort: vi.fn() as unknown as IPromptService['abort'], + abortBySession: vi.fn() as unknown as IPromptService['abortBySession'], + getCurrentPromptId: vi.fn().mockImplementation((sid: string) => + activePromptIds.get(sid), + ) as unknown as IPromptService['getCurrentPromptId'], + applyAgentState: vi.fn() as unknown as IPromptService['applyAgentState'], + onDidComplete: emitter.event as unknown as IPromptService['onDidComplete'], + onDidAbort: emitter.event as unknown as IPromptService['onDidAbort'], + getAgentStateSnapshot: vi.fn().mockImplementation((sid: string) => + snapshots.get(sid), + ) as unknown as IPromptService['getAgentStateSnapshot'], + }; + return { promptService, activePromptIds, snapshots }; +} + +function makeApprovalServiceStub(): { + approvalService: IApprovalService; + pending: Map; +} { + const pending = new Map(); + const approvalService: IApprovalService = { + _serviceBrand: undefined, + request: vi.fn() as unknown as IApprovalService['request'], + resolve: vi.fn() as unknown as IApprovalService['resolve'], + listPending: vi.fn().mockImplementation((sessionId: string) => { + return (pending.get(sessionId) ?? []) as unknown as ReturnType< + IApprovalService['listPending'] + >; + }), + } as unknown as IApprovalService; + return { approvalService, pending }; +} + +function makeQuestionServiceStub(): { + questionService: IQuestionService; + pending: Map; +} { + const pending = new Map(); + const questionService: IQuestionService = { + _serviceBrand: undefined, + request: vi.fn() as unknown as IQuestionService['request'], + resolve: vi.fn() as unknown as IQuestionService['resolve'], + dismiss: vi.fn() as unknown as IQuestionService['dismiss'], + listPending: vi.fn().mockImplementation((sessionId: string) => { + return (pending.get(sessionId) ?? []) as unknown as ReturnType< + IQuestionService['listPending'] + >; + }), + } as unknown as IQuestionService; + return { questionService, pending }; +} + +const SESSION_STATUSES: readonly SessionStatus[] = [ + 'idle', + 'running', + 'awaiting_approval', + 'awaiting_question', + 'aborted', +]; + +let state: FakeState; +let bridge: ICoreRuntime; +let svc: SessionRuntimeService; +let promptStub: ReturnType; +let approvalStub: ReturnType; +let questionStub: ReturnType; +let eventBus: ReturnType; +let instantiation: TestInstantiationService; + +beforeEach(() => { + state = freshState(); + bridge = makeFakeBridge(state); + promptStub = makePromptServiceStub(); + approvalStub = makeApprovalServiceStub(); + questionStub = makeQuestionServiceStub(); + eventBus = makeEventServiceStub(); + instantiation = new TestInstantiationService(undefined, true); + instantiation.stub(IInstantiationService, instantiation); + instantiation.stub(IPromptService, promptStub.promptService); + instantiation.stub(IApprovalService, approvalStub.approvalService); + instantiation.stub(IQuestionService, questionStub.questionService); + svc = new SessionRuntimeService( + bridge, + eventBus.eventService, + instantiation, + approvalStub.approvalService, + questionStub.questionService, + ); +}); + +afterEach(() => { + svc.dispose(); + instantiation.dispose(); +}); + +describe('SessionRuntimeService.getStatus', () => { + it('returns idle for a cold (archived) session with no live state', async () => { + state.sessions.push(makeSummary({ id: 'cold', archived: true })); + + const status = await svc.getStatus('cold'); + + expect(status.status).toBe('idle'); + expect(status.context_tokens).toBe(250); + expect(status.max_context_tokens).toBe(1000); + expect(status.context_usage).toBeCloseTo(0.25); + expect(status.model).toBe('kimi-k2'); + expect(status.thinking_level).toBe('medium'); + expect(status.permission).toBe('default'); + expect(status.plan_mode).toBe(false); + expect(status.swarm_mode).toBe(false); + }); + + it('returns running for a live session with an active prompt', async () => { + state.sessions.push(makeSummary({ id: 'live' })); + promptStub.activePromptIds.set('live', 'prompt-1'); + promptStub.snapshots.set('live', { swarmMode: true, model: 'kimi-k2' }); + + const status = await svc.getStatus('live'); + + expect(status.status).toBe('running'); + expect(status.swarm_mode).toBe(true); + }); + + it('returns a clear SessionStatus enum (never undefined) for a cold session', async () => { + state.sessions.push(makeSummary({ id: 's' })); + + const status = await svc.getStatus('s'); + + expect(SESSION_STATUSES).toContain(status.status); + expect(status.status).toBe('idle'); + }); + + it('throws SessionNotFoundError for an unknown session', async () => { + await expect(svc.getStatus('missing')).rejects.toBeInstanceOf(SessionNotFoundError); + }); +}); + +describe('SessionRuntimeService.getLiveState', () => { + it('returns the cold indicator for an unknown session', async () => { + expect(await svc.getLiveState('unknown')).toEqual({ live: false }); + }); + + it('returns a live descriptor when an agent is loaded', async () => { + promptStub.snapshots.set('s', { model: 'kimi-k2', swarmMode: true }); + + const live = await svc.getLiveState('s'); + + expect(live).toEqual({ + live: true, + agentState: { model: 'kimi-k2', swarmMode: true }, + }); + }); +}); + +describe('SessionRuntimeService.onDidChangeStatus', () => { + it('fires when an IEventService event changes the status', async () => { + const listener = vi.fn(); + svc.onDidChangeStatus(listener); + + eventBus.eventService.publish({ type: 'turn.started', sessionId: 's' } as unknown as Event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 's', + status: 'running', + previousStatus: 'idle', + }), + ); + }); + + it('does NOT fire when the status is unchanged', () => { + const listener = vi.fn(); + svc.onDidChangeStatus(listener); + + eventBus.eventService.publish({ type: 'prompt.completed', sessionId: 's' } as unknown as Event); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('republishes event.session.status_changed with the same payload', () => { + svc.onDidChangeStatus(vi.fn()); + + eventBus.eventService.publish({ type: 'turn.started', sessionId: 's' } as unknown as Event); + + const statusChanged = eventBus.published.find( + (e) => (e as { type?: string }).type === 'event.session.status_changed', + ); + expect(statusChanged).toMatchObject({ + type: 'event.session.status_changed', + sessionId: 's', + status: 'running', + previous_status: 'idle', + }); + }); +}); diff --git a/packages/agent-core/test/services/task-service.test.ts b/packages/agent-core/test/services/task-service.test.ts index 7dc59aff8..245bf9c90 100644 --- a/packages/agent-core/test/services/task-service.test.ts +++ b/packages/agent-core/test/services/task-service.test.ts @@ -1,7 +1,7 @@ /** * `TaskService` (Chain 8 / P1.8, W9.2) unit tests. * - * Hermetic: mocks `ICoreProcessService` with an in-memory `rpc` proxy. Coverage: + * Hermetic: mocks `ICoreRuntime` with an in-memory `rpc` proxy. Coverage: * - kind mapping (process/agent/question → bash/subagent/tool) * - status mapping (running/completed/failed/timed_out/killed/lost → wire) * - timestamp synthesis (created_at = started_at from startedAt; completed_at @@ -21,14 +21,14 @@ import type { StopBackgroundPayload, } from '../../src'; +import { SessionNotFoundError } from '#/session'; import { - type ICoreProcessService, - SessionNotFoundError, TaskAlreadyFinishedError, TaskNotFoundError, TaskService, toProtocolTask, } from '../../src/services'; +import { type ICoreRuntime } from '#/coreProcess'; interface FakeState { sessions: SessionSummary[]; @@ -37,7 +37,7 @@ interface FakeState { stopCalls: Array; } -function makeBridge(state: FakeState): ICoreProcessService { +function makeBridge(state: FakeState): ICoreRuntime & { getCoreApi(): CoreRPC } { const rpc: Partial = { listSessions: async () => state.sessions, getBackground: async (p: { sessionId: string; agentId: string; activeOnly?: boolean }) => @@ -57,10 +57,14 @@ function makeBridge(state: FakeState): ICoreProcessService { }, }; return { + _serviceBrand: undefined, rpc: rpc as CoreRPC, + // `getCoreApi()` mirrors `rpc`: both expose the identical CoreAPI method + // set; `getCoreApi()` is the in-process zero-serialization path the + // service now routes through, `rpc` is the serializing proxy. + getCoreApi: () => rpc as CoreRPC, ready: async () => undefined, dispose: () => undefined, - _serviceBrand: undefined, }; } diff --git a/packages/agent-core/test/services/terminal-service.test.ts b/packages/agent-core/test/services/terminal-service.test.ts index 5874eff4f..dbc8c9700 100644 --- a/packages/agent-core/test/services/terminal-service.test.ts +++ b/packages/agent-core/test/services/terminal-service.test.ts @@ -6,12 +6,11 @@ import { Emitter } from '../../src'; import type { Session } from '@moonshot-ai/protocol'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SessionNotFoundError, type ISessionService } from '#/session'; import { FsPathEscapesError, - SessionNotFoundError, TerminalNotFoundError, TerminalService, - type ISessionService, type TerminalBackend, type TerminalFrame, type TerminalProcess, @@ -117,7 +116,6 @@ function makeSessionService(sessions: Map): ISessionService { create: async () => { throw new Error('not implemented'); }, - list: async () => ({ items: [...sessions.values()], has_more: false }), get: async (id: string) => { const found = sessions.get(id); if (found === undefined) throw new SessionNotFoundError(id); @@ -129,13 +127,9 @@ function makeSessionService(sessions: Map): ISessionService { fork: async () => { throw new Error('not implemented'); }, - listChildren: async () => ({ items: [], has_more: false }), createChild: async () => { throw new Error('not implemented'); }, - getStatus: async () => { - throw new Error('not implemented'); - }, compact: async () => { throw new Error('not implemented'); }, diff --git a/packages/agent-core/test/services/tool-service.test.ts b/packages/agent-core/test/services/tool-service.test.ts index 59dbd6b35..c40b4903b 100644 --- a/packages/agent-core/test/services/tool-service.test.ts +++ b/packages/agent-core/test/services/tool-service.test.ts @@ -1,7 +1,7 @@ /** * `ToolService` + `McpService` (Chain 7 / P1.7, W9.1) unit tests. * - * Hermetic: mocks `ICoreProcessService` with an in-memory `rpc` proxy. Exercises: + * Hermetic: mocks `ICoreRuntime` with an in-memory `rpc` proxy. Exercises: * - tool source mapping: 'builtin' / 'user'→'skill' / 'mcp' + mcp_server_id parse * - mcp server status mapping (all 5 agent-core literals → 4 wire literals) * - transport pass-through @@ -21,13 +21,13 @@ import type { } from '../../src'; import { - type ICoreProcessService, McpServerNotFoundError, McpService, ToolService, toProtocolMcpServer, toProtocolTool, } from '../../src/services'; +import { type ICoreRuntime } from '#/coreProcess'; import type { AgentCoreToolInfoLike } from '../../src/services'; interface FakeBridgeState { @@ -37,7 +37,7 @@ interface FakeBridgeState { reconnectCalls: ReconnectMcpServerPayload[]; } -function makeFakeBridge(state: FakeBridgeState): ICoreProcessService { +function makeFakeBridge(state: FakeBridgeState): ICoreRuntime & { getCoreApi(): CoreRPC } { const rpc: Partial = { listSessions: async () => state.sessions, getTools: async (_p: unknown) => state.tools as unknown as readonly never[], @@ -48,8 +48,14 @@ function makeFakeBridge(state: FakeBridgeState): ICoreProcessService { state.reconnectCalls.push(p); }, }; + // `getCoreApi()` mirrors `rpc` on purpose: in production both expose the + // identical CoreAPI method set — `getCoreApi()` is the in-process + // (zero-serialization) path, `rpc` is the serializing proxy. Sharing one + // stub keeps the `bridge.rpc.*` assertions (e.g. the ToolService override + // below) valid without duplicating the mock. return { rpc: rpc as CoreRPC, + getCoreApi: () => rpc as CoreRPC, ready: async () => undefined, dispose: () => undefined, _serviceBrand: undefined, diff --git a/packages/agent-core/test/services/workspace/workspaceService.test.ts b/packages/agent-core/test/services/workspace/workspaceService.test.ts new file mode 100644 index 000000000..da6c8b065 --- /dev/null +++ b/packages/agent-core/test/services/workspace/workspaceService.test.ts @@ -0,0 +1,125 @@ +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Emitter } from '../../../src'; +import { IEnvironmentService } from '../../../src/services/environment/environment'; +import { IEventService } from '#/event'; +import { ILogService } from '../../../src/services/logger/logger'; +import { RECENT_ROOTS_LIMIT } from '../../../src/services/workspace/workspaceFs'; +import { + WorkspaceNotFoundError, +} from '../../../src/services/workspace/workspaceRegistry'; +import { WorkspaceFsService } from '../../../src/services/workspace/workspaceFsService'; +import { WorkspaceRegistryService } from '../../../src/services/workspace/workspaceRegistryService'; +import { WorkspaceService } from '../../../src/services/workspace/workspaceService'; + +import type { Event as ProtocolEvent } from '@moonshot-ai/protocol'; + +class FakeLogService implements ILogService { + readonly _serviceBrand: undefined; + info(): void {} + warn(): void {} + error(): void {} + debug(): void {} + child(): ILogService { + return this; + } +} + +class FakeEventService implements IEventService { + readonly _serviceBrand: undefined; + private readonly emitter = new Emitter(); + readonly onDidPublish = this.emitter.event; + publish(event: ProtocolEvent): void { + this.emitter.fire(event); + } +} + +function makeEnv(homeDir: string): IEnvironmentService { + return { + _serviceBrand: undefined, + homeDir, + configPath: join(homeDir, 'config.toml'), + }; +} + +describe('WorkspaceService (M1.6 facade)', () => { + let tmpHome: string; + let root: string; + let service: WorkspaceService; + + beforeEach(() => { + tmpHome = mkdtempSync(join(os.tmpdir(), 'kimi-ws-svc-')); + root = join(tmpHome, 'ws-root'); + mkdirSync(root, { recursive: true }); + + const registry = new WorkspaceRegistryService( + makeEnv(tmpHome), + new FakeLogService(), + new FakeEventService(), + ); + const fs = new WorkspaceFsService(registry); + service = new WorkspaceService(registry, fs); + }); + + afterEach(() => { + service.dispose(); + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('register (createOrTouch) then get returns the workspace', async () => { + const created = await service.createOrTouch(root, 'my-ws'); + expect(created.name).toBe('my-ws'); + expect(created.root).toBe(realpathSync(root)); + + const fetched = await service.get(created.id); + expect(fetched.id).toBe(created.id); + expect(fetched.name).toBe('my-ws'); + expect(fetched.root).toBe(created.root); + }); + + it('resolveRoot returns the workDir for a workspace_id', async () => { + const created = await service.createOrTouch(root); + const resolved = await service.resolveRoot(created.id); + expect(resolved).toBe(realpathSync(root)); + }); + + it('list returns registered workspaces', async () => { + const created = await service.createOrTouch(root); + const all = await service.list(); + expect(all.map((w) => w.id)).toContain(created.id); + expect(all).toHaveLength(1); + }); + + it('browse returns the fs browse response (delegate)', async () => { + mkdirSync(join(root, 'child-dir')); + const response = await service.browse(root); + expect(response.path).toBe(realpathSync(root)); + const child = response.entries.find((e) => e.name === 'child-dir'); + expect(child).toBeDefined(); + expect(child?.is_dir).toBe(true); + }); + + it('home delegates to the fs service and surfaces recent roots', async () => { + await service.createOrTouch(root); + const response = await service.home(); + expect(response.home).toBe(os.homedir()); + expect(response.recent_roots).toContain(realpathSync(root)); + }); + + it('listRecent surfaces the registry recency view (derived, no separate store)', async () => { + const created = await service.createOrTouch(root); + const recent = await service.listRecent(); + expect(recent.length).toBeLessThanOrEqual(RECENT_ROOTS_LIMIT); + expect(recent.map((w) => w.id)).toContain(created.id); + }); + + it('delete removes the workspace', async () => { + const created = await service.createOrTouch(root); + await service.delete(created.id); + await expect(service.get(created.id)).rejects.toThrow(WorkspaceNotFoundError); + }); +}); diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 89657684a..77658e002 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -9,7 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; import { trimTrailingOpenToolExchange } from '../../src/agent/context/projector'; -import { ProviderManager } from '../../src/session/provider-manager'; +import { ProviderService, type IProviderService } from '../../src/session/provider-manager'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; @@ -612,8 +612,8 @@ async function makeTempDir(): Promise { return dir; } -function testProviderManager(): ProviderManager { - return new ProviderManager({ +function testProviderManager() : IProviderService { + return new ProviderService({ config: { providers: { test: { diff --git a/packages/agent-core/test/session/session-host.test.ts b/packages/agent-core/test/session/session-host.test.ts new file mode 100644 index 000000000..d3d8c99ff --- /dev/null +++ b/packages/agent-core/test/session/session-host.test.ts @@ -0,0 +1,284 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { SDKSessionRPC } from '../../src/rpc'; +import { Session } from '../../src/session'; +import { testKaos } from '../fixtures/test-kaos'; + +const tempDirs: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 }); + } +}); + +describe('SessionHost', () => { + it('createMain registers the main agent in the host registry', async () => { + const { sessionDir, workDir } = await sessionFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-create-main', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + + const main = await session.createMain(); + + expect(main.type).toBe('main'); + expect(session.host.agents.has('main')).toBe(true); + expect(session.host.getReadyAgent('main')).toBe(main); + // The Session.agents view delegates to the same host registry. + expect(session.agents.has('main')).toBe(true); + await session.close(); + }); + + it('createAgent registers a subagent in the host map and metadata', async () => { + const { sessionDir, workDir } = await sessionFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-create-agent', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + await session.createMain(); + + const { id, agent } = await session.createAgent( + { type: 'sub' }, + { parentAgentId: 'main' }, + ); + + expect(agent.type).toBe('sub'); + expect(session.host.agents.has(id)).toBe(true); + expect(session.host.getReadyAgent(id)).toBe(agent); + expect(session.metadata.agents[id]?.parentAgentId).toBe('main'); + await session.close(); + }); + + it('close disposes every registered agent', async () => { + const { sessionDir, workDir } = await sessionFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-close', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + const main = await session.createMain(); + const { agent: child } = await session.createAgent( + { type: 'sub' }, + { parentAgentId: 'main' }, + ); + const mainDispose = vi.spyOn(main, 'dispose'); + const childDispose = vi.spyOn(child, 'dispose'); + + await session.close(); + + expect(mainDispose).toHaveBeenCalledOnce(); + expect(childDispose).toHaveBeenCalledOnce(); + }); + + it('resume restores the persisted main agent into the host registry', async () => { + const { sessionDir, workDir } = await sessionFixture(); + const first = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-resume-first', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + await first.createMain(); + await first.close(); + + const resumed = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-resume-second', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + await resumed.resume(); + + expect(resumed.host.agents.has('main')).toBe(true); + const main = resumed.host.getReadyAgent('main'); + expect(main).toBeDefined(); + expect(main?.type).toBe('main'); + await resumed.close(); + }); + + it('fires session lifecycle hooks through the host on startup and close', async () => { + const { command, logPath, sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-hooks', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + hooks: [ + { event: 'SessionStart', matcher: 'startup', command, timeout: 5 }, + { event: 'SessionEnd', matcher: 'exit', command, timeout: 5 }, + ], + }); + + await session.createMain(); + await session.close(); + + expect(await readHookPayloads(logPath)).toMatchObject([ + { + hook_event_name: 'SessionStart', + session_id: 'host-hooks', + source: 'startup', + }, + { + hook_event_name: 'SessionEnd', + session_id: 'host-hooks', + reason: 'exit', + }, + ]); + }); + + it('fires session-scoped lifecycle hooks in willStart → didStart → willClose → didClose order', async () => { + const { sessionDir, workDir } = await sessionFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-hook-order', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + + const order: string[] = []; + const disposables = [ + session.lifecycle.onSessionWillStart(() => { + order.push('willStart'); + }), + session.lifecycle.onSessionDidStart(() => { + order.push('didStart'); + }), + session.lifecycle.onSessionWillClose(() => { + order.push('willClose'); + }), + session.lifecycle.onSessionDidClose(() => { + order.push('didClose'); + }), + ]; + + await session.createMain(); + await session.close(); + + expect(order).toEqual(['willStart', 'didStart', 'willClose', 'didClose']); + for (const d of disposables) d.dispose(); + }); + + it('fires session-scoped lifecycle hooks in order during resume', async () => { + const { sessionDir, workDir } = await sessionFixture(); + const first = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-hook-order-resume-first', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + await first.createMain(); + await first.close(); + + const resumed = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'host-hook-order-resume-second', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + + const order: string[] = []; + const disposables = [ + resumed.lifecycle.onSessionWillStart(() => { + order.push('willStart'); + }), + resumed.lifecycle.onSessionDidStart(() => { + order.push('didStart'); + }), + resumed.lifecycle.onSessionWillClose(() => { + order.push('willClose'); + }), + resumed.lifecycle.onSessionDidClose(() => { + order.push('didClose'); + }), + ]; + + await resumed.resume(); + await resumed.close(); + + expect(order).toEqual(['willStart', 'didStart', 'willClose', 'didClose']); + for (const d of disposables) d.dispose(); + }); +}); + +async function sessionFixture(): Promise<{ + readonly sessionDir: string; + readonly workDir: string; +}> { + const dir = await mkdtemp(join(tmpdir(), 'kimi-session-host-')); + tempDirs.push(dir); + const workDir = join(dir, 'work'); + const sessionDir = join(dir, 'session'); + await mkdir(join(workDir, '.git'), { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + return { sessionDir, workDir }; +} + +async function hookFixture(): Promise<{ + readonly command: string; + readonly logPath: string; + readonly sessionDir: string; + readonly workDir: string; +}> { + const { sessionDir, workDir } = await sessionFixture(); + const logPath = join(sessionDir, '..', 'hooks.jsonl'); + const scriptPath = join(sessionDir, '..', 'record-hook.cjs'); + await writeFile( + scriptPath, + [ + "const { appendFileSync } = require('node:fs');", + "let input = '';", + "process.stdin.on('data', (chunk) => { input += chunk; });", + "process.stdin.on('end', () => { appendFileSync(process.argv[2], `${input.trim()}\\n`); });", + '', + ].join('\n'), + 'utf-8', + ); + return { + command: `node ${JSON.stringify(scriptPath)} ${JSON.stringify(logPath)}`, + logPath, + sessionDir, + workDir, + }; +} + +async function readHookPayloads(path: string): Promise[]> { + const text = await readFile(path, 'utf-8'); + return text + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +function createSessionRpc(overrides: Partial = {}): SDKSessionRPC { + return { + emitEvent: vi.fn(async () => {}), + requestApproval: vi.fn(async () => ({ decision: 'cancelled' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ + output: 'custom tools are not supported in this test', + isError: true, + })), + ...overrides, + } as SDKSessionRPC; +} diff --git a/packages/agent-core/test/session/sessionRepository.test.ts b/packages/agent-core/test/session/sessionRepository.test.ts new file mode 100644 index 000000000..8ef002e84 --- /dev/null +++ b/packages/agent-core/test/session/sessionRepository.test.ts @@ -0,0 +1,124 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { LocalKaos, type Kaos } from '@moonshot-ai/kaos'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { SessionMeta } from '../../src/session'; +import { SessionRepository } from '../../src/session/sessionRepository'; + +let rootDir: string; +let homedir: string; +let kaos: Kaos; + +function makeMeta(overrides: Partial = {}): SessionMeta { + return { + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + title: 'New Session', + isCustomTitle: false, + agents: {}, + custom: {}, + ...overrides, + }; +} + +function statePath(): string { + return join(homedir, 'state.json'); +} + +beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), 'kimi-session-repo-')); + homedir = join(rootDir, 'session-home'); + kaos = await LocalKaos.create(); +}); + +afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); +}); + +describe('SessionRepository', () => { + it('create: write then read returns the same SessionMeta', async () => { + const repo = new SessionRepository(homedir, kaos); + const meta = makeMeta({ title: 'hello' }); + await repo.write(meta); + + const read = await repo.read(); + expect(read).toEqual(meta); + }); + + it('get: read on a missing state.json throws (matches Session.readMetadata)', async () => { + // `Session.readMetadata` calls `persistenceKaos.readText` directly, which + // rejects when the file is absent; the repository preserves that behavior. + const repo = new SessionRepository(homedir, kaos); + await expect(repo.read()).rejects.toThrow(); + }); + + it('update: two sequential writes; read returns the latest', async () => { + const repo = new SessionRepository(homedir, kaos); + await repo.write(makeMeta({ title: 'first' })); + await repo.write(makeMeta({ title: 'second' })); + + const read = await repo.read(); + expect(read.title).toBe('second'); + }); + + it('write creates the homedir when it does not exist yet', async () => { + const nested = join(homedir, 'nested', 'dir'); + const repo = new SessionRepository(nested, kaos); + const meta = makeMeta({ title: 'nested' }); + await repo.write(meta); + + const onDisk = JSON.parse(readFileSync(join(nested, 'state.json'), 'utf8')) as SessionMeta; + expect(onDisk).toEqual(meta); + }); + + it('flush resolves only after pending writes land on disk', async () => { + const repo = new SessionRepository(homedir, kaos); + const meta = makeMeta({ title: 'flushed' }); + // Intentionally do NOT await the write; flush must still wait for it. + void repo.write(meta); + + await repo.flush(); + + const onDisk = JSON.parse(readFileSync(statePath(), 'utf8')) as SessionMeta; + expect(onDisk).toEqual(meta); + }); + + it('serializes concurrent writes: final state.json equals the last submitted meta', async () => { + const repo = new SessionRepository(homedir, kaos); + const count = 8; + const metas = Array.from({ length: count }, (_, i) => + makeMeta({ title: `write-${i}`, updatedAt: `2026-01-0${i + 1}T00:00:00.000Z` }), + ); + + // Fire every write without awaiting; the repository must order them by + // submission order and drop none. + await Promise.all(metas.map((meta) => repo.write(meta))); + await repo.flush(); + + const last = metas[count - 1]!; + const onDisk = readFileSync(statePath(), 'utf8'); + expect(onDisk).toBe(JSON.stringify(last, null, 2)); + expect(JSON.parse(onDisk)).toEqual(last); + }); + + it('keeps the write chain alive after a rejected write', async () => { + // The original `Session.writeMetadata` chains via `.then(write, write)` so a + // failed write does not poison subsequent writes on the SAME repository. Pin + // that behavior: make the homedir path a file so `mkdir` rejects, then clear + // it and confirm a later write on the same instance still runs. + const blocked = join(rootDir, 'blocked-home'); + writeFileSync(blocked, 'x'); + const repo = new SessionRepository(blocked, kaos); + + await expect(repo.write(makeMeta({ title: 'first' }))).rejects.toThrow(); + + rmSync(blocked); + await repo.write(makeMeta({ title: 'second' })); + await repo.flush(); + + await expect(repo.read()).resolves.toMatchObject({ title: 'second' }); + }); +}); diff --git a/packages/agent-core/test/session/subagent-batch.test.ts b/packages/agent-core/test/session/subagent-batch.test.ts index ac9cbab25..6bdef17fe 100644 --- a/packages/agent-core/test/session/subagent-batch.test.ts +++ b/packages/agent-core/test/session/subagent-batch.test.ts @@ -14,7 +14,7 @@ import { type SubagentResult, type SubagentSuspendedEvent, } from '../../src/session/subagent-batch'; -import { userCancellationReason } from '../../src/utils/abort'; +import { userCancellationReason } from '#/_utils/abort'; const signal = new AbortController().signal; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 5354e4756..da70de6de 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -16,7 +16,7 @@ import { SessionSubagentHost, type QueuedSubagentTask, } from '../../src/session/subagent-host'; -import { abortError, userCancellationReason } from '../../src/utils/abort'; +import { abortError, userCancellationReason } from '#/_utils/abort'; import { testAgent, type AgentTestContext } from '../agent/harness/agent'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { executeTool } from '../tools/fixtures/execute-tool'; @@ -244,7 +244,7 @@ describe('SessionSubagentHost', () => { const child = testAgent({ type: 'sub', - permission: { parent: parent.agent.permission }, + permission: { parent: parent.agent.permission.unwrap() }, }); child.mockNextResponse({ type: 'text', text: 'Investigated the request and completed the child task end to end. The relevant module was located, its behavior traced through every call site, and the requested change applied and verified against the existing test suite.' }); const session = fakeSession(parent.agent, child.agent); @@ -826,7 +826,7 @@ describe('SessionSubagentHost', () => { const child = testAgent({ type: 'sub', - permission: { parent: parent.agent.permission }, + permission: { parent: parent.agent.permission.unwrap() }, }); child.configure({ tools: ['Read'] }); child.agent.useProfile( diff --git a/packages/agent-core/test/skill/builtin-custom-theme.test.ts b/packages/agent-core/test/skill/builtin-custom-theme.test.ts index a0e88248e..b2684374b 100644 --- a/packages/agent-core/test/skill/builtin-custom-theme.test.ts +++ b/packages/agent-core/test/skill/builtin-custom-theme.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { CUSTOM_THEME_SKILL, SessionSkillRegistry, registerBuiltinSkills } from '../../src/skill'; +import { CUSTOM_THEME_SKILL, SkillRegistryService, registerBuiltinSkills } from '../../src/skill'; describe('builtin skill: custom-theme', () => { it('has the expected identity and inline metadata', () => { @@ -46,7 +46,7 @@ describe('builtin skill: custom-theme', () => { }); it('registers through registerBuiltinSkills but stays out of the model skill listing', () => { - const registry = new SessionSkillRegistry(); + const registry = new SkillRegistryService(); registerBuiltinSkills(registry); expect(registry.getSkill('custom-theme')).toBeDefined(); diff --git a/packages/agent-core/test/skill/builtin-sub-skill.test.ts b/packages/agent-core/test/skill/builtin-sub-skill.test.ts index 46c8fc92a..ffb4f2379 100644 --- a/packages/agent-core/test/skill/builtin-sub-skill.test.ts +++ b/packages/agent-core/test/skill/builtin-sub-skill.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - SessionSkillRegistry, + SkillRegistryService, SUB_SKILL_CONSOLIDATE, SUB_SKILL_PARENT, SUB_SKILL_REVIEW, @@ -22,7 +22,7 @@ describe('builtin skill: sub-skill', () => { }); it('registers through registerBuiltinSkills but stays out of the model skill listing', () => { - const registry = new SessionSkillRegistry(); + const registry = new SkillRegistryService(); registerBuiltinSkills(registry); expect(registry.getSkill('sub-skill')).toBeDefined(); @@ -32,14 +32,14 @@ describe('builtin skill: sub-skill', () => { }); it('remains visible in the full skill list for CLI display', () => { - const registry = new SessionSkillRegistry(); + const registry = new SkillRegistryService(); registerBuiltinSkills(registry); expect(registry.listSkills().some((skill) => skill.name === 'sub-skill')).toBe(true); }); it('registers every sub-skill builtin', () => { - const registry = new SessionSkillRegistry(); + const registry = new SkillRegistryService(); registerBuiltinSkills(registry); expect(registry.getSkill('sub-skill')).toBeDefined(); @@ -62,7 +62,7 @@ describe('builtin skill: sub-skill.review', () => { }); it('registers through registerBuiltinSkills', () => { - const registry = new SessionSkillRegistry(); + const registry = new SkillRegistryService(); registerBuiltinSkills(registry); expect(registry.getSkill('sub-skill.review')).toBeDefined(); @@ -96,7 +96,7 @@ describe('builtin skill: sub-skill.consolidate', () => { }); it('registers through registerBuiltinSkills', () => { - const registry = new SessionSkillRegistry(); + const registry = new SkillRegistryService(); registerBuiltinSkills(registry); expect(registry.getSkill('sub-skill.consolidate')).toBeDefined(); diff --git a/packages/agent-core/test/skill/builtin-update-config.test.ts b/packages/agent-core/test/skill/builtin-update-config.test.ts index 2b8198313..84ba7ed86 100644 --- a/packages/agent-core/test/skill/builtin-update-config.test.ts +++ b/packages/agent-core/test/skill/builtin-update-config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { SessionSkillRegistry, UPDATE_CONFIG_SKILL, registerBuiltinSkills } from '../../src/skill'; +import { SkillRegistryService, UPDATE_CONFIG_SKILL, registerBuiltinSkills } from '../../src/skill'; describe('builtin skill: update-config', () => { it('has the expected identity and inline metadata', () => { @@ -23,7 +23,7 @@ describe('builtin skill: update-config', () => { }); it('registers through registerBuiltinSkills and shows up as model-invocable', () => { - const registry = new SessionSkillRegistry(); + const registry = new SkillRegistryService(); registerBuiltinSkills(registry); expect(registry.getSkill('update-config')).toBeDefined(); diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index de8f11865..10b604385 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { ToolAccesses } from '../../src/loop'; -import type { Logger, LogPayload } from '../../src/logging'; +import type { Logger, LogPayload } from '#/_base/logging'; import type { ResolvedAgentProfile } from '../../src/profile'; -import type { SessionSubagentHost } from '../../src/session/subagent-host'; +import type { ISubagentHostService } from '../../src/session/subagent-host'; import { AgentBackgroundTask } from '../../src/agent/background'; import { AgentTool, AgentToolInputSchema } from '../../src/tools/builtin/collaboration/agent'; -import { userCancellationReason } from '../../src/utils/abort'; +import { userCancellationReason } from '#/_utils/abort'; import { createBackgroundManager } from '../agent/background/helpers'; import { executeTool } from './fixtures/execute-tool'; @@ -16,10 +16,10 @@ function context(args: Input, toolCallId = 'call_agent') { return { turnId: '0', toolCallId, args, signal }; } -function mockSubagentHost & Partial>( +function mockSubagentHost & Partial>( host: T, -): T & SessionSubagentHost { - return { resume: vi.fn(), ...host } as unknown as T & SessionSubagentHost; +): T & ISubagentHostService { + return { resume: vi.fn(), ...host } as unknown as T & ISubagentHostService; } interface CapturedLogEntry { diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 3dcdbf2f5..9a5fc90a4 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -17,7 +17,7 @@ import { DEFAULT_SUBAGENT_TIMEOUT_MS, type QueuedSubagentRunResult, type QueuedSubagentTask, - type SessionSubagentHost, + type ISubagentHostService, } from '../../src/session/subagent-host'; import { SessionSkillRegistry } from '../../src/skill'; import { TaskListInputSchema } from '../../src/tools/background/task-list'; @@ -67,16 +67,16 @@ function context(args: Input, toolCallId = 'call_1') { return { turnId: '0', toolCallId, args, signal }; } -function mockSubagentHost>( +function mockSubagentHost>( host: T, -): T & SessionSubagentHost { +): T & ISubagentHostService { return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), getSwarmItem: vi.fn(), ...host, - } as unknown as T & SessionSubagentHost; + } as unknown as T & ISubagentHostService; } function mockSwarmMode(): SwarmMode { @@ -515,7 +515,7 @@ describe('current builtin collaboration tools', () => { }; const host = mockSubagentHost({ getSwarmItem: vi.fn((agentId: string) => persistedItems[agentId]), - runQueued: runQueued as unknown as SessionSubagentHost['runQueued'], + runQueued: runQueued as unknown as ISubagentHostService['runQueued'], }); const swarmMode = mockSwarmMode(); const tool = new AgentSwarmTool(host, swarmMode); @@ -642,7 +642,7 @@ describe('current builtin collaboration tools', () => { getSwarmItem: vi.fn((agentId: string) => agentId === 'agent-old-1' ? 'src/old-a.ts' : undefined, ), - runQueued: runQueued as unknown as SessionSubagentHost['runQueued'], + runQueued: runQueued as unknown as ISubagentHostService['runQueued'], }); const swarmMode = mockSwarmMode(); const tool = new AgentSwarmTool(host, swarmMode); diff --git a/packages/agent-core/test/tools/goal.test.ts b/packages/agent-core/test/tools/goal.test.ts index 2568be66c..483e25e26 100644 --- a/packages/agent-core/test/tools/goal.test.ts +++ b/packages/agent-core/test/tools/goal.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Agent } from '../../src/agent'; -import { GoalMode } from '../../src/agent/goal'; +import { GoalService, type IGoalService } from '../../src/agent/goal'; import { ErrorCodes } from '../../src/errors'; import { compileToolArgsValidator, validateToolArgs } from '../../src/tools/args-validator'; import { @@ -22,7 +22,7 @@ function makeStore() { return fakeAgent().goal; } -function fakeAgent(opts: { type?: 'main' | 'sub'; goal?: GoalMode } = {}): Agent { +function fakeAgent(opts: { type?: 'main' | 'sub'; goal?: IGoalService } = {}): Agent { const agent = { type: opts.type ?? 'main', records: { logRecord: () => {} }, @@ -30,7 +30,7 @@ function fakeAgent(opts: { type?: 'main' | 'sub'; goal?: GoalMode } = {}): Agent telemetry: { track: () => {} }, context: { appendSystemReminder: () => {} }, } as unknown as Agent; - (agent as { goal: GoalMode }).goal = opts.goal ?? new GoalMode(agent); + (agent as { goal: IGoalService }).goal = opts.goal ?? new GoalService(agent.telemetry, (e) => agent.emitEvent(e), agent.records, agent.replayBuilder, agent.context); return agent; } @@ -209,7 +209,7 @@ describe('UpdateGoalTool', () => { // Terminal paths append follow-up reminders, so the agent needs a context // exposing appendSystemReminder. function agentWithContext( - store: GoalMode, + store: IGoalService, reminders: Array<{ readonly content: string; readonly origin: unknown }> = [], ): Agent { return { diff --git a/packages/agent-core/test/tools/plan-mode-hard-block.test.ts b/packages/agent-core/test/tools/plan-mode-hard-block.test.ts index c01060074..8607a222c 100644 --- a/packages/agent-core/test/tools/plan-mode-hard-block.test.ts +++ b/packages/agent-core/test/tools/plan-mode-hard-block.test.ts @@ -18,13 +18,14 @@ async function activePlanAgent(): Promise<{ agent: Agent; planMode: PlanMode }> const agent = { homedir: '/tmp/kimi-plan-test', emitStatusUpdated: vi.fn(), + statusService: { notifyStatusChanged: vi.fn() }, records: { logRecord: vi.fn() }, replayBuilder: { push: vi.fn() }, kaos: { mkdir: vi.fn().mockResolvedValue(undefined), }, } as unknown as Agent; - const planMode = new PlanMode(agent); + const planMode = new PlanMode(agent.kaos, agent.homedir, agent.statusService, agent.records, agent.replayBuilder, agent.config); Object.assign(agent, { planMode }); await planMode.enter('current-plan', false); return { agent, planMode }; diff --git a/packages/agent-core/test/utils/abort.test.ts b/packages/agent-core/test/utils/abort.test.ts index 56f7881bc..4a3e82dec 100644 --- a/packages/agent-core/test/utils/abort.test.ts +++ b/packages/agent-core/test/utils/abort.test.ts @@ -6,7 +6,7 @@ import { abortable, isUserCancellation, userCancellationReason, -} from '../../src/utils/abort'; +} from '#/_utils/abort'; describe('userCancellationReason', () => { it('is recognised as a deliberate user cancellation', () => { diff --git a/packages/agent-core/test/utils/hero-slug.test.ts b/packages/agent-core/test/utils/hero-slug.test.ts index ab63cbb05..8c2387d8c 100644 --- a/packages/agent-core/test/utils/hero-slug.test.ts +++ b/packages/agent-core/test/utils/hero-slug.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { HERO_NAMES, generateHeroSlug } from '../../src/utils/hero-slug'; +import { HERO_NAMES, generateHeroSlug } from '#/_utils/slug'; describe('generateHeroSlug', () => { it('returns a slug made of exactly 3 hero names joined by "-"', () => { diff --git a/packages/agent-core/test/utils/per-id-json-store.test.ts b/packages/agent-core/test/utils/per-id-json-store.test.ts index a3ba47249..428119976 100644 --- a/packages/agent-core/test/utils/per-id-json-store.test.ts +++ b/packages/agent-core/test/utils/per-id-json-store.test.ts @@ -12,7 +12,7 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createPerIdJsonStore } from '../../src/utils/per-id-json-store'; +import { createPerIdJsonStore } from '#/_utils/persistence'; interface Sample { readonly id: string; diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index 91b8bce92..a8a8a7d0a 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -9,7 +9,7 @@ import { reconcileChildNoProxy, resolveNoProxy, resolveSocksProxy, -} from '../../src/utils/proxy'; +} from '#/_utils/net'; describe('isProxyConfigured', () => { it('is false when no proxy variable is set', () => { diff --git a/packages/node-sdk/test/__snapshots__/api-surface.snapshot.test.ts.snap b/packages/node-sdk/test/__snapshots__/api-surface.snapshot.test.ts.snap new file mode 100644 index 000000000..bebee8f6c --- /dev/null +++ b/packages/node-sdk/test/__snapshots__/api-surface.snapshot.test.ts.snap @@ -0,0 +1,203 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`node-sdk API surface snapshot > matches the public export snapshot 1`] = ` +[ + "AgentBackgroundTaskInfo", + "AgentReplayRecord", + "AgentStatusUpdatedEvent", + "ApplyCatalogProviderOptions", + "ApprovalDecision", + "ApprovalHandler", + "ApprovalRequest", + "ApprovalResponse", + "ApprovalScope", + "AssistantDeltaEvent", + "BackgroundConfig", + "BackgroundTaskInfo", + "BackgroundTaskStartedEvent", + "BackgroundTaskStatus", + "BackgroundTaskTerminatedEvent", + "Catalog", + "CatalogFetchError", + "CatalogModel", + "CatalogProviderEntry", + "CompactOptions", + "CompactionBlockedEvent", + "CompactionCancelledEvent", + "CompactionCompletedEvent", + "CompactionResult", + "CompactionStartedEvent", + "ConfigDiagnostics", + "ContentPart", + "ContextMessage", + "CreateGoalInput", + "CreateSessionOptions", + "CronFiredEvent", + "DEFAULT_CATALOG_URL", + "ErrorCodes", + "ErrorEvent", + "Event", + "ExperimentalFeatureState", + "ExperimentalFlagMap", + "ExperimentalFlagSource", + "ExportSessionInput", + "ExportSessionManifest", + "ExportSessionResult", + "FlagDefinition", + "FlagDefinitionInput", + "FlagId", + "FlagSurface", + "ForkSessionInput", + "GetConfigOptions", + "GoalBudgetLimits", + "GoalBudgetReport", + "GoalChange", + "GoalChangeStats", + "GoalSnapshot", + "GoalStatus", + "GoalToolResult", + "GoalUpdatedEvent", + "HookResultEvent", + "JsonObject", + "JsonPrimitive", + "JsonValue", + "KIMI_ERROR_INFO", + "KimiAuthFacade", + "KimiAuthLoginResult", + "KimiAuthLogoutResult", + "KimiAuthSubmitFeedbackInput", + "KimiConfig", + "KimiConfigPatch", + "KimiConfigRpc", + "KimiConfigRpcClient", + "KimiConfigValidationIssue", + "KimiConfigValidationPathSegment", + "KimiError", + "KimiErrorCode", + "KimiErrorInfo", + "KimiErrorOptions", + "KimiErrorPayload", + "KimiForCodingProvider", + "KimiForCodingProviderOptions", + "KimiHarness", + "KimiHarnessOptions", + "KimiHarnessRuntimeOptions", + "KimiHostIdentity", + "ListSessionsOptions", + "LogContext", + "LogLevel", + "LogPayload", + "Logger", + "LoopControl", + "MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE", + "MaybePromise", + "McpOAuthAuthorizationUrlUpdateData", + "McpServerInfo", + "McpServerStatusEvent", + "McpServerStatusPayload", + "McpStartupMetrics", + "ModelAlias", + "MoonshotServiceConfig", + "OAuthRef", + "OAuthRefreshOutcome", + "PermissionMode", + "PlanInfo", + "PluginGithubMetadata", + "PluginGithubRef", + "PluginInfo", + "PluginMcpServerInfo", + "PluginSource", + "PluginSummary", + "ProcessBackgroundTaskInfo", + "PromptInput", + "PromptOrigin", + "PromptPart", + "ProviderConfig", + "ProviderType", + "QuestionAnswerMethod", + "QuestionAnswers", + "QuestionBackgroundTaskInfo", + "QuestionHandler", + "QuestionItem", + "QuestionOption", + "QuestionRequest", + "QuestionResponse", + "QuestionResult", + "ReloadSummary", + "RenameSessionInput", + "ResolveKimiConfigPathInput", + "ResumeSessionInput", + "ResumedAgentState", + "ResumedSessionState", + "ResumedSessionSummary", + "Role", + "SDKRpcClient", + "SDKRpcClientBase", + "SDKRpcClientOptions", + "ServicesConfig", + "Session", + "SessionMetaUpdatedEvent", + "SessionPlan", + "SessionStatus", + "SessionSummary", + "SessionUsage", + "ShellEnvironment", + "SkillActivatedEvent", + "SkillSummary", + "SubagentCompletedEvent", + "SubagentFailedEvent", + "SubagentSpawnedEvent", + "SubagentStartedEvent", + "SubagentSuspendedEvent", + "TelemetryClient", + "TelemetryContextPatch", + "TelemetryProperties", + "TextPromptPart", + "ThinkingConfig", + "ThinkingDeltaEvent", + "TokenUsage", + "ToolCall", + "ToolCallDeltaEvent", + "ToolCallRequest", + "ToolCallResponse", + "ToolCallStartedEvent", + "ToolInfo", + "ToolInputDisplay", + "ToolListUpdatedEvent", + "ToolListUpdatedReason", + "ToolProgressEvent", + "ToolResultEvent", + "ToolUpdate", + "TurnEndReason", + "TurnEndedEvent", + "TurnStartedEvent", + "TurnStepCompletedEvent", + "TurnStepInterruptedEvent", + "TurnStepRetryingEvent", + "TurnStepStartedEvent", + "Unsubscribe", + "UsageStatus", + "ValidateKimiConfigTomlInput", + "WarningEvent", + "applyCatalogProvider", + "catalogBaseUrl", + "catalogModelToAlias", + "catalogProviderModels", + "createKimiConfigRpc", + "createKimiHarness", + "fetchCatalog", + "flushDiagnosticLogs", + "fromKimiErrorPayload", + "inferWireType", + "installGlobalProxyDispatcher", + "isKimiError", + "loadBuiltInCatalog", + "loadRuntimeConfigSafe", + "log", + "redact", + "resolveConfigPath", + "resolveGlobalLogPath", + "resolveKimiHome", + "toKimiErrorPayload", +] +`; diff --git a/packages/node-sdk/test/api-surface.snapshot.test.ts b/packages/node-sdk/test/api-surface.snapshot.test.ts new file mode 100644 index 000000000..fa0238b8e --- /dev/null +++ b/packages/node-sdk/test/api-surface.snapshot.test.ts @@ -0,0 +1,154 @@ +/** + * Public API surface snapshot for `@moonshot-ai/kimi-code-sdk`. + * + * The package's only public entry is `src/index.ts` (see the `exports` map in + * `package.json`). This test statically enumerates every name exported from that + * entry — value exports, `export type`/`export interface` declarations, inline + * `type` specifiers, and `export *` / `export type *` re-exports (resolved one + * level into the internal `#/...` modules) — then sorts them and compares + * against the committed snapshot. + * + * Static parsing is used instead of `Object.keys(await import(...))` so that + * type-only exports (which are erased at runtime) are captured too; the result + * is the complete public surface, not just the runtime value exports. Any export + * add/remove/rename fails this test until the snapshot is updated. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const PACKAGE_ROOT = resolve(import.meta.dirname, '..'); +const SRC_ROOT = join(PACKAGE_ROOT, 'src'); + +function readSource(absPath: string): string { + return readFileSync(absPath, 'utf8'); +} + +/** Strip line + block comments so `export` inside comments is ignored. */ +function stripComments(src: string): string { + return src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +/** Parse one `foo`, `type foo`, or `foo as bar` specifier into its exported name. */ +function exportedName(specifier: string): string | null { + let s = specifier.trim(); + if (s.length === 0) { + return null; + } + // Strip a leading `type` modifier (inline type-only re-export). + s = s.replace(/^type\s+/, ''); + // `local as exported` -> the public name is `exported`. + const asMatch = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(s); + if (asMatch) { + return asMatch[1] ?? null; + } + const nameMatch = /^([A-Za-z_$][\w$]*)/.exec(s); + return nameMatch ? (nameMatch[1] ?? null) : null; +} + +/** + * Resolve an internal module specifier to an absolute `.ts` file. + * Handles `#/...` via the package `imports` map pattern + * (`./src/.ts` then `./src//index.ts`) and relative specifiers. + * Returns null for external (bare) specifiers, which we do not expand. + */ +function resolveModule(specifier: string, fromFile: string): string | null { + if (specifier.startsWith('#/')) { + const sub = specifier.slice(2); + const candidates = [join(SRC_ROOT, `${sub}.ts`), join(SRC_ROOT, sub, 'index.ts')]; + for (const candidate of candidates) { + try { + readSource(candidate); + return candidate; + } catch { + // try next candidate + } + } + return null; + } + if (specifier.startsWith('.')) { + const base = resolve(dirname(fromFile), specifier); + const candidates = [base, `${base}.ts`, join(base, 'index.ts')]; + for (const candidate of candidates) { + try { + readSource(candidate); + return candidate; + } catch { + // try next candidate + } + } + return null; + } + return null; +} + +/** + * Collect the names exported by a single file, recursively expanding any + * `export *` / `export type *` re-exports from internal modules. + */ +function collectExports(absPath: string, seen: Set, out: Set): void { + if (seen.has(absPath)) { + return; + } + seen.add(absPath); + + const src = stripComments(readSource(absPath)); + + // 1. Named (re-)exports: `export { a, type b, c as d } [from 'x']` + // and `export type { a, b } from 'x'`. Specifier blocks may span lines. + const namedRe = /export\s+(?:type\s+)?\{([\s\S]*?)\}/g; + let match: RegExpExecArray | null; + while ((match = namedRe.exec(src)) !== null) { + const block = match[1]; + if (block === undefined) { + continue; + } + for (const raw of block.split(',')) { + const name = exportedName(raw); + if (name !== null) { + out.add(name); + } + } + } + + // 2. Named declarations: `export type|interface|class|function|const|let|var|enum Foo` + const declRe = + /export\s+(?:declare\s+)?(?:type|interface|class|function|const|let|var|enum)\s+([A-Za-z_$][\w$]*)/g; + while ((match = declRe.exec(src)) !== null) { + const name = match[1]; + if (name !== undefined) { + out.add(name); + } + } + + // 3. Star re-exports: `export [type] * from 'x'` (internal modules only). + const starRe = /export\s+(?:type\s+)?\*\s+from\s+['"]([^'"]+)['"]/g; + while ((match = starRe.exec(src)) !== null) { + const specifier = match[1]; + if (specifier === undefined) { + continue; + } + const target = resolveModule(specifier, absPath); + if (target !== null) { + collectExports(target, seen, out); + } + } +} + +function collectPublicExports(): string[] { + const entry = join(SRC_ROOT, 'index.ts'); + const out = new Set(); + collectExports(entry, new Set(), out); + return [...out].sort(); +} + +describe('node-sdk API surface snapshot', () => { + it('matches the public export snapshot', () => { + const exports = collectPublicExports(); + expect(exports).toMatchSnapshot(); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8a043c88f..e393d9656 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -31,7 +31,7 @@ export { IEventService, IApprovalService, IQuestionService, - ICoreProcessService, + ICoreRuntime, ILogService, IModelCatalogService, ISessionService, diff --git a/packages/server/src/lib/sessionArchive.ts b/packages/server/src/lib/sessionArchive.ts new file mode 100644 index 000000000..c78466237 --- /dev/null +++ b/packages/server/src/lib/sessionArchive.ts @@ -0,0 +1,109 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { basename, isAbsolute, join, relative, resolve } from 'node:path'; + +import { SessionNotFoundError } from '@moonshot-ai/agent-core'; + +/** + * Temporary server-side session restore. + * + * TODO: remove once `@moonshot-ai/agent-core` exposes `ISessionService.restore` + * natively. At that point the `:restore` route should delegate to the service + * instead of rewriting `state.json` here. + * + * Archive is a boolean flag (`archived`) persisted in each session's + * `/state.json`. agent-core's `SessionStore` can set it to `true` + * (`archive`) but has no inverse; while agent-core is being refactored we flip + * it back from the server by: + * 1. reading `/session_index.jsonl` to resolve `sessionId -> sessionDir`; + * 2. validating the resolved dir is inside `/sessions` (defense + * against a tampered index); + * 3. read-modify-write `state.json` with `archived: false`. + * + * This mirrors `SessionStore.archive` and publishes no event (same as archive). + * The query read-model rebuilds from the store on every call, so a restored + * session shows up in subsequent lists with no extra invalidation. + */ + +interface SessionIndexEntry { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +export async function restoreArchivedSession(homeDir: string, sessionId: string): Promise { + const sessionDir = await findSessionDir(homeDir, sessionId); + if (sessionDir === undefined) { + throw new SessionNotFoundError(sessionId); + } + + const statePath = join(sessionDir, 'state.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(statePath, 'utf-8')) as unknown; + } catch { + throw new SessionNotFoundError(sessionId); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new SessionNotFoundError(sessionId); + } + + const next: Record = { + ...(parsed as Record), + archived: false, + updatedAt: new Date().toISOString(), + }; + await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); +} + +async function findSessionDir(homeDir: string, sessionId: string): Promise { + const indexPath = join(homeDir, 'session_index.jsonl'); + let raw: string; + try { + raw = await readFile(indexPath, 'utf-8'); + } catch { + return undefined; + } + + const sessionsDir = join(homeDir, 'sessions'); + let found: string | undefined; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseIndexLine(trimmed); + if (entry === undefined || entry.sessionId !== sessionId) continue; + const sessionDir = resolve(entry.sessionDir); + if (!isAbsolute(entry.sessionDir)) continue; + if (!isPathInside(sessionsDir, sessionDir)) continue; + if (basename(sessionDir) !== entry.sessionId) continue; + // Last valid line wins, matching `readSessionIndex`'s Map semantics. + found = sessionDir; + } + return found; +} + +function parseIndexLine(line: string): SessionIndexEntry | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null) return undefined; + const entry = parsed as Partial; + if ( + typeof entry.sessionId !== 'string' || + typeof entry.sessionDir !== 'string' || + typeof entry.workDir !== 'string' + ) { + return undefined; + } + return { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + }; + } catch { + return undefined; + } +} + +function isPathInside(parent: string, child: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 8ccabb0b3..1d6a60c79 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -21,11 +21,12 @@ import { undoSessionResponseSchema, workspaceIdSchema, } from '@moonshot-ai/protocol'; -import { IPromptService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IWorkspaceRegistry, WorkspaceNotFoundError, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; +import { IPromptService, ISessionService, ISessionQueryService, ISessionRuntimeService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IEnvironmentService, IWorkspaceRegistry, WorkspaceNotFoundError, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; +import { restoreArchivedSession } from '../lib/sessionArchive'; import { defineRoute } from '../middleware/defineRoute'; import { parseActionSuffix } from './action-suffix'; @@ -81,6 +82,7 @@ const sessionsListQueryCoercion = z page_size: z.coerce.number().int().min(1).max(100).optional(), status: sessionStatusSchema.optional(), include_archive: booleanQueryParam, + archived_only: booleanQueryParam, workspace_id: workspaceIdSchema.optional(), }) @@ -93,6 +95,14 @@ const sessionsListQueryCoercion = z params: { code: ErrorCode.VALIDATION_FAILED }, }); } + if (value.archived_only === true && value.include_archive === true) { + ctx.addIssue({ + code: 'custom', + message: 'archived_only and include_archive are mutually exclusive', + path: ['archived_only'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } }); const sessionChildrenListQueryCoercion = z @@ -264,12 +274,15 @@ export function registerSessionsRoutes( async (req, reply) => { try { const raw = req.query; + const archivedOnly = raw.archived_only === true; const baseQuery = { before_id: raw.before_id, after_id: raw.after_id, page_size: raw.page_size, status: raw.status, - includeArchive: raw.include_archive, + // archived_only needs the mixed set so we can post-filter to + // archived-only below (temporary server-side filter; see comment). + includeArchive: archivedOnly ? true : raw.include_archive, }; let query; if (raw.workspace_id !== undefined) { @@ -290,7 +303,25 @@ export function registerSessionsRoutes( } else { query = baseQuery; } - const page = await ix.invokeFunction((a) => a.get(ISessionService).list(query)); + const page = await ix.invokeFunction((a) => a.get(ISessionQueryService).list(query)); + if (archivedOnly) { + // Temporary server-side archived-only filter. The post-filter happens + // after pagination, so `has_more` still reflects the mixed set: a page + // may come back short (or empty) while `has_more` is true, and clients + // must keep paging. No data is lost. Remove once agent-core supports + // archived-only natively (SessionIndex already has the `'only'` + // visibility for this). + reply.send( + okEnvelope( + { + items: page.items.filter((session) => session.archived === true), + has_more: page.has_more, + }, + req.id, + ), + ); + return; + } reply.send(okEnvelope(page, req.id)); } catch (err) { sendMappedError(reply, req.id, err); @@ -409,7 +440,7 @@ export function registerSessionsRoutes( const { tail } = req.params; const parsed = parseActionSuffix({ tail, - allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive'] as const, + allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive', 'restore'] as const, resourceLabel: 'session', }); if (parsed.kind !== 'action') { @@ -467,6 +498,16 @@ export function registerSessionsRoutes( return; } + if (parsed.action === 'restore') { + const homeDir = ix.invokeFunction((a) => a.get(IEnvironmentService)).homeDir; + await restoreArchivedSession(homeDir, parsed.id); + const session = await ix.invokeFunction((a) => + a.get(ISessionService).get(parsed.id), + ); + reply.send(okEnvelope(session, req.id)); + return; + } + const body = undoSessionRequestSchema.parse(req.body); const result = await ix.invokeFunction((a) => a.get(ISessionService).undo(parsed.id, body), @@ -501,7 +542,7 @@ export function registerSessionsRoutes( try { const { session_id } = req.params; const page = await ix.invokeFunction((a) => - a.get(ISessionService).listChildren(session_id, req.query), + a.get(ISessionQueryService).listChildren(session_id, req.query), ); reply.send(okEnvelope(page, req.id)); } catch (error) { @@ -565,7 +606,7 @@ export function registerSessionsRoutes( try { const { session_id } = req.params; const status = await ix.invokeFunction((a) => - a.get(ISessionService).getStatus(session_id), + a.get(ISessionRuntimeService).getStatus(session_id), ); reply.send(okEnvelope(status, req.id)); } catch (err) { diff --git a/packages/server/src/services/serviceCollection.ts b/packages/server/src/services/serviceCollection.ts index 207229291..8bc6e5e86 100644 --- a/packages/server/src/services/serviceCollection.ts +++ b/packages/server/src/services/serviceCollection.ts @@ -53,7 +53,7 @@ export function createServerServiceCollection( new SyncDescriptor(WSGateway, [server.wsGatewayOptions ?? {}], false), ); services.set( - Services.ICoreProcessService, + Services.ICoreRuntime, new SyncDescriptor(Services.CoreProcessService, [server.coreProcessOptions ?? {}], false), ); diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index dcd11349d..94cb71f7c 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -1,4 +1,4 @@ -import { InstantiationService, resolveConfigPath, resolveKimiHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IOAuthService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@moonshot-ai/agent-core'; +import { InstantiationService, resolveConfigPath, resolveKimiHome, setUnexpectedErrorHandler, CoreProcessService, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreRuntime, IModelCatalogService, IMcpService, IMessageService, IOAuthService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@moonshot-ai/agent-core'; import { ErrorCode, createAsyncApiDocument } from '@moonshot-ai/protocol'; import Fastify from 'fastify'; import { promises as fspPromises } from 'node:fs'; @@ -137,8 +137,34 @@ export async function startServer(opts: ServerStartOptions): Promise { @@ -202,7 +228,7 @@ export async function startServer(opts: ServerStartOptions): Promise> "${logPath}" 2>&1"`; } export function buildScheduledTaskXml(input: BuildScheduledTaskXmlInput): string { const description = escapeXmlText(input.description); - const command = escapeXmlText(input.command); - const args = input.arguments ? escapeXmlText(input.arguments) : ''; + const redirect = input.redirectLogPath?.trim(); + const command = redirect ? escapeXmlText('cmd.exe') : escapeXmlText(input.command); + const rawArgs = redirect + ? buildCmdRedirectArgs(input.command, input.arguments, redirect) + : (input.arguments ?? ''); + const argumentsXml = rawArgs ? `\n ${escapeXmlText(rawArgs)}` : ''; const principalLogon = input.taskUser ? `\n ${escapeXmlText(input.taskUser)}\n InteractiveToken` @@ -29,7 +44,6 @@ export function buildScheduledTaskXml(input: BuildScheduledTaskXmlInput): string const triggerUser = input.taskUser ? `\n ${escapeXmlText(input.taskUser)}` : ''; - const argumentsXml = args ? `\n ${args}` : ''; return ` diff --git a/packages/server/src/svc/schtasks.ts b/packages/server/src/svc/schtasks.ts index d78499370..b256f7fae 100644 --- a/packages/server/src/svc/schtasks.ts +++ b/packages/server/src/svc/schtasks.ts @@ -39,7 +39,7 @@ export interface SchtasksManagerDeps { const DEFAULT_DEPS: SchtasksManagerDeps = { execSchtasks: (args, options) => execFileUtf8('schtasks', args, { windowsHide: true, ...options }), - resolveProgram: () => resolveSupervisorProgram(process.argv, process.cwd(), 'kimi.exe'), + resolveProgram: () => resolveSupervisorProgram(), logPath: defaultSupervisorLogPath, writeTaskXml: defaultWriteTaskXml, taskExists: defaultTaskExists, @@ -69,6 +69,7 @@ export function createSchtasksManager( description: 'Kimi Code local server (managed by `kimi server install`)', command: plan.program, ...(argString.length > 0 ? { arguments: argString } : {}), + redirectLogPath: logPath, }); const xmlPath = deps.writeTaskXml(xml); diff --git a/packages/server/src/svc/systemd-unit.ts b/packages/server/src/svc/systemd-unit.ts index 47543e356..e8bee3a4c 100644 --- a/packages/server/src/svc/systemd-unit.ts +++ b/packages/server/src/svc/systemd-unit.ts @@ -23,6 +23,8 @@ export interface BuildSystemdUnitInput { workingDirectory?: string; environment?: Readonly>; + + logPath?: string; } @@ -43,6 +45,16 @@ export function buildSystemdUnit(input: BuildSystemdUnitInput): string { }) : []; + const logLines = input.logPath + ? (() => { + assertNoLineBreaks(input.logPath, 'Systemd log path'); + return [ + `StandardOutput=append:${input.logPath}`, + `StandardError=append:${input.logPath}`, + ]; + })() + : []; + const lines = [ '[Unit]', `Description=${description}`, @@ -61,6 +73,7 @@ export function buildSystemdUnit(input: BuildSystemdUnitInput): string { 'KillMode=control-group', workingDirLine, ...envLines, + ...logLines, '', '[Install]', 'WantedBy=default.target', diff --git a/packages/server/src/svc/systemd.ts b/packages/server/src/svc/systemd.ts index a596a5399..5b1caee91 100644 --- a/packages/server/src/svc/systemd.ts +++ b/packages/server/src/svc/systemd.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { userInfo } from 'node:os'; import { dirname } from 'node:path'; import { execFileUtf8, type ExecOptions, type ExecResult } from './exec'; @@ -34,18 +35,24 @@ export interface SystemdManagerDeps { execSystemctl(args: readonly string[], options?: ExecOptions): Promise; + execLoginctl(args: readonly string[], options?: ExecOptions): Promise; + resolveProgram(): string; unitPath(): string; logPath(): string; + + userName(): string; } const DEFAULT_DEPS: SystemdManagerDeps = { execSystemctl: (args, options) => execFileUtf8('systemctl', ['--user', ...args], options), + execLoginctl: (args, options) => execFileUtf8('loginctl', args, options), resolveProgram: () => resolveSupervisorProgram(), unitPath: defaultSystemdUnitPath, logPath: defaultSupervisorLogPath, + userName: () => userInfo().username, }; export function createSystemdManager( @@ -87,9 +94,12 @@ export function createSystemdManager( ); } + const lingerNote = await enableLinger(deps); + const baseMessage = `Kimi server systemd unit ${alreadyInstalled ? 'replaced' : 'installed'} at ${unitPath} (port ${plan.port}).`; + return { status: alreadyInstalled ? 'replaced' : 'installed', - message: `Kimi server systemd unit ${alreadyInstalled ? 'replaced' : 'installed'} at ${unitPath} (port ${plan.port}).`, + message: lingerNote ? `${baseMessage} ${lingerNote}` : baseMessage, unitPath, }; } @@ -190,11 +200,15 @@ export function createSystemdManager( const subState = fields['SubState']; const mainPid = Number.parseInt(fields['MainPID'] ?? '', 10); const running = activeState === 'active' && subState !== 'failed'; + const lingerNote = await probeLingerNote(deps); return { ...base, running, ...(Number.isFinite(mainPid) && mainPid > 0 ? { pid: mainPid } : {}), - notes: [`systemd state: ${activeState ?? 'unknown'}/${subState ?? 'unknown'}`], + notes: [ + `systemd state: ${activeState ?? 'unknown'}/${subState ?? 'unknown'}`, + ...(lingerNote ? [lingerNote] : []), + ], }; } @@ -215,6 +229,7 @@ function writeUnit(unitPath: string, plan: InstallPlan): void { const text = buildSystemdUnit({ description: 'Kimi Code local server (managed by `kimi server install`)', programArguments: plan.programArguments, + logPath: plan.logPath, }); mkdirSync(dirname(unitPath), { recursive: true, mode: UNIT_DIR_MODE }); writeFileSync(unitPath, text, { mode: UNIT_MODE }); @@ -224,3 +239,28 @@ function detail(res: ExecResult): string | undefined { const text = (res.stderr || res.stdout).trim(); return text.length > 0 ? text : undefined; } + +async function readLinger(deps: SystemdManagerDeps): Promise<'yes' | 'no' | undefined> { + const probe = await deps.execLoginctl(['show-user', deps.userName(), '--property=Linger']); + if (probe.code !== 0) return undefined; + if (/\bLinger=yes\b/.test(probe.stdout)) return 'yes'; + if (/\bLinger=no\b/.test(probe.stdout)) return 'no'; + return undefined; +} + +async function enableLinger(deps: SystemdManagerDeps): Promise { + const user = deps.userName(); + if ((await readLinger(deps)) === 'yes') return undefined; + const enable = await deps.execLoginctl(['enable-linger', user]); + if (enable.code === 0) return undefined; + return `Note: could not enable linger automatically; run \`sudo loginctl enable-linger ${user}\` to keep the server running after logout.`; +} + +async function probeLingerNote(deps: SystemdManagerDeps): Promise { + const current = await readLinger(deps); + if (current === 'yes') return 'linger: enabled (survives logout / starts at boot)'; + if (current === 'no') { + return 'linger: disabled (stops on logout; run `sudo loginctl enable-linger $USER` to persist)'; + } + return undefined; +} diff --git a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap new file mode 100644 index 000000000..9e0539dfe --- /dev/null +++ b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap @@ -0,0 +1,1817 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`server API surface snapshot > matches the registered route surface snapshot 1`] = ` +[ + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/auth", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/auth", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/config", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/config", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "background", + "default_model", + "default_permission_mode", + "default_plan_mode", + "default_provider", + "default_thinking", + "experimental", + "extra_skill_dirs", + "hooks", + "loop_control", + "merge_all_available_skills", + "models", + "permission", + "plan_mode", + "providers", + "services", + "telemetry", + "thinking", + "yolo", + ], + "required": [], + "type": "object", + }, + "params": null, + "querystring": null, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/config", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/connections", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/connections", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/files", + }, + { + "method": "DELETE", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "file_id", + ], + "required": [ + "file_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/files/:file_id", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "file_id", + ], + "required": [ + "file_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:string", + }, + }, + "url": "/api/v1/files/:file_id", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "file_id", + ], + "required": [ + "file_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:string", + }, + }, + "url": "/api/v1/files/:file_id", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "path", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/fs::browse", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "path", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/fs::browse", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/fs::home", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/fs::home", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/healthz", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/healthz", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/mcp/servers", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/mcp/servers", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/mcp/servers/:tail", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/meta", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/meta", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/models", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/models", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "tail", + ], + "required": [ + "tail", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/models/:tail", + }, + { + "method": "DELETE", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "provider", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/oauth/login", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "provider", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/oauth/login", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "provider", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/oauth/login", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "provider", + ], + "required": [], + "type": "object", + }, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/oauth/login", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "provider", + ], + "required": [], + "type": "object", + }, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/oauth/logout", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/providers", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/providers", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/providers:refresh_oauth", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "provider_id", + ], + "required": [ + "provider_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/providers/:provider_id", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "provider_id", + ], + "required": [ + "provider_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/providers/:provider_id", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "after_id", + "archived_only", + "before_id", + "include_archive", + "page_size", + "status", + "workspace_id", + ], + "required": [ + "archived_only", + "include_archive", + ], + "type": "object", + }, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "after_id", + "archived_only", + "before_id", + "include_archive", + "page_size", + "status", + "workspace_id", + ], + "required": [ + "archived_only", + "include_archive", + ], + "type": "object", + }, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "agent_config", + "metadata", + "title", + "workspace_id", + ], + "required": [], + "type": "object", + }, + "params": null, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + "tail", + ], + "required": [ + "session_id", + "tail", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(12)", + }, + }, + "url": "/api/v1/sessions/:session_id/:tail", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "status", + ], + "required": [ + "status", + ], + "type": "object", + }, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/approvals", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "status", + ], + "required": [ + "status", + ], + "type": "object", + }, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/approvals", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "decision", + "feedback", + "scope", + "selected_label", + ], + "required": [ + "decision", + ], + "type": "object", + }, + "params": { + "properties": [ + "approval_id", + "session_id", + ], + "required": [ + "approval_id", + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/sessions/:session_id/approvals/:approval_id", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "after_id", + "before_id", + "page_size", + "status", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/children", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "after_id", + "before_id", + "page_size", + "status", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/children", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "metadata", + "title", + ], + "required": [], + "type": "object", + }, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/children", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:string", + }, + }, + "url": "/api/v1/sessions/:session_id/fs/*", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:string", + }, + }, + "url": "/api/v1/sessions/:session_id/fs/*", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "after_id", + "before_id", + "page_size", + "role", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/sessions/:session_id/messages", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "after_id", + "before_id", + "page_size", + "role", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/sessions/:session_id/messages", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "message_id", + "session_id", + ], + "required": [ + "message_id", + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/sessions/:session_id/messages/:message_id", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "message_id", + "session_id", + ], + "required": [ + "message_id", + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/sessions/:session_id/messages/:message_id", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/profile", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/profile", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "agent_config", + "metadata", + "permission_rules", + "title", + ], + "required": [], + "type": "object", + }, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/profile", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/prompts", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/prompts", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "agent_id", + "content", + "goal_control", + "goal_objective", + "metadata", + "model", + "permission_mode", + "plan_mode", + "swarm_mode", + "thinking", + ], + "required": [ + "content", + ], + "type": "object", + }, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(10)", + }, + }, + "url": "/api/v1/sessions/:session_id/prompts", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "prompt_ids", + ], + "required": [ + "prompt_ids", + ], + "type": "object", + }, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/prompts::steer", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "oneOf(5)", + }, + }, + "url": "/api/v1/sessions/:session_id/prompts/:tail", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "status", + ], + "required": [ + "status", + ], + "type": "object", + }, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/questions", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "status", + ], + "required": [ + "status", + ], + "type": "object", + }, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/questions", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + "tail", + ], + "required": [ + "session_id", + "tail", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(5)", + }, + }, + "url": "/api/v1/sessions/:session_id/questions/:tail", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/skills", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(2)", + }, + }, + "url": "/api/v1/sessions/:session_id/skills", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "args", + ], + "required": [], + "type": "object", + }, + "params": { + "properties": [ + "session_id", + "tail", + ], + "required": [ + "session_id", + "tail", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(5)", + }, + }, + "url": "/api/v1/sessions/:session_id/skills/:tail", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/sessions/:session_id/snapshot", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/sessions/:session_id/snapshot", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/status", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/status", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "status", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/tasks", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "status", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/tasks", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "oneOf(5)", + }, + }, + "url": "/api/v1/sessions/:session_id/tasks/:tail", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + "task_id", + ], + "required": [ + "session_id", + "task_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "output_bytes", + "with_output", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/tasks/:task_id", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + "task_id", + ], + "required": [ + "session_id", + "task_id", + ], + "type": "object", + }, + "querystring": { + "properties": [ + "output_bytes", + "with_output", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/tasks/:task_id", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/terminals", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(3)", + }, + }, + "url": "/api/v1/sessions/:session_id/terminals", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "cols", + "cwd", + "rows", + "shell", + ], + "required": [], + "type": "object", + }, + "params": { + "properties": [ + "session_id", + ], + "required": [ + "session_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/terminals", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + "tail", + ], + "required": [ + "session_id", + "tail", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/terminals/:tail", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + "terminal_id", + ], + "required": [ + "session_id", + "terminal_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/terminals/:terminal_id", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "session_id", + "terminal_id", + ], + "required": [ + "session_id", + "terminal_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(4)", + }, + }, + "url": "/api/v1/sessions/:session_id/terminals/:terminal_id", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "count", + "instruction", + "metadata", + "page_size", + "title", + ], + "required": [], + "type": "object", + }, + "params": { + "properties": [ + "tail", + ], + "required": [ + "tail", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "oneOf(6)", + }, + }, + "url": "/api/v1/sessions/:tail", + }, + { + "method": "POST", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/shutdown", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "session_id", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/tools", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": { + "properties": [ + "session_id", + ], + "required": [], + "type": "object", + }, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/tools", + }, + { + "method": "GET", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/workspaces", + }, + { + "method": "HEAD", + "schemaSummary": { + "body": null, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/workspaces", + }, + { + "method": "POST", + "schemaSummary": { + "body": { + "properties": [ + "name", + "root", + ], + "required": [ + "root", + ], + "type": "object", + }, + "params": null, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/workspaces", + }, + { + "method": "DELETE", + "schemaSummary": { + "body": null, + "params": { + "properties": [ + "workspace_id", + ], + "required": [ + "workspace_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/workspaces/:workspace_id", + }, + { + "method": "PATCH", + "schemaSummary": { + "body": { + "properties": [ + "name", + ], + "required": [ + "name", + ], + "type": "object", + }, + "params": { + "properties": [ + "workspace_id", + ], + "required": [ + "workspace_id", + ], + "type": "object", + }, + "querystring": null, + "responses": { + "200": "type:object", + }, + }, + "url": "/api/v1/workspaces/:workspace_id", + }, +] +`; diff --git a/packages/server/test/api-surface.snapshot.test.ts b/packages/server/test/api-surface.snapshot.test.ts new file mode 100644 index 000000000..be5c1b616 --- /dev/null +++ b/packages/server/test/api-surface.snapshot.test.ts @@ -0,0 +1,149 @@ +/** + * API surface snapshot for the server's `/api/v1` Fastify route table. + * + * Builds a minimal Fastify instance, registers the REAL route table via + * `registerApiV1Routes` (the same entry used by `start.ts`) with a stub DI + * container, and captures every registered route's `{ method, url, schemaSummary }` + * through Fastify's `onRoute` hook. The result is sorted deterministically and + * compared against the committed snapshot, so any route add/remove/rename or + * request/response schema change fails this test until the snapshot is updated. + * + * No HTTP server is started and no backend services are connected: route + * modules resolve services lazily inside their handlers, so registration never + * touches the stub container. + */ + +import Fastify, { type FastifyInstance, type RouteOptions } from 'fastify'; +import type { IInstantiationService } from '@moonshot-ai/agent-core'; +import { describe, expect, it } from 'vitest'; + +import { registerApiV1Routes } from '../src/routes/registerApiV1Routes.js'; + +/** + * Compact, deterministic summary of one JSON-schema request part + * (body / params / querystring). Captures shape-changing fields (type, + * required keys, property names) without dumping the whole schema. + */ +interface SchemaPartSummary { + readonly type: string | readonly string[] | null; + readonly required: readonly string[]; + readonly properties: readonly string[]; +} + +/** Compact summary of a route's request + response schema. */ +interface SchemaSummary { + readonly body: SchemaPartSummary | null; + readonly params: SchemaPartSummary | null; + readonly querystring: SchemaPartSummary | null; + /** Map of response status code -> compact descriptor of that schema. */ + readonly responses: Readonly>; +} + +interface RouteSurfaceEntry { + readonly method: string; + readonly url: string; + readonly schemaSummary: SchemaSummary; +} + +function summarizePart(schema: unknown): SchemaPartSummary | null { + if (schema === null || typeof schema !== 'object') { + return null; + } + const obj = schema as Record; + const rawType = obj['type']; + const type = + typeof rawType === 'string' + ? rawType + : Array.isArray(rawType) && rawType.every((t): t is string => typeof t === 'string') + ? [...rawType].sort() + : null; + + const rawRequired = obj['required']; + const required = + Array.isArray(rawRequired) && rawRequired.every((r): r is string => typeof r === 'string') + ? [...rawRequired].sort() + : []; + + const rawProps = obj['properties']; + const properties = + rawProps !== null && typeof rawProps === 'object' && !Array.isArray(rawProps) + ? Object.keys(rawProps as Record).sort() + : []; + + return { type, required, properties }; +} + +function describeResponseSchema(schema: unknown): string { + if (schema === null || typeof schema !== 'object') { + return 'none'; + } + const obj = schema as Record; + if (Array.isArray(obj['oneOf'])) { + return `oneOf(${(obj['oneOf'] as unknown[]).length})`; + } + if (Array.isArray(obj['anyOf'])) { + return `anyOf(${(obj['anyOf'] as unknown[]).length})`; + } + const rawType = obj['type']; + if (typeof rawType === 'string') { + return `type:${rawType}`; + } + return 'other'; +} + +function summarizeResponses(schema: unknown): Readonly> { + if (schema === null || typeof schema !== 'object') { + return {}; + } + const out: Record = {}; + for (const code of Object.keys(schema as Record).sort()) { + out[code] = describeResponseSchema((schema as Record)[code]); + } + return out; +} + +function buildSchemaSummary(schema: RouteOptions['schema']): SchemaSummary { + const s = (schema ?? {}) as Record; + return { + body: summarizePart(s['body']), + params: summarizePart(s['params']), + querystring: summarizePart(s['querystring']), + responses: summarizeResponses(s['response']), + }; +} + +async function collectRouteSurface(): Promise { + const app: FastifyInstance = Fastify({ logger: false }); + // Match start.ts: bypass Fastify's schema compilers so `app.ready()` does + // not try to compile/validate response schemas (we only need the route table + // and raw schema objects from the onRoute hook, not runtime serialization). + app.setValidatorCompiler(() => () => true); + app.setSerializerCompiler(() => (data) => JSON.stringify(data)); + const entries: RouteSurfaceEntry[] = []; + + app.addHook('onRoute', (route: RouteOptions) => { + const methods = Array.isArray(route.method) ? route.method : [route.method]; + for (const method of methods) { + entries.push({ + method: method.toUpperCase(), + url: route.url, + schemaSummary: buildSchemaSummary(route.schema), + }); + } + }); + + const stubIx = null as unknown as IInstantiationService; + await registerApiV1Routes(app, stubIx, { serverVersion: '0.0.0-snapshot' }); + await app.ready(); + + return entries.sort((a, b) => + a.url === b.url ? a.method.localeCompare(b.method) : a.url.localeCompare(b.url), + ); +} + +describe('server API surface snapshot', () => { + it('matches the registered route surface snapshot', async () => { + const routes = await collectRouteSurface(); + expect(routes).toMatchSnapshot(); + }); +}); diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 44b034706..552e43738 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -772,3 +772,103 @@ describe('POST /api/v1/sessions/{session_id}:archive — archive', () => { expect(env.code).toBe(40401); }); }); + +describe('GET /api/v1/sessions?archived_only — archived-only list', () => { + it('returns only archived sessions and hides them from the default list', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-archived-only'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:archive`, + payload: {}, + }); + + const defaultList = envelopeOf<{ items: Array<{ id: string }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' })).json(), + ); + expect(defaultList.data!.items.find((s) => s.id === created.id)).toBeUndefined(); + + const archivedOnly = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions?archived_only=true' })).json(), + ); + expect(archivedOnly.code).toBe(0); + const listed = archivedOnly.data!.items.find((s) => s.id === created.id); + expect(listed).toBeDefined(); + expect(listed!.archived).toBe(true); + // No live session should leak into the archived-only view. + expect(archivedOnly.data!.items.every((s) => s.archived === true)).toBe(true); + }); + + it('rejects archived_only together with include_archive', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/sessions?archived_only=true&include_archive=true', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('POST /api/v1/sessions/{session_id}:restore — restore', () => { + it('restores an archived session so it reappears in the default list', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-restore'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:archive`, + payload: {}, + }); + + const restoreRes = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:restore`, + payload: {}, + }); + const restoreEnv = envelopeOf(restoreRes.json()); + expect(restoreEnv.code).toBe(0); + const session = sessionSchema.parse(restoreEnv.data); + expect(session.id).toBe(created.id); + expect(session.archived).toBe(false); + + const defaultList = envelopeOf<{ items: Array<{ id: string; archived?: boolean }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' })).json(), + ); + const relisted = defaultList.data!.items.find((s) => s.id === created.id); + expect(relisted).toBeDefined(); + expect(relisted!.archived).toBe(false); + + const getRes = envelopeOf( + (await appOf(r).inject({ method: 'GET', url: `/api/v1/sessions/${created.id}` })).json(), + ); + expect(getRes.code).toBe(0); + expect(sessionSchema.parse(getRes.data).archived).toBe(false); + }); + + it('returns 40401 for unknown id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions/sess_missing:restore', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); diff --git a/packages/server/test/start.test.ts b/packages/server/test/start.test.ts index cd25d5750..47c7f9a44 100644 --- a/packages/server/test/start.test.ts +++ b/packages/server/test/start.test.ts @@ -7,7 +7,7 @@ * * The DI graph end-to-end is exercised implicitly: every startServer call * constructs ILogService, IRestGateway, IEventService, IApprovalService, - * IQuestionService, and ICoreProcessService in order. Failure modes there (missing + * IQuestionService, and ICoreRuntime in order. Failure modes there (missing * service, wrong ctor args) would surface as a startServer reject. */ @@ -34,7 +34,7 @@ import { IApprovalService, IConnectionRegistry, IEventService, - ICoreProcessService, + ICoreRuntime, ILogService, IQuestionService, IRestGateway, @@ -393,7 +393,7 @@ describe('startServer — DI container wiring', () => { expect(a.get(IApprovalService)).toBeDefined(); expect(a.get(IQuestionService)).toBeDefined(); expect(a.get(IWSGateway)).toBeDefined(); - const bridge = a.get(ICoreProcessService); + const bridge = a.get(ICoreRuntime); expect(bridge).toBeDefined(); expect(typeof bridge.rpc).toBe('object'); expect(typeof bridge.dispose).toBe('function'); @@ -403,8 +403,8 @@ describe('startServer — DI container wiring', () => { it('CoreProcessService.rpc rejects after the server is closed (dispose cascade)', async () => { const r = await spawn(); // Grab a bridge reference BEFORE close — after close the container is disposed - // and a.get(ICoreProcessService) would throw on the dead InstantiationService. - const bridge = r.services.invokeFunction((a) => a.get(ICoreProcessService)); + // and a.get(ICoreRuntime) would throw on the dead InstantiationService. + const bridge = r.services.invokeFunction((a) => a.get(ICoreRuntime)); await r.close(); await expect(bridge.rpc.getCoreInfo({})).rejects.toThrow(/disposed/); }); diff --git a/packages/server/test/svc/launchd.test.ts b/packages/server/test/svc/launchd.test.ts index d44291a21..a86df501e 100644 --- a/packages/server/test/svc/launchd.test.ts +++ b/packages/server/test/svc/launchd.test.ts @@ -154,6 +154,7 @@ describe('launchd manager — install', () => { const xml = readFileSync(plistPath, 'utf8'); expect(xml).toContain(`${KIMI_SERVER_LABEL}`); expect(xml).toContain('/usr/local/bin/kimi'); + expect(xml).toContain('--foreground'); expect(xml).toContain('58627'); expect(calls.length).toBe(1); diff --git a/packages/server/test/svc/schtasks.test.ts b/packages/server/test/svc/schtasks.test.ts index 2433f1953..9e9584fea 100644 --- a/packages/server/test/svc/schtasks.test.ts +++ b/packages/server/test/svc/schtasks.test.ts @@ -14,7 +14,11 @@ import { createSchtasksManager, type SchtasksManagerDeps, } from '../../src/svc/schtasks'; -import { buildScheduledTaskXml, parseSchtasksQuery } from '../../src/svc/schtasks-xml'; +import { + buildCmdRedirectArgs, + buildScheduledTaskXml, + parseSchtasksQuery, +} from '../../src/svc/schtasks-xml'; import { KIMI_SERVER_TASK_NAME } from '../../src/svc/paths'; import { readInstallPlan, writeInstallPlan } from '../../src/svc/install-plan'; import type { ExecOptions, ExecResult } from '../../src/svc/exec'; @@ -120,6 +124,34 @@ describe('buildScheduledTaskXml', () => { expect(xml).toContain('InteractiveToken'); expect(xml).not.toContain('S-1-5-32-545'); }); + + it('wraps in cmd.exe and redirects stdout/stderr when redirectLogPath is set', () => { + const xml = buildScheduledTaskXml({ + description: 'desc', + command: 'C:\\Program Files\\Kimi\\kimi.exe', + arguments: 'server run --foreground --port 58627', + redirectLogPath: 'C:\\Users\\alice\\AppData\\Local\\kimi-code\\server.log', + }); + expect(xml).toContain('cmd.exe'); + expect(xml).toContain(''); + expect(xml).toContain('>>'); + expect(xml).toContain('2>&1'); + expect(xml).toContain('C:\\Program Files\\Kimi\\kimi.exe'); + expect(xml).toContain('C:\\Users\\alice\\AppData\\Local\\kimi-code\\server.log'); + expect(xml).toContain('server run --foreground --port 58627'); + }); +}); + +describe('buildCmdRedirectArgs', () => { + it('builds a cmd /c line that redirects to the log file', () => { + const args = buildCmdRedirectArgs('C:\\bin\\kimi.exe', 'server run', 'C:\\logs\\server.log'); + expect(args).toBe('/c ""C:\\bin\\kimi.exe" server run >> "C:\\logs\\server.log" 2>&1"'); + }); + + it('handles a missing argument list', () => { + const args = buildCmdRedirectArgs('C:\\bin\\kimi.exe', undefined, 'C:\\logs\\server.log'); + expect(args).toBe('/c ""C:\\bin\\kimi.exe" >> "C:\\logs\\server.log" 2>&1"'); + }); }); describe('parseSchtasksQuery', () => { @@ -168,6 +200,7 @@ describe('schtasks manager — install', () => { expect(writtenXmls.length).toBe(1); expect(writtenXmls[0]).toContain(`Kimi Code local server`); expect(writtenXmls[0]).not.toContain('--host'); + expect(writtenXmls[0]).toContain('--foreground'); expect(writtenXmls[0]).toContain('--port 58627'); expect(calls.length).toBe(2); diff --git a/packages/server/test/svc/systemd.test.ts b/packages/server/test/svc/systemd.test.ts index 30142ddbd..023dbbe82 100644 --- a/packages/server/test/svc/systemd.test.ts +++ b/packages/server/test/svc/systemd.test.ts @@ -47,17 +47,27 @@ function makeStubExec(responses: ReadonlyArray) { function makeDeps( responses: ReadonlyArray, workDir: string, -): { deps: SystemdManagerDeps; calls: StubCall[]; unitPath: string; logPath: string } { + loginctlResponses: ReadonlyArray = [], +): { + deps: SystemdManagerDeps; + calls: StubCall[]; + loginctlCalls: StubCall[]; + unitPath: string; + logPath: string; +} { const { execSystemctl, calls } = makeStubExec(responses); + const { execSystemctl: execLoginctl, calls: loginctlCalls } = makeStubExec(loginctlResponses); const unitPath = join(workDir, 'systemd', 'user', KIMI_SERVER_SYSTEMD_UNIT); const logPath = join(workDir, 'server', 'server.log'); const deps: SystemdManagerDeps = { execSystemctl, + execLoginctl, resolveProgram: () => '/usr/local/bin/kimi', unitPath: () => unitPath, logPath: () => logPath, + userName: () => 'alice', }; - return { deps, calls, unitPath, logPath }; + return { deps, calls, loginctlCalls, unitPath, logPath }; } let workDir: string; @@ -113,6 +123,21 @@ describe('buildSystemdUnit', () => { expect(unit).toContain('Environment=FOO=bar'); expect(unit).toContain('Environment=BAZ=qux'); }); + + it('renders StandardOutput/StandardError append lines when logPath is set', () => { + const unit = buildSystemdUnit({ + programArguments: ['/usr/bin/kimi'], + logPath: '/home/alice/.kimi/server/server.log', + }); + expect(unit).toContain('StandardOutput=append:/home/alice/.kimi/server/server.log'); + expect(unit).toContain('StandardError=append:/home/alice/.kimi/server/server.log'); + }); + + it('omits StandardOutput/StandardError when logPath is unset', () => { + const unit = buildSystemdUnit({ programArguments: ['/usr/bin/kimi'] }); + expect(unit).not.toContain('StandardOutput'); + expect(unit).not.toContain('StandardError'); + }); }); describe('parseSystemctlShow', () => { @@ -149,7 +174,7 @@ describe('systemd manager — install', () => { expect(result.unitPath).toBe(unitPath); expect(existsSync(unitPath)).toBe(true); const text = readFileSync(unitPath, 'utf8'); - expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --port 58627 --log-level info'); + expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --foreground --port 58627 --log-level info'); expect(text).not.toContain('--host'); expect(calls.length).toBe(3); @@ -185,7 +210,7 @@ describe('systemd manager — install', () => { const result = await mgr.install({ host: '0.0.0.0', port: 9999, logLevel: 'debug', force: true }); expect(result.status).toBe('replaced'); const text = readFileSync(unitPath, 'utf8'); - expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --port 9999 --log-level debug'); + expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --foreground --port 9999 --log-level debug'); expect(text).not.toContain('0.0.0.0'); }); @@ -357,3 +382,88 @@ describe('systemd manager — status', () => { expect(status.notes?.[0]).toMatch(/systemctl --user show failed/); }); }); + +describe('systemd manager — lingering', () => { + it('enables linger on install when not yet enabled', async () => { + const { deps, calls, loginctlCalls } = makeDeps( + [ + { stdout: '', stderr: '', code: 0 }, + { stdout: '', stderr: '', code: 0 }, + { stdout: '', stderr: '', code: 0 }, + ], + workDir, + [ + { stdout: 'Linger=no', stderr: '', code: 0 }, + { stdout: '', stderr: '', code: 0 }, + ], + ); + const mgr = createSystemdManager(deps); + const result = await mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }); + expect(result.status).toBe('installed'); + expect(result.message).not.toMatch(/could not enable linger/); + expect(loginctlCalls.map((c) => c.args)).toEqual([ + ['show-user', 'alice', '--property=Linger'], + ['enable-linger', 'alice'], + ]); + expect(calls.length).toBe(3); + }); + + it('does not call enable-linger when already lingering', async () => { + const { deps, loginctlCalls } = makeDeps( + [ + { stdout: '', stderr: '', code: 0 }, + { stdout: '', stderr: '', code: 0 }, + { stdout: '', stderr: '', code: 0 }, + ], + workDir, + [{ stdout: 'Linger=yes', stderr: '', code: 0 }], + ); + const mgr = createSystemdManager(deps); + await mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }); + expect(loginctlCalls.map((c) => c.args)).toEqual([['show-user', 'alice', '--property=Linger']]); + }); + + it('surfaces a note when enable-linger fails', async () => { + const { deps } = makeDeps( + [ + { stdout: '', stderr: '', code: 0 }, + { stdout: '', stderr: '', code: 0 }, + { stdout: '', stderr: '', code: 0 }, + ], + workDir, + [ + { stdout: 'Linger=no', stderr: '', code: 0 }, + { stdout: '', stderr: 'Access denied', code: 1 }, + ], + ); + const mgr = createSystemdManager(deps); + const result = await mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }); + expect(result.status).toBe('installed'); + expect(result.message).toMatch(/could not enable linger/); + expect(result.message).toMatch(/sudo loginctl enable-linger alice/); + }); + + it('reports linger state in status notes', async () => { + const showOutput = ['ActiveState=active', 'SubState=running', 'MainPID=42'].join('\n'); + const { deps, unitPath } = makeDeps( + [{ stdout: showOutput, stderr: '', code: 0 }], + workDir, + [{ stdout: 'Linger=yes', stderr: '', code: 0 }], + ); + mkdirSync(unitPath.replace(/\/[^/]+$/, ''), { recursive: true }); + writeFileSync(unitPath, '# stub'); + writeInstallPlan({ + host: '127.0.0.1', + port: 58627, + logLevel: 'info', + program: '/usr/local/bin/kimi', + programArguments: [], + logPath: '/tmp/x', + installedAt: '2026-06-11T00:00:00.000Z', + }); + const mgr = createSystemdManager(deps); + const status = await mgr.status(); + expect(status.running).toBe(true); + expect(status.notes?.some((n) => n.includes('linger: enabled'))).toBe(true); + }); +}); diff --git a/plan/PLAN.md b/plan/PLAN.md new file mode 100644 index 000000000..5f9972291 --- /dev/null +++ b/plan/PLAN.md @@ -0,0 +1,269 @@ +# PLAN — `packages/agent-core` di-v3 重构(下一阶段) + +> 从当前 `refactor/di-domain-runtime-services`(M0–M7 已完成)演进到 di-v3 目标架构。 +> 目标设计参考 `/Users/moonshot/Projects/kimi-code-dev-2/plan/`(30 篇设计文档)。 +> 现状评估见本目录 `CURRENT-STATE.md`(可选)与本次会话的偏离分析。 + +--- + +## 0. 背景 + +### 0.1 当前状态(M0–M7 已完成) + +`refactor/di-domain-runtime-services` 分支已完成 DI 架构第二阶段(48 步,58 提交): + +- Session 拆为 command / query / runtime + SessionIndex + SessionRepository + SessionHost +- Agent 瘦身为句柄(Llm / Status / Rpc / Resume / Profile / Factory / 错误桥) +- CoreRPC 进程内零序列化切片(`getCoreApi()`,10 个 consumer domain 全部切走) +- 9 个 domain 的 service-skill 概念定稿 +- 事件驱动:IDomainEventBus(per-agent)+ IEventService(全局)+ 投影边界 + ILifecycleService 钩子 +- ICoreRuntime(替代 ICoreProcessService)+ KimiCore thin 线控 +- 依赖方向 fence(runtime ↛ services / repository/index ↛ services / 不跨服务业务 import) +- 弃用 alias 清理 + 终态文档 + +### 0.2 di-v3 目标(`kimi-code-dev-2/plan/`) + +agent-core 完整重架构为 **20 个 domain × scope 二维矩阵**: + +- **20 个 domain**:Kosong / Kaos / Loop / Permission / Agent(收窄)/ Session / Workspace / MCP / Skill / Plugin / Profile / Hook / Cron / Background / Goal / Swarm / Records / Context / Todo / Web +- **scope = 子 InstantiationService**:Core / Session / Agent / Turn / ToolCall 五层 scope,每层一个 child container +- **service 通过 `registerScopedService(scope, I, Impl)` 注册**,ctor 注入 `I*Context` 取身份,方法签名不带 id +- **目录 `agent-core//`**:契约 + 厚实现 + 工具同居 +- **基础设施下沉**:`_base/`(di / event / logging / errors)+ `_utils/`(纯函数);除 `_base/` / `_utils/` 与横切保留目录(rpc / config / flags / errors / logging 等)外,`agent-core/src/` 顶层其余目录均为 domain +- **barrel-only 暴露**:每层(`_base/` / `_utils/` / ``)只通过 `index.ts` 暴露公共面;consumer 从 barrel 导入(`#/`、`#/_base/di`、`@moonshot-ai/agent-core`),**禁止 deep-import 子模块** +- **禁止 re-import / re-export shim**:迁移不留旧路径 re-export alias;consumer 直接从新位置的 barrel 导入。旧路径在同一步内删除(不再「deprecated,P9 删除」) +- **工具按域注册**:每域 `registerTools(accessor)`,`bootstrap.ts::registerAllBuiltinTools` 统一调 +- **Agent 收窄**到 3–4 个服务(lifecycle / restorable / subagentHost) + +### 0.3 偏离摘要 + +| 维度 | 当前 | di-v3 | 偏离 | +|---|---|---|---| +| domain 数 | ~10–12 | 20 | 中 | +| scope 机制 | per-agent ServiceCollection(raw createChild) | LifecycleScope + registerScopedService + I*Context | **极大** | +| 目录 | `services//` + `agent//` + `tools/` | `agent-core//` 同居 | 大 | +| 工具 | 集中 `tools/` | 按域 `registerTools` | 大 | +| 基础设施 | `di/` + `base/` + `utils/` | `_base/` + `_utils/` | 中 | +| Agent | ~25 服务字段 | 3–4 服务 | 中 | +| Session / Event / ICoreRuntime / fence | 已对齐 | 已对齐 | 小 | + +--- + +## 1. 决策 + +### 1.1 总体策略:**演进式,不是推倒重来** + +M0–M7 已为 di-v3 铺了地基(Session 拆分、事件系统、ICoreRuntime、fence、DI 容器)。下一阶段**复用这些地基**,在其上引入 di-v3 的核心机制(scope、目录、工具、domain 拆分),而不是从零开始。 + +**理由**: +- M0–M7 的代码基本都能在 di-v3 里找到归宿(被重组而非被丢弃)。 +- 推倒重来会浪费 M0–M7 已验证的 session 拆分、事件系统、fence。 +- 演进式允许分阶段验证,每阶段独立可测、可回滚。 + +### 1.2 核心决策 + +| # | 决策 | 选择 | 理由 | +|---|---|---|---| +| 1 | scope 机制引入时机 | **先建 scope 机制,再迁 domain** | scope 是 di-v3 的基础,domain 迁移依赖它 | +| 2 | 目录重组方式 | **逐 domain 迁移**(`services//` → `/`),不是一次性大挪移 | 降低风险,每 domain 独立验证 | +| 3 | domain 拆分时机 | **先迁目录,再拆细**(先把现有 domain 迁到新结构,再把大的拆成 20 个) | 两步走,避免同时改结构 + 拆分 | +| 4 | 工具迁移 | **随 domain 迁移一起走**(每域迁完后,把对应工具搬进 `/tools/` 并写 `registerTools`) | 工具跟随 domain,避免二次搬运 | +| 5 | 基础设施下沉 | **先于 domain 迁移**(先把 di/event/logging/errors/utils 沉到 `_base/`/`_utils/`,domain 迁移时引用新位置) | 避免 domain 迁移后还要改 import | +| 6 | Agent 收窄 | **最后做**(等所有 domain 迁完 + scope 稳定后,把 Agent 剩余职责拆到 domain) | Agent 收窄依赖所有 domain 就位 | +| 7 | 兼容策略 | **每阶段保持 green**(typecheck + 全套 test + fence),不允许长时间 broken | 每步可回滚 | +| 8 | registerSingleton → registerScopedService | **逐 service 迁移**(每个 service 标注 scope 后迁移注册方式) | 渐进式,避免大爆炸 | + +### 1.3 复用 M0–M7 的地基(不重复造) + +| M0–M7 成果 | di-v3 中的归宿 | 复用方式 | +|---|---|---| +| Session cmd/query/runtime 拆分 | Session 域的 ISessionService / ISessionQueryService / ISessionRuntimeService | 直接复用,搬到 `session/` | +| SessionIndex | Session 域的 ISessionIndex | 直接复用 | +| SessionRepository(runtime) | Session 域的 ISessionRepository | 直接复用 | +| SessionHost | Session 域的 SessionHost(manager 候选) | 演进为 ISessionLifecycleService(manager) | +| IDomainEventBus + IEventService + 投影 | RPC-Event 域 | 直接复用 | +| ILifecycleService 钩子 | 各 scope manager 的钩子 | 拆分到 Session / Agent / Turn manager | +| ICoreRuntime + KimiCore thin 线控 | RPC-Event 域的 ICoreProcessService + KimiCore | 直接复用 | +| getCoreApi() 进程内切片 | RPC-Event 域 | 直接复用 | +| 依赖方向 fence | lint + scope 机制 | 保留 test fence + 加 lint | +| DI 容器(di/) | `_base/di/` | 下沉到 `_base/` | +| 9 个 domain concept doc | di-v3 各 domain 定稿 | 演进为 di-v3 定稿 | + +### 1.4 拒绝的替代方案 + +| 方案 | 拒绝理由 | +|---|---| +| 推倒重来(从 main 重新写 di-v3) | 浪费 M0–M7 已验证的地基;风险高 | +| 一次性大挪移(一个 PR 迁完所有 domain) | 风险极高,无法独立验证 / 回滚 | +| 先拆 domain 再建 scope | domain 拆分依赖 scope 机制,顺序错了 | +| 保留 `services/` 不动 | di-v3 明确要求 `services/` 消失,保留会持续偏离 | +| 工具继续集中 `tools/` | di-v3 明确工具按域注册,集中会持续偏离 | +| 迁移时保留旧路径 re-export alias(deprecated,后续 P9 删除) | 违反 barrel-only / 不允许 re-import;留下 deep-import 与双入口;改为同一步内全量改写 consumer 到 barrel 并立即删除旧路径 | + +--- + +## 2. 目标架构(终态) + +### 2.1 目录结构(终态) + +``` +packages/agent-core/src/ +├── scope/ # scope-DI 机制(LifecycleScope / registry / builder / context) +├── kosong/ # 域 1:LLM provider / 模型 IO +├── kaos/ # 域 2:执行环境(fs / process / shell / terminal) +├── loop/ # 域 3:turn 推进(ITurnService / IToolService / TurnFlow) +├── permission/ # 域 4:权限(跨四层 scope) +├── agent/ # 域 5:组合根(收窄到 3-4 服务) +├── session/ # 域 6:session(cmd/query/runtime/repository/index/manager) +├── workspace/ # 域 7:workspace +├── mcp/ # 域 8:MCP +├── skill/ # 域 9:skill +├── plugin/ # 域 10:plugin(runtime,已对齐) +├── profile/ # 域 11:profile +├── hook/ # 域 12:hook +├── cron/ # 域 13:cron(新) +├── background/ # 域 14:background(新) +├── goal/ # 域 15:goal(新) +├── swarm/ # 域 16:swarm(新) +├── records/ # 域 17:records + replay(新) +├── context/ # 域 18:context + compaction + injection(新) +├── todo/ # 域 19:todo(新) +├── web/ # 域 20:web(新) +├── logging/ # 横切基础设施 service +├── rpc/ # 跨进程基础设施(CoreAPI / KimiCore / IEventService / createRPC) +├── config/ # Kimi 专属(保留) +├── flags/ # Kimi 专属(保留) +├── errors/ # Kimi error 类(保留) +├── telemetry.ts # Kimi telemetry(保留) +├── _base/ # 内部基础设施(di / event / logging / errors) +├── _utils/ # 内部工具函数(abort / fs / slug / xml / ...) +└── index.ts +``` + +### 2.2 scope 机制(终态) + +```ts +// scope/lifecycle.ts +export enum LifecycleScope { Core, Session, Agent, Turn, ToolCall } + +// scope/registry.ts +export function registerScopedService( + scope: LifecycleScope, + id: ServiceIdentifier, + descriptor: SyncDescriptor, + type: InstantiationType, + options?: { replace?: boolean }, +): void; + +// service ctor 注入身份 +class FooService { + constructor(@IAgentContext private readonly ctx: IAgentContext) { } + // 方法签名不带 agentId + doSomething(): void { this.ctx.id ... } +} +``` + +### 2.3 每个 domain 目录的结构(终态) + +``` +/ +├── .ts # 契约:IXxxService + createDecorator + sentinel errors +├── Service.ts # 厚实现:class XxxService +├── # 状态机 / scheduler / persistence / parser / provider 适配器 +├── tools/ # 该域提供的工具(如有) +│ └── .ts +└── index.ts # export + registerServices + registerTools +``` + +**barrel-only 暴露(强制)**:每层(`` / `_base/` / `_utils/`)的 `index.ts` 是其唯一公共面。consumer 一律从 barrel 导入(`#/session`、`#/_base/di`、`@moonshot-ai/agent-core`),禁止 deep-import 子模块(如 `#/_base/di/instantiation`、`#/session/store`)。迁移不留旧路径 re-export alias(不允许 re-import):旧路径在同一步内删除,consumer 全量改写为 barrel 导入。 + +### 2.4 工具注册(终态) + +```ts +// /index.ts +export function registerTools(accessor: IServiceAccessor): IDisposable { ... } + +// agent-core/bootstrap.ts +export function registerAllBuiltinTools(accessor: IServiceAccessor): IDisposable { + return new DisposableStore([ + registerKaosTools(accessor), + registerWebTools(accessor), + registerCronTools(accessor), + // ... + ]); +} +``` + +--- + +## 3. 阶段划分(高层) + +| 阶段 | 主题 | 输出 | 依赖 | +|---|---|---|---| +| **P0** | 地基与护栏 | fence 扩展、snapshot、scope 设计定稿 | — | +| **P1** | scope 机制 | LifecycleScope / ScopeRegistry / registerScopedService / I*Context / ScopeBuilder / manager 模式 | P0 | +| **P2** | 基础设施下沉 | `_base/` + `_utils/` + lint | P1 | +| **P3** | domain 目录迁移(现有 domain) | `services//` → `/`,逐 domain | P2 | +| **P4** | domain 拆分(→ 20) | Cron / Background / Goal / Swarm / Records / Context / Todo / Web 独立成域 | P3 | +| **P5** | 工具按域注册 | `registerTools` + `registerAllBuiltinTools` | P3, P4 | +| **P6** | service scope 标注 | 逐 service 从 registerSingleton 迁到 registerScopedService + I*Context 注入 | P1, P3 | +| **P7** | Agent 收窄 | Agent 瘦到 3–4 服务,剩余职责拆到 domain | P4, P6 | +| **P8** | bootstrap 生命周期 | 5 阶段启动 + shutdown 反向链 + Restorable resume | P6, P7 | +| **P9** | 收尾 + 文档 | 删除 deprecated、终态文档、changeset | P8 | + +--- + +## 4. 关键风险与对策 + +| 风险 | 对策 | +|---|---| +| scope 机制引入破坏现有 DI | 先建 scope 机制,跑通 ScopeBuilder + 1 个试点 service(如 ILogService),再批量迁移 | +| 目录重组破坏 import | 逐 domain 迁移,每域迁完跑全套 test;用 codemod / 批量 sed 改 import | +| 工具搬迁破坏工具注册 | 每域迁完工具后,写 `registerTools` 并接入 bootstrap;保持工具注册表 green | +| domain 拆分破坏现有 service 边界 | 先迁目录(保持边界),再拆细(一次拆一个 domain) | +| scope 迁移破坏 per-agent 行为 | 每 service 迁到 registerScopedService 后,验证 per-agent 实例化 + 身份注入正确 | +| Agent 收窄破坏 consumer | 最后做,等所有 domain 就位;consumer 已通过 facade 访问,影响可控 | +| 长时间 broken | 每阶段保持 green;不允许跨 phase 的 broken 状态 | + +--- + +## 5. 验收标准(终态) + +- `packages/agent-core/src/services/` 消失(迁到 `/`) +- 20 个 domain 目录全部就位,每个有契约 + 厚实现 + 工具(如有)+ `registerTools` +- scope 机制就位:LifecycleScope + registerScopedService + I*Context + ScopeBuilder + manager +- 所有 service 标注 scope 并通过 registerScopedService 注册 +- `_base/`(di/event/logging/errors)+ `_utils/` 就位;每层只通过 `index.ts` 暴露;lint + fence 强制依赖方向 + barrel-only +- 无旧路径 re-export alias;consumer 全部从 barrel 导入(`grep` 旧路径 0 命中,无例外;无 deep-import) +- Agent 收窄到 3–4 服务 +- `bootstrap.ts::registerAllBuiltinTools` 是唯一工具注册入口 +- 全套 test + typecheck + fence green +- server-e2e 0 diff(如可跑) + +--- + +## 6. 与 di-v3 设计文档的对应 + +本 PLAN 是**执行计划**,目标架构以 `kimi-code-dev-2/plan/` 的 30 篇设计文档为准。本 PLAN 不重复设计文档的内容,只规定**如何从当前状态演进到目标**。 + +执行时每个 phase 需回读对应的 di-v3 设计文档(见 ROADMAP 每步的 `源` 字段)。 + +--- + +## 7. 估算 + +| 阶段 | 规模 | 估时(单人) | +|---|---|---| +| P0 地基 | 小 | 2–3d | +| P1 scope 机制 | 大 | 8–12d | +| P2 基础设施下沉 | 中 | 3–5d | +| P3 domain 目录迁移 | 大 | 10–15d(~10 个 domain) | +| P4 domain 拆分 | 大 | 10–15d(~8 个新 domain) | +| P5 工具按域注册 | 中 | 5–8d | +| P6 service scope 标注 | 大 | 8–12d | +| P7 Agent 收窄 | 中 | 3–5d | +| P8 bootstrap 生命周期 | 中 | 3–5d | +| P9 收尾 | 小 | 2–3d | +| **合计** | | **约 54–81 单人日**(11–16 周) | + +> 注:di-v3 是比 M0–M7(47.5 单人日)更大的重构。3 人并行可压缩到 5–7 周。 diff --git a/plan/README.md b/plan/README.md new file mode 100644 index 000000000..fe41fa288 --- /dev/null +++ b/plan/README.md @@ -0,0 +1,71 @@ +# `packages/agent-core` di-v3 重构 — 变更计划 + +> 下一阶段重构的执行计划。从 `refactor/di-domain-runtime-services`(M0–M7 已完成)演进到 di-v3 目标架构。 + +## 文件 + +- **[`PLAN.md`](./PLAN.md)** — 决策、目标架构、偏离分析、阶段划分、风险、验收标准。 +- **[`ROADMAP.md`](./ROADMAP.md)** — 原子化、有序、可验证的执行步骤(P0–P9,约 60–70 步)。 + +## 一句话 + +M0–M7 是 di-v3 的「地基 + 试点」。本计划把 di-v3 的主体结构(scope 机制、目录重组、工具按域、20 个 domain、基础设施下沉)分 10 个阶段演进到位,复用 M0–M7 已验证的地基,不推倒重来。 + +## 目标架构参考 + +di-v3 目标设计以 `/Users/moonshot/Projects/kimi-code-dev-2/plan/` 的 30 篇设计文档为准。关键文档: + +| 文档 | 重点 | +|---|---| +| `2026.06.22-agent-core-Refactor-Overview.md` | 20 个 domain + 厚实现同居 + 工具注册 bootstrap | +| `2026.06.21-Domain 和 Scope 的划分.md` | 域 × scope 思维框架 | +| `2026.06.22-Scope-Mechanism.md` | scope = 子 InstantiationService 机制(核心) | +| `2026.06.21-Kosong-Kaos-Loop-v2.md` | 三大核心域 + 边界规则 | +| `2026.06.22-Bootstrap-Lifecycle.md` | 5 阶段启动 + shutdown | +| `2026.06.22-Restorable-Lifecycle.md` | Restorable resume | +| `2026.06.22-Infrastructure-To-Base-Utils.md` | `_base/` + `_utils/` 下沉 | +| `2026.06.22-RPC-Event-Domain.md` | RPC + 事件总线 | +| `2026.06.22--Domain.md`(×20) | 各 domain 详细设计 | + +## 阶段速览 + +| 阶段 | 主题 | 估时(单人) | +|---|---|---| +| P0 | 地基与护栏 | 2–3d | +| P1 | scope 机制 | 8–12d | +| P2 | 基础设施下沉 | 3–5d | +| P3 | domain 目录迁移 | 10–15d | +| P4 | domain 拆分(→ 20) | 10–15d | +| P5 | 工具按域注册 | 5–8d | +| P6 | service scope 标注 | 8–12d | +| P7 | Agent 收窄 | 3–5d | +| P8 | bootstrap 生命周期 | 3–5d | +| P9 | 收尾 + 文档 | 2–3d | +| **合计** | | **54–81d(11–16 周)** | + +## 执行方式 + +按 [`plan-lifecycle`](../../.claude/skills/plan-lifecycle/SKILL.md) 流程执行: + +1. **frame** — PLAN.md(已完成)。 +2. **atomic-plan** — ROADMAP.md(已完成)。 +3. **execute** — 按 ROADMAP 逐 phase 执行(worker + reviewer 闭环,每步一个提交 + 一次验证)。 +4. **resync** — 每 phase 结束后对比计划更新现状。 +5. **handoff** — 跨 session 交接。 + +## 起点 + +- 分支:`refactor/di-domain-runtime-services`(或从它新建 `refactor/di-v3`)。 +- 当前状态:M0–M7 已完成(58 提交,48 phase review PASS)。 +- 目标:di-v3(20 个 domain × scope 二维矩阵)。 + +## 验收(终态) + +- `packages/agent-core/src/services/` 消失。 +- 20 个 domain 目录就位,每个有契约 + 厚实现 + 工具(如有)+ `registerTools`。 +- scope 机制就位(LifecycleScope / registerScopedService / I*Context / ScopeBuilder / manager)。 +- 所有 service 标注 scope 并通过 registerScopedService 注册。 +- `_base/` + `_utils/` 就位,lint 强制依赖方向。 +- Agent 收窄到 3–4 服务。 +- `bootstrap.ts::registerAllBuiltinTools` 是唯一工具注册入口。 +- 全套 test + typecheck + fence green。 diff --git a/plan/ROADMAP.md b/plan/ROADMAP.md new file mode 100644 index 000000000..3d88da8b2 --- /dev/null +++ b/plan/ROADMAP.md @@ -0,0 +1,1023 @@ +# ROADMAP — `packages/agent-core` di-v3 重构(下一阶段) + +> 配套 [`PLAN.md`](./PLAN.md)。把 PLAN §3 的 10 个阶段原子化为「一个提交、一次验证」的步骤。 +> 目标架构以 `/Users/moonshot/Projects/kimi-code-dev-2/plan/` 的 30 篇设计文档为准。 +> 起点:`refactor/di-domain-runtime-services`(M0–M7 已完成)。 + +--- + +## Global constraints + +- 每个提交遵循 Conventional Commits。允许 scope:`agent-core` / `server` / `node-sdk` / `test` / `docs` / `changeset`。 +- 每个提交必须通过: + ```bash + pnpm --filter @moonshot-ai/agent-core typecheck + pnpm --filter @moonshot-ai/server typecheck + pnpm --filter @moonshot-ai/agent-core test + pnpm --filter @moonshot-ai/server test + pnpm --filter @moonshot-ai/agent-core test -- dependency-direction + ``` + 改动运行时 wiring 的提交额外跑 `packages/server-e2e` smoke(如可跑;user 已接受无 e2e 风险)。 +- 禁止 `it.skip` / `test.skip`。失败测试修复、删除或拆到后续 step。 +- 用户可见行为变化需要 `.changeset/.md`。 +- decorator 字符串:M0–M7 保留了 `'coreProcessService'` 等,本 ROADMAP 在 P9 才允许改名(需全 consumer audit)。 +- 依赖方向 fence(M7.2 已完整):每步必须保持 green;新增 domain 时同步更新 fence 规则(如需)。 +- **barrel-only 暴露(强制)**:每层(`_base/` / `_utils/` / ``)只通过 `index.ts` 暴露公共面;consumer 从 barrel 导入(`#/` / `#/_base/di` / `@moonshot-ai/agent-core`),禁止 deep-import 子模块(如 `#/_base/di/instantiation`)。 +- **禁止 re-import / re-export shim**:迁移不留旧路径 re-export alias;consumer 直接从新位置的 barrel 导入。因此 P2 / P3 的迁移步骤必须**全量改写 consumer import**,旧路径在**同一步内删除**(不再「deprecated,P9 删除」)。 +- Step ID:`P.`。Phase 对应 PLAN §3 的 P0–P9。 + +--- + +## P0 · 地基与护栏(2–3d) + +### P0.1 test(agent-core): API surface snapshot 扩展 + +- 改: + - `packages/server/test/api-surface.snapshot.test.ts`(extend) + - `packages/node-sdk/test/api-surface.snapshot.test.ts`(extend) +- 实现: + - 扩展 M0.2 的 snapshot,覆盖 di-v3 会影响的 route / export(如新增 domain 的 SDK surface)。 + - 当前 di-v3 还没改 surface,snapshot 是当前状态的基线。 +- 测:snapshot 生成;后续 diff 为 0。 +- 验:`pnpm --filter @moonshot-ai/server test -- api-surface` exit 0;node-sdk 同。 +- 依:— +- 源:M0.2 +- 耗:0.5d + +### P0.2 test(agent-core): dependency-direction fence di-v3 扩展 + +- 改: + - `packages/agent-core/test/dependency-direction.test.ts`(extend) +- 实现: + - 在 M7.2 的 3 条规则上,加入 di-v3 目标目录结构的规则预留: + - `_utils/` ← `_base/` ← `domains/`(lint 强制;test 先预留,P2 落地后启用)。 + - `agent-core//` 之间不互引 impl(通过接口 + IServiceAccessor)。 + - 当前 di-v3 目录还不存在,新规则先用 fixture 验证(正例:现状 0 违例;反例:fixture 违例报错)。 +- 测:≥ 2 个新 case(di-v3 目录规则的 fixture)。 +- 验:`pnpm --filter @moonshot-ai/agent-core test -- dependency-direction` exit 0;≥ 9 cases(M7.2 的 7 + 新 2)。 +- 依:— +- 源:M7.2、`services/AGENTS.md` +- 耗:1d + +### P0.3 docs: scope 机制设计定稿 + +- 改: + - `.agents/skills/service-skill/explanation/scope-mechanism.md`(new) +- 实现: + - 把 di-v3 的 scope 机制(LifecycleScope / registerScopedService / I*Context / ScopeBuilder / manager 模式)整理成一份中文定稿,作为 P1 的实施依据。 + - 引用 `kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md`。 +- 测:—(纯文档) +- 验:阅读定稿;确认 P1 可据此实施。 +- 依:— +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` +- 耗:1d + +**P0 acceptance:** fence 扩展 + snapshot 守住边界;scope 机制设计定稿;P1 可启动。 + +--- + +## P1 · scope 机制(8–12d) + +> 前置:P0.3 scope 设计定稿完成。本阶段落地 di-v3 的核心机制。 + +### P1.1 feat(agent-core): LifecycleScope enum + ScopeRegistry + +- 改: + - `packages/agent-core/src/scope/lifecycle.ts`(new,LifecycleScope enum) + - `packages/agent-core/src/scope/registry.ts`(new,ScopeRegistry + registerScopedService) + - `packages/agent-core/test/scope/registry.test.ts`(new) +- 实现: + - `LifecycleScope { Core, Session, Agent, Turn, ToolCall }`。 + - `ScopeRegistry`:process-wide `Map>`。 + - `registerScopedService(scope, id, descriptor, type, options?)`:写入 registry(lazy,不实例化);`registerScopedService(Core, ...)` 是 `registerSingleton` 的别名;duplicate 时 last-write-wins + warn,`{ replace: true }` 静默。 + - 注册必须在第一次 `scopeBuilder.build()` 前;之后注册 warn + 忽略。 +- 测:register / get / duplicate warn / replace / Core 别名 / 注册时机。 +- 验:≥ 6 cases。 +- 依:— +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §2–3 +- 耗:1.5d + +### P1.2 feat(agent-core): scope identity contexts + +- 改: + - `packages/agent-core/src/scope/context/sessionContext.ts`(new,ISessionContext) + - `packages/agent-core/src/scope/context/agentContext.ts`(new,IAgentContext) + - `packages/agent-core/src/scope/context/turnContext.ts`(new,ITurnContext) + - `packages/agent-core/src/scope/context/toolCallContext.ts`(new,IToolCallContext) + - `packages/agent-core/test/scope/context.test.ts`(new) +- 实现: + - `ISessionContext { id, parentId?: undefined, abortSignal, executionScope }`。 + - `IAgentContext { id, parentId: sessionId, abortSignal, executionScope }`。 + - `ITurnContext { id, parentId: agentId, abortSignal, executionScope }`。 + - `IToolCallContext { id, parentId: turnId, abortSignal, executionScope }`。 + - 每个 context 是 decorator(createDecorator),service ctor 通过 `@IAgentContext` 注入。 +- 测:每个 context 的字段 + decorator 解析。 +- 验:≥ 4 cases。 +- 依:P1.1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §4 +- 耗:1d + +### P1.3 feat(agent-core): IScopeHandle + ScopeBuilder + +- 改: + - `packages/agent-core/src/scope/handle.ts`(new,IScopeHandle) + - `packages/agent-core/src/scope/builder.ts`(new,SessionScopeBuilder / AgentScopeBuilder / TurnScopeBuilder) + - `packages/agent-core/test/scope/builder.test.ts`(new) +- 实现: + - `IScopeHandle { id, scope, accessor, onWillDispose, onDidDispose, dispose() }`。 + - `ScopeBuilder` 4 步 pipeline:① inject scope identity context;② install Pattern-1 statically registered services as SyncDescriptors;③ reserved build hook(Pattern 2,未启用);④ reserved post-build interceptor(未启用);然后 `parent.createChild(collection)` 返回 handle。 + - `dispose()`:倒序 dispose child services。 +- 测:builder 4 步 / handle dispose / 倒序 dispose / identity context 注入。 +- 验:≥ 6 cases。 +- 依:P1.1, P1.2 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §5–6 +- 耗:2d + +### P1.4 feat(agent-core): manager service pattern + +- 改: + - `packages/agent-core/src/scope/manager.ts`(new,manager service 基类 / 约定) + - `packages/agent-core/test/scope/manager.test.ts`(new) +- 实现: + - manager service 住父 scope,是子 scope 的唯一上行事件发布点。 + - manager 通过 `child.accessor.get(...)` 主动 attach 子 scope 事件源,re-emit 为 collection-view 事件(加 child id)。 + - `dispose()` 配对:`try { await childScope.dispose() } finally { manager.onDidXxx.fire(); eventBus.publish(...) }`。 + - 子 scope service 不反向调用 manager 的写方法。 +- 测:manager attach / onDid* fire / dispose 配对 / 子不反向写。 +- 验:≥ 5 cases。 +- 依:P1.3 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §7–8 +- 耗:1.5d + +### P1.5 feat(agent-core): scope 试点 — ILogService 迁到 registerScopedService + +- 改: + - `packages/agent-core/src/logging/logService.ts`(new or extend,ILogService) + - `packages/agent-core/src/logging/logServiceImpl.ts`(new or extend) + - `packages/agent-core/test/logging/logService.test.ts`(new or extend) +- 实现: + - 把 `ILogService`(Core scope)从 `registerSingleton` 迁到 `registerScopedService(Core, ILogService, ...)`。 + - ctor 不再依赖 id(Core scope 无身份)。 + - 验证 ScopeBuilder.build(Core) 能解析 ILogService。 +- 测:ILogService 通过 registerScopedService 注册 + 解析 + 行为不变。 +- 验:≥ 3 cases;全套 test green。 +- 依:P1.1, P1.3 +- 源:`kimi-code-dev-2/plan/2026.06.22-Logging-Domain.md` +- 耗:1d + +### P1.6 feat(agent-core): scope 机制 barrel + index + +- 改: + - `packages/agent-core/src/scope/index.ts`(new,barrel) + - `packages/agent-core/src/index.ts`(export scope) +- 实现: + - `scope/index.ts` 导出 LifecycleScope / registerScopedService / I*Context / ScopeBuilder / IScopeHandle。 + - 顶层 `index.ts` export scope(触发 registerScopedService side effects)。 +- 测:barrel export 完整。 +- 验:typecheck green;scope API 可从 `@moonshot-ai/agent-core` 导入。 +- 依:P1.1–P1.5 +- 源:— +- 耗:0.5d + +**P1 acceptance:** scope 机制完整可用;ILogService 试点通过 registerScopedService 注册;P2 / P3 可启动。 + +--- + +## P2 · 基础设施下沉(3–5d) + +> 前置:P1 scope 机制完成。本阶段把 di / event / logging / errors / utils 沉到 `_base/` + `_utils/`。**每层只通过 `index.ts` 暴露;迁移不留 re-export alias;consumer 全量改写为 barrel 导入,旧路径同一步内删除。** + +### P2.1 refactor(agent-core): sink di/ → _base/di/ + +- 改: + - `packages/agent-core/src/_base/di/`(new,从 `di/` 迁入) + - `packages/agent-core/src/di/`(delete,**不留 re-export alias**) + - 所有 consumer import 全量改写为 `#/_base/di` barrel(bare `../di` / `#/di` 与 deep `../di/` / `#/di/` 全部收敛到 barrel) +- 实现: + - 把 `di/` 的全部内容(instantiation / descriptors / extensions / serviceCollection / instantiationService / lifecycle / errors / graph / test / testInstantiationService / util)迁到 `_base/di/`。 + - 确保 `_base/di/index.ts` 是唯一公共面,导出全部需要的符号(含 test 工具如 `TestInstantiationService` / `createServices`,以便测试从 barrel 导入)。 + - 全量改写所有 consumer import 为 `#/_base/di` barrel(或相对 `../_base/di` / `../../_base/di`);不再 deep-import 子模块。 + - 删除 `di/`(不留 alias)。同步处理 `package.json` `exports` 中 `@moonshot-ai/agent-core/di/*` 子路径(改为指向 `_base/di` 或移除,外部统一从包根 barrel 导入)。 +- 测:typecheck green;全套 test green;DI 行为不变。 +- 验: + - `grep -rEn "from ['\"][^'\"]*?/di['\"]" packages/agent-core/src packages/agent-core/test` 0 命中(无 alias,无例外)。 + - `grep -rEn "#/(di|_base/di)/" packages/agent-core/src packages/agent-core/test` 0 命中(禁止 deep-import 子模块)。 +- 依:P1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Infrastructure-To-Base-Utils.md` §1 +- 耗:1d + +### P2.2 refactor(agent-core): sink base/common/event → _base/event/ + +- 改: + - `packages/agent-core/src/_base/event/`(new,从 `base/common/event.ts` 迁入,含 `index.ts` barrel) + - `packages/agent-core/src/base/common/event.ts`(delete,**不留 alias**) + - consumer import 全量改写为 `#/_base/event` barrel +- 实现: + - 把 `base/common/event.ts`(Event / Emitter)迁到 `_base/event/`,新增 `_base/event/index.ts` barrel 暴露公共面。 + - 内部依赖(如对 DI 的引用)改为从 `_base/di` barrel 导入(`../di`),不 deep-import `../di/lifecycle`。 + - 全量改写所有 consumer import 为 `#/_base/event` barrel;删除 `base/common/event.ts`(不留 alias)。如 `base/common/` 因迁出而变空,一并清理。 +- 测:typecheck green;Event / Emitter 行为不变。 +- 验: + - `grep -rEn "base/common/event" packages/agent-core/src packages/agent-core/test` 0 命中(无 alias)。 + - `grep -rEn "#/_base/event/" packages/agent-core/src packages/agent-core/test` 0 命中(禁止 deep-import)。 +- 依:P2.1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Infrastructure-To-Base-Utils.md` §2 +- 耗:0.5d + +### P2.3 refactor(agent-core): sink logging/ → _base/logging/ + +- 改: + - `packages/agent-core/src/_base/logging/`(new,无 DI 的 Logger / RootLogger / sinks) + - `packages/agent-core/src/logging/`(保留 ILogService / ISessionLogService,引用 `_base/logging`) +- 实现: + - 把无 DI 的 `Logger` / `RootLogger` / sinks 沉到 `_base/logging/`。 + - `logging/`(DI service:ILogService / ISessionLogService)保留,引用 `_base/logging`。 +- 测:typecheck green;logging 行为不变。 +- 验:`grep "from ['\"][^'\"]*?/logging['\"]" packages/agent-core/src` 仅命中 `logging/`(DI service)和 `_base/logging`。 +- 依:P2.2 +- 源:`kimi-code-dev-2/plan/2026.06.22-Infrastructure-To-Base-Utils.md` §3、`2026.06.22-Logging-Domain.md` +- 耗:1d + +### P2.4 refactor(agent-core): sink errors/unexpectedError → _base/errors/ + +- 改: + - `packages/agent-core/src/_base/errors/`(new,从 `errors/unexpectedError.ts` 迁入,含 `index.ts` barrel) + - `packages/agent-core/src/errors/unexpectedError.ts`(delete,**不留 alias**) + - consumer import 全量改写为 `#/_base/errors` barrel +- 实现: + - 把 `errors/unexpectedError.ts` 沉到 `_base/errors/`,新增 `_base/errors/index.ts` barrel。 + - 更新 `_base/di/lifecycle.ts` 等内部引用为从 `#/_base/errors` barrel 导入。 + - Kimi 专属 error 类(`errors/` 其余)保留在 `errors/`。 + - 删除 `errors/unexpectedError.ts`(不留 alias)。 +- 测:typecheck green;unexpectedError 行为不变。 +- 验: + - `grep -rEn "errors/unexpectedError" packages/agent-core/src packages/agent-core/test` 0 命中(无 alias)。 + - `grep -rEn "#/_base/errors/" packages/agent-core/src packages/agent-core/test` 0 命中(禁止 deep-import)。 +- 依:P2.3 +- 源:`kimi-code-dev-2/plan/2026.06.22-Infrastructure-To-Base-Utils.md` §4 +- 耗:0.5d + +### P2.5 refactor(agent-core): sink utils/ → _utils/ + +- 改: + - `packages/agent-core/src/_utils/`(new,从 `utils/` 迁入;每个子目录含 `index.ts` barrel) + - `packages/agent-core/src/utils/`(delete,**不留 alias**) + - consumer import 全量改写为 `#/_utils/` barrel +- 实现: + - 把 `utils/` 的纯函数(abort / fs / hero-slug / workdir-slug / xml-escape / render-prompt / types / per-id-json-store / proxy)沉到 `_utils/{abort,fs,slug,xml,template,types,persistence,net}/`,每个子目录含 `index.ts` barrel。 + - `utils/{tokens,completion-budget}.ts` 不沉(依赖 kosong types,搬到 `kosong/`,P3 处理)。 + - 全量改写 consumer import 为 `#/_utils/` barrel;删除 `utils/`(不留 alias)。 +- 测:typecheck green;utils 行为不变。 +- 验: + - `grep -rEn "from ['\"][^'\"]*?/utils/" packages/agent-core/src packages/agent-core/test` 0 命中(无 alias)。 + - `grep -rEn "#/_utils/[a-z]+/" packages/agent-core/src packages/agent-core/test` 0 命中(禁止 deep-import 子模块;barrel 为 `#/_utils/`)。 +- 依:P2.4 +- 源:`kimi-code-dev-2/plan/2026.06.22-Infrastructure-To-Base-Utils.md` §5 +- 耗:1.5d + +### P2.6 lint: enforce _utils ← _base ← domains + +- 改: + - `.oxlintrc.json`(或 eslint config,加 no-restricted-paths) + - `packages/agent-core/test/dependency-direction.test.ts`(extend,启用 P0.2 预留的 _utils ← _base ← domains 规则) +- 实现: + - lint 强制:`_utils/` 不 import `_base/` / domains;`_base/` 不 import domains;domains 不 import `_base/`/`_utils/` 的内部(仅通过 barrel)。 + - dependency-direction fence 启用 _utils ← _base ← domains 规则。 +- 测:lint green;fence 启用新规则;现状 0 违例。 +- 验:lint + fence green。 +- 依:P2.5 +- 源:`kimi-code-dev-2/plan/2026.06.22-Infrastructure-To-Base-Utils.md` §6 +- 耗:0.5d + +**P2 acceptance:** di / event / logging / errors / utils 沉到 `_base/` + `_utils/`;每层只通过 `index.ts` 暴露;无旧路径 re-export alias;consumer 全部从 barrel 导入;lint + fence 强制依赖方向 + barrel-only;P3 可启动。 + +--- + +## P3 · domain 目录迁移(现有 domain)(10–15d) + +> 前置:P2 基础设施下沉完成。本阶段把现有 `services//` 逐 domain 迁到 `agent-core//`(契约 + 厚实现 + 工具同居)。 +> 每次迁一个 domain,独立验证。 + +### P3.0 docs: domain 迁移规范 + +- 改: + - `.agents/skills/service-skill/explanation/domain-migration.md`(new) +- 实现: + - 规定每个 domain 迁移的步骤:① 建 `agent-core//`;② 移契约(`.ts`);③ 移厚实现(`Service.ts`);④ 移工具(`/tools/`);⑤ 更新 import;⑥ 写 `registerServices` + `registerTools`;⑦ 验证。 +- 测:—(纯文档) +- 验:阅读规范;P3.x 据此实施。 +- 依:P2 +- 源:PLAN §2.3 +- 耗:0.5d + +### P3.1 refactor(agent-core): migrate session domain → session/ + +- 改: + - `packages/agent-core/src/session/`(extend:迁入 `services/session/` 的契约 + impl) + - `packages/agent-core/src/services/session/`(delete) + - import 更新 +- 实现: + - 把 `services/session/` 的 `session.ts`(契约)+ `sessionService.ts` / `sessionQueryService.ts` / `sessionRuntimeService.ts` / `sessionIndex.ts`(impl)迁到 `session/`(已有 SessionHost / SessionRepository)。 + - 更新 import(`services/session` → `session`)。 + - `session/index.ts` export ISessionService / ISessionQueryService / ISessionRuntimeService / ISessionIndex / SessionRepository / SessionHost。 +- 测:session 行为不变;server session route 0 diff。 +- 验:全套 test green;fence green;`grep "services/session" packages` 0 命中。 +- 依:P3.0 +- 源:`kimi-code-dev-2/plan/2026.06.22-Session-Domain.md` +- 耗:2d + +### P3.2 refactor(agent-core): migrate workspace domain → workspace/ + +- 改: + - `packages/agent-core/src/workspace/`(new,从 `services/workspace/` 迁入) + - `packages/agent-core/src/services/workspace/`(delete) + - import 更新 +- 实现: + - 把 `services/workspace/` 的 `workspace.ts` / `workspaceService.ts` / `workspaceRegistry.ts` / `workspaceFs.ts` 迁到 `workspace/`。 + - 更新 import。 +- 测:workspace 行为不变。 +- 验:全套 test green;`grep "services/workspace" packages` 0 命中。 +- 依:P3.1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Workspace-Domain.md` +- 耗:1d + +### P3.3 refactor(agent-core): migrate mcp domain → mcp/ + +- 改: + - `packages/agent-core/src/mcp/`(extend:迁入 `services/mcp/`) + - `packages/agent-core/src/services/mcp/`(delete) + - import 更新 +- 实现: + - 把 `services/mcp/` 的 `mcp.ts` / `mcpService.ts` 迁到 `mcp/`(已有 connection-manager)。 + - 更新 import。 +- 测:mcp 行为不变。 +- 验:全套 test green;`grep "services/mcp" packages` 0 命中。 +- 依:P3.2 +- 源:`kimi-code-dev-2/plan/2026.06.22-MCP-Domain.md` +- 耗:1d + +### P3.4 refactor(agent-core): migrate skill domain → skill/ + +- 改: + - `packages/agent-core/src/skill/`(extend:迁入 `services/skill/`) + - `packages/agent-core/src/services/skill/`(delete) + - import 更新 +- 实现: + - 把 `services/skill/` 的 `skill.ts` / `skillService.ts` 迁到 `skill/`(已有 registry / agent skill)。 + - 更新 import。 +- 测:skill 行为不变。 +- 验:全套 test green;`grep "services/skill" packages` 0 命中。 +- 依:P3.3 +- 源:`kimi-code-dev-2/plan/2026.06.22-Skill-Domain.md` +- 耗:1d + +### P3.5 refactor(agent-core): migrate terminal domain → kaos/terminal + +- 改: + - `packages/agent-core/src/kaos/terminal.ts`(new,从 `services/terminal/` 迁入) + - `packages/agent-core/src/services/terminal/`(delete) + - import 更新 +- 实现: + - di-v3 把 terminal 归入 Kaos 域(执行环境)。把 `services/terminal/` 的 `terminal.ts` / `terminalService.ts` 迁到 `kaos/terminal.ts`(或 `kaos/` 下)。 + - 更新 import。 +- 测:terminal 行为不变。 +- 验:全套 test green;`grep "services/terminal" packages` 0 命中。 +- 依:P3.4 +- 源:`kimi-code-dev-2/plan/2026.06.21-Kosong-Kaos-Loop-v2.md` §2 +- 耗:1d + +### P3.6 refactor(agent-core): migrate config domain → config/ + +- 改: + - `packages/agent-core/src/config/`(extend:迁入 `services/config/`) + - `packages/agent-core/src/services/config/`(delete) + - import 更新 +- 实现: + - 把 `services/config/` 的 `config.ts` / `configService.ts` 迁到 `config/`。 + - 更新 import。 +- 测:config 行为不变。 +- 验:全套 test green;`grep "services/config" packages` 0 命中。 +- 依:P3.5 +- 源:— +- 耗:0.5d + +### P3.7 refactor(agent-core): migrate message + tool + modelCatalog + fs + fileStore + approval + question + environment + logger + event + authSummary + oauth + auth + task + prompt + agentHost + userInteraction + +- 改: + - 每个 `services//` → `agent-core//`(或对应 runtime 位置) + - import 更新 +- 实现: + - 逐个迁移剩余的 `services//` 到对应位置(message → message/ 或 loop/;tool → loop/;modelCatalog → kosong/;fs → kaos/;fileStore → fileStore/ 或 kaos/;approval/question → permission/;environment → environment/ 或 kaos/;logger → logging/;event → rpc/;authSummary/oauth/auth → kosong/;task → background/;prompt → session/;agentHost/userInteraction → 删除或合并)。 + - 每个 domain 独立验证。 +- 测:每个 domain 行为不变。 +- 验:全套 test green;`grep "services/" packages/agent-core/src` 0 命中(`services/` 消失)。 +- 依:P3.6 +- 源:各 di-v3 domain 文档 +- 耗:3–5d + +### P3.8 refactor(agent-core): delete services/ barrel + verify + +- 改: + - `packages/agent-core/src/services/`(delete,已空) + - `packages/agent-core/src/index.ts`(移除 services barrel export) +- 实现: + - `services/` 已空(所有 domain 迁完),删除。 + - `index.ts` 不再 export services barrel;改为 export 各 domain barrel。 +- 测:typecheck green;全套 test green;`services/` 不存在。 +- 验:`ls packages/agent-core/src/services` 不存在;`grep "from ['\"][^'\"]*?/services" packages` 0 命中。 +- 依:P3.7 +- 源:PLAN §2.1 +- 耗:0.5d + +**P3 acceptance:** `services/` 消失;所有现有 domain 迁到 `agent-core//`;import 全部更新;P4 / P5 可启动。 + +--- + +## P4 · domain 拆分(→ 20)(10–15d) + +> 前置:P3 现有 domain 迁到新结构。本阶段把大的 domain 拆成 di-v3 的 20 个细粒度 domain。 +> 每个新 domain:契约 + 厚实现 + scope 标注 + 工具(如有)+ 概念定稿。 + +### P4.1 refactor(agent-core): extract Cron domain + +- 改: + - `packages/agent-core/src/cron/`(new,从 `agent/cron/` + `tools/cron/` 合并) + - `packages/agent-core/src/agent/cron/`(delete) + - `packages/agent-core/src/tools/cron/`(delete) + - import 更新 +- 实现: + - 把 `agent/cron/`(ICronService + CronManager)+ `tools/cron/`(scheduler / persist / clock / jitter / 3 个 cron 工具)合并到 `cron/`。 + - `cron/cron.ts`(ICronService)+ `cron/cronService.ts`(厚实现,CronManager 改名)+ `cron/scheduler.ts` + `cron/persist.ts` + `cron/expr.ts` + `cron/jitter.ts` + `cron/clock.ts` + `cron/fireXml.ts` + `cron/tools/`(CronCreate / CronList / CronDelete)。 + - `ICronService` 标 Agent scope(per main agent;sub-agent ctor no-op)。 + - 写 `registerCronTools(accessor)`。 + - 概念定稿 `.agents/skills/service-skill/explanation/domains/cron.md`(从 M4.6 task.md 拆出)。 +- 测:cron 行为不变;3 个 cron 工具注册正常。 +- 验:全套 test green;`grep "agent/cron\|tools/cron" packages` 0 命中。 +- 依:P3.8 +- 源:`kimi-code-dev-2/plan/2026.06.22-Cron-Domain.md` +- 耗:2d + +### P4.2 refactor(agent-core): extract Background domain + +- 改: + - `packages/agent-core/src/background/`(new,从 `agent/background/` + `tools/background/` 合并) + - `packages/agent-core/src/agent/background/`(delete) + - `packages/agent-core/src/tools/background/`(delete) + - import 更新 +- 实现: + - 把 `agent/background/`(IBackgroundService + BackgroundManager + 3 种 task)+ `tools/background/`(3 个 task 工具 + format)合并到 `background/`。 + - `background/background.ts` + `background/backgroundService.ts` + `background/task.ts`(基类)+ `background/processTask.ts` + `background/agentTask.ts` + `background/questionTask.ts` + `background/persist.ts` + `background/tools/`。 + - `IBackgroundService` 标 Agent scope。 + - 写 `registerBackgroundTools(accessor)`。 + - 概念定稿 `background.md`(从 task.md 拆出)。 +- 测:background 行为不变;3 个 task 工具注册正常。 +- 验:全套 test green;`grep "agent/background\|tools/background" packages` 0 命中。 +- 依:P4.1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Background-Domain.md` +- 耗:2d + +### P4.3 refactor(agent-core): extract Goal domain + +- 改: + - `packages/agent-core/src/goal/`(new,从 `agent/goal/` + `tools/builtin/goal/` 合并) + - `packages/agent-core/src/agent/goal/`(delete) + - `packages/agent-core/src/tools/builtin/goal/`(delete) + - import 更新 +- 实现: + - 把 `agent/goal/`(IGoalService + GoalMode)+ `tools/builtin/goal/`(4 个 goal 工具)合并到 `goal/`。 + - `goal/goal.ts` + `goal/goalService.ts` + `goal/budget.ts` + `goal/actor.ts` + `goal/injector.ts` + `goal/outcomePrompts.ts` + `goal/tools/`。 + - `IGoalService` 标 Agent scope。 + - 写 `registerGoalTools(accessor)`。 + - 概念定稿 `goal.md`(从 task.md 拆出)。 +- 测:goal 行为不变;4 个 goal 工具注册正常。 +- 验:全套 test green;`grep "agent/goal\|tools/builtin/goal" packages` 0 命中。 +- 依:P4.2 +- 源:`kimi-code-dev-2/plan/2026.06.22-Goal-Domain.md` +- 耗:1.5d + +### P4.4 refactor(agent-core): extract Swarm domain + +- 改: + - `packages/agent-core/src/swarm/`(new,从 `agent/swarm/` + `tools/builtin/collaboration/agent-swarm.ts` 合并) + - `packages/agent-core/src/agent/swarm/`(delete) + - `packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts`(delete) + - import 更新 +- 实现: + - 把 `agent/swarm/`(ISwarmService + SwarmMode)+ `agent-swarm.ts` 工具合并到 `swarm/`。 + - `swarm/swarm.ts` + `swarm/swarmService.ts` + `swarm/injector.ts` + `swarm/batch.ts` + `swarm/tools/`。 + - `ISwarmService` 标 Agent scope。 + - 写 `registerSwarmTools(accessor)`。 + - 概念定稿 `swarm.md`。 +- 测:swarm 行为不变;AgentSwarmTool 注册正常。 +- 验:全套 test green;`grep "agent/swarm\|agent-swarm" packages` 0 命中。 +- 依:P4.3 +- 源:`kimi-code-dev-2/plan/2026.06.22-Swarm-Domain.md` +- 耗:1.5d + +### P4.5 refactor(agent-core): extract Records domain + +- 改: + - `packages/agent-core/src/records/`(new,从 `agent/records/` + `agent/replay/` 合并) + - `packages/agent-core/src/agent/records/`(delete) + - `packages/agent-core/src/agent/replay/`(delete) + - import 更新 +- 实现: + - 把 `agent/records/`(IRecordsService + RecordsService + BlobStore)+ `agent/replay/`(IReplayService + ReplayBuilder)合并到 `records/`。 + - `records/records.ts` + `records/recordsService.ts` + `records/replay.ts` + `records/replayService.ts` + `records/types.ts` + `records/persistence.ts` + `records/blobStore.ts`。 + - `IRecordsService` / `IReplayService` 标 Agent scope;BlobStore 绑 `/blobs/`。 + - 概念定稿 `records.md`。 +- 测:records / replay 行为不变。 +- 验:全套 test green;`grep "agent/records\|agent/replay" packages` 0 命中。 +- 依:P4.4 +- 源:`kimi-code-dev-2/plan/2026.06.22-Records-Domain.md` +- 耗:1.5d + +### P4.6 refactor(agent-core): extract Context domain + +- 改: + - `packages/agent-core/src/context/`(new,从 `agent/context/` + `agent/compaction/` + `agent/injection/` 合并) + - `packages/agent-core/src/agent/context/`(delete) + - `packages/agent-core/src/agent/compaction/`(delete) + - `packages/agent-core/src/agent/injection/`(delete,injector 散到各源域) + - import 更新 +- 实现: + - 把 `agent/context/`(IContextService + ContextMemory + Projector)+ `agent/compaction/`(ICompactionService + Full/Micro)+ `agent/injection/`(IInjectionService orchestrator)合并到 `context/`。 + - `context/contextMemory.ts` + `context/contextMemoryService.ts` + `context/projector.ts` + `context/notificationXml.ts` + `context/compaction.ts` + `context/compactionService.ts` + `context/fullCompaction.ts` + `context/microCompaction.ts` + `context/compactionStrategy.ts` + `context/renderMessages.ts` + `context/injection.ts` + `context/injectionService.ts` + `context/injector.ts`。 + - 三个 service(IContextMemoryService / ICompactionService / IInjectionService)标 Agent scope。 + - 具体 injector(GoalInjector / PlanModeInjector / TodoListReminderInjector / SwarmInjector / PluginSessionStartInjector)散到各源域,self-register。 + - 概念定稿 `context.md`(从 M4.4 message-context.md 拆出)。 +- 测:context / compaction / injection 行为不变。 +- 验:全套 test green;`grep "agent/context\|agent/compaction\|agent/injection" packages` 0 命中。 +- 依:P4.5 +- 源:`kimi-code-dev-2/plan/2026.06.22-Context-Domain.md` +- 耗:2d + +### P4.7 refactor(agent-core): extract Todo domain + +- 改: + - `packages/agent-core/src/todo/`(new,从 `tools/builtin/state/todo-list.ts` + `agent/injection/todo-list.ts` 合并) + - `packages/agent-core/src/tools/builtin/state/todo-list.ts`(delete) + - `packages/agent-core/src/agent/injection/todo-list.ts`(delete) + - import 更新 +- 实现: + - 把 `todo-list.ts`(TodoList state + 工具)+ `todo-list.ts` injector 合并到 `todo/`。 + - `todo/todo.ts`(ITodoService)+ `todo/todoService.ts`(TodoList state)+ `todo/injector.ts`(TodoListReminderInjector)+ `todo/render.ts` + `todo/tools/todoList.ts`。 + - `ITodoService` 标 Agent scope。 + - 写 `registerTodoTools(accessor)`。 + - 概念定稿 `todo.md`。 +- 测:todo 行为不变;TodoListTool 注册正常。 +- 验:全套 test green;`grep "todo-list" packages` 0 命中。 +- 依:P4.6 +- 源:`kimi-code-dev-2/plan/2026.06.22-Todo-Domain.md` +- 耗:1d + +### P4.8 refactor(agent-core): extract Web domain + +- 改: + - `packages/agent-core/src/web/`(new,从 `tools/builtin/web/` + `tools/providers/` 合并) + - `packages/agent-core/src/tools/builtin/web/`(delete) + - `packages/agent-core/src/tools/providers/`(delete) + - import 更新 +- 实现: + - 把 `tools/builtin/web/`(FetchURL / WebSearch 工具)+ `tools/providers/`(local / moonshot fetch-url / moonshot web-search)合并到 `web/`。 + - `web/fetcher.ts`(IUrlFetcherProviderService)+ `web/webSearch.ts`(IWebSearchProviderService)+ `web/providers/`(localFetchUrl / moonshotFetchUrl / moonshotWebSearch)+ `web/tools/`(fetchUrl / webSearch)。 + - 两个 service 标 Core scope(provider 实例也 Core scope)。 + - 写 `registerWebTools(accessor)`。 + - 概念定稿 `web.md`。 +- 测:web 行为不变;2 个 web 工具注册正常。 +- 验:全套 test green;`grep "tools/builtin/web\|tools/providers" packages` 0 命中。 +- 依:P4.7 +- 源:`kimi-code-dev-2/plan/2026.06.22-Web-Domain.md` +- 耗:1.5d + +### P4.9 refactor(agent-core): extract Hook + Profile + Permission + Kosong + Kaos + Loop domain 收尾 + +- 改: + - `packages/agent-core/src/hook/`(new or extend) + - `packages/agent-core/src/profile/`(extend) + - `packages/agent-core/src/permission/`(extend) + - `packages/agent-core/src/kosong/`(new) + - `packages/agent-core/src/kaos/`(new) + - `packages/agent-core/src/loop/`(extend) + - import 更新 +- 实现: + - Hook:从 `session/hooks/` 抽到 `hook/`(IHookRegistry / IHookEngine / IHookRunnerService)。 + - Profile:从 `profile/` + `agent/profile/` + `agent/config/`(profileRef 部分)整合到 `profile/`(IProfileLoaderService / IProfileResolverService / IProfileCatalogService / ISystemPromptRendererService / IAgentProfileService)。 + - Permission:从 `agent/permission/` + `agent/plan/` + `services/approval/` + `services/question/` 整合到 `permission/`(IPermissionRegistry / IPermissionPolicyChain / IApprovalService / IQuestionService / IAgentModeService / PlanMode)。 + - Kosong:从 `services/modelCatalog/` + `services/oauth/` + `services/auth/` + `agent/usage/` + `agent/turn/kosong-llm.ts` + `session/provider-manager.ts` 整合到 `kosong/`(IModelCatalogService / IChatProviderService / IModelAuthService / ITokenizerService / IUsageHistoryService / ISessionUsageView / IAgentModelSelectionService / KosongLLM)。 + - Kaos:从 `services/fs/` + `services/terminal/`(P3.5 已迁 kaos/terminal)+ `services/environment/` + `tools/builtin/{shell,file}` + `tools/policies/path-access.ts` 整合到 `kaos/`(IKaosRegistryService / IExecutionScope / IPathSafetyService / IFsService / IProcessService / ITerminalService + Kaos 工具)。 + - Loop:从 `loop/` + `agent/turn/` + `agent/tool/` + `services/task/` + `services/tool/` + `services/message/` 整合到 `loop/`(ITurnService / IToolService / TurnFlow / ToolScheduler / TranscriptSink / LiveEventBus)。 + - 每个 domain 独立验证;概念定稿。 +- 测:每个 domain 行为不变。 +- 验:全套 test green;import 全部更新。 +- 依:P4.8 +- 源:各 di-v3 domain 文档 +- 耗:3–5d + +**P4 acceptance:** 20 个 domain 全部就位;每个有契约 + 厚实现 + scope 标注 + 工具(如有)+ 概念定稿;P5 / P6 可启动。 + +--- + +## P5 · 工具按域注册(5–8d) + +> 前置:P3 / P4 domain 目录迁移 + 拆分完成。本阶段把工具注册从集中式改为按域 `registerTools`。 + +### P5.1 feat(agent-core): registerKaosTools + registerWebTools + +- 改: + - `packages/agent-core/src/kaos/index.ts`(extend,registerKaosTools) + - `packages/agent-core/src/web/index.ts`(extend,registerWebTools) + - `packages/agent-core/src/bootstrap.ts`(new,registerAllBuiltinTools) +- 实现: + - `registerKaosTools(accessor)`:注册 Bash / Read / Write / Edit / Glob / Grep / ReadMediaFile 工具到 IToolService。 + - `registerWebTools(accessor)`:注册 FetchURL / WebSearch 工具。 + - `bootstrap.ts::registerAllBuiltinTools(accessor)`:调所有 register*Tools。 +- 测:Kaos / Web 工具注册正常;IToolService 列出所有工具。 +- 验:全套 test green。 +- 依:P4 +- 源:`kimi-code-dev-2/plan/2026.06.22-agent-core-Refactor-Overview.md` §六 +- 耗:1d + +### P5.2 feat(agent-core): registerCronTools + registerBackgroundTools + registerGoalTools + +- 改: + - `packages/agent-core/src/cron/index.ts`(registerCronTools) + - `packages/agent-core/src/background/index.ts`(registerBackgroundTools) + - `packages/agent-core/src/goal/index.ts`(registerGoalTools) + - `packages/agent-core/src/bootstrap.ts`(extend) +- 实现: + - `registerCronTools`:CronCreate / CronList / CronDelete。 + - `registerBackgroundTools`:TaskList / TaskOutput / TaskStop。 + - `registerGoalTools`:CreateGoal / GetGoal / UpdateGoal / SetGoalBudget。 +- 测:3 域工具注册正常。 +- 验:全套 test green。 +- 依:P5.1 +- 源:各 domain 文档 +- 耗:1d + +### P5.3 feat(agent-core): registerSwarmTools + registerAgentTools + registerPermissionTools + +- 改: + - `packages/agent-core/src/swarm/index.ts`(registerSwarmTools) + - `packages/agent-core/src/agent/index.ts`(registerAgentTools) + - `packages/agent-core/src/permission/index.ts`(registerPermissionTools) + - `packages/agent-core/src/bootstrap.ts`(extend) +- 实现: + - `registerSwarmTools`:AgentSwarmTool。 + - `registerAgentTools`:AgentTool(spawn single subagent)。 + - `registerPermissionTools`:EnterPlanMode / ExitPlanMode / AskUserQuestion。 +- 测:3 域工具注册正常。 +- 验:全套 test green。 +- 依:P5.2 +- 源:各 domain 文档 +- 耗:1d + +### P5.4 feat(agent-core): registerSkillTools + registerMcpTools + registerTodoTools + +- 改: + - `packages/agent-core/src/skill/index.ts`(registerSkillTools) + - `packages/agent-core/src/mcp/index.ts`(registerMcpTools) + - `packages/agent-core/src/todo/index.ts`(registerTodoTools) + - `packages/agent-core/src/bootstrap.ts`(extend) +- 实现: + - `registerSkillTools`:SkillTool。 + - `registerMcpTools`:createMcpAuthTool factory。 + - `registerTodoTools`:TodoListTool。 +- 测:3 域工具注册正常。 +- 验:全套 test green。 +- 依:P5.3 +- 源:各 domain 文档 +- 耗:1d + +### P5.5 refactor(agent-core): delete centralized tools/ registration + +- 改: + - `packages/agent-core/src/tools/`(delete,已空) + - `packages/agent-core/src/tools/support/services.ts`(ToolServices bag → loop/toolServices.ts) + - import 更新 +- 实现: + - `tools/` 已空(所有工具搬到各域),删除。 + - `tools/support/services.ts`(ToolServices bag)搬到 `loop/toolServices.ts`。 + - 工具注册的唯一入口是 `bootstrap.ts::registerAllBuiltinTools`。 +- 测:typecheck green;全套 test green;`tools/` 不存在。 +- 验:`ls packages/agent-core/src/tools` 不存在;`grep "from ['\"][^'\"]*?/tools/" packages/agent-core/src` 0 命中。 +- 依:P5.4 +- 源:PLAN §2.4 +- 耗:1d + +**P5 acceptance:** 所有工具按域注册;`registerAllBuiltinTools` 是唯一入口;集中式 `tools/` 消失;P6 / P7 可启动。 + +--- + +## P6 · service scope 标注(8–12d) + +> 前置:P1 scope 机制 + P3 / P4 domain 就位。本阶段把每个 service 从 registerSingleton 迁到 registerScopedService,标注 scope,注入 I*Context。 + +### P6.1 refactor(agent-core): Core scope services 迁移 + +- 改: + - 所有 Core scope service 的 `registerSingleton` → `registerScopedService(Core, ...)` + - 对应 import 更新 +- 实现: + - Core scope service(IModelCatalogService / IChatProviderService / IModelAuthService / ITokenizerService / IUsageHistoryService / IKaosRegistryService / IPathSafetyService / IFsService / IProcessService / ITerminalService / IPermissionRegistry / IPermissionPolicyChain / IPermissionProfileService / IPermissionRuleService / IPermissionService / IPermissionAuditService / IShellCommandClassifier / IWorkspaceRegistryService / IWorkspaceBrowserService / IWorkspaceGitContextService / IMcpRegistryService / IMcpCredentialStore / ISkillScannerService / ISkillParserService / ISkillCatalogService / IPluginStore / IPluginSourceResolverService / IPluginArchiveService / IPluginManifestService / IPluginManagerService / IProfileLoaderService / IProfileResolverService / IProfileCatalogService / ISystemPromptRendererService / IHookRegistry / IUrlFetcherProviderService / IWebSearchProviderService / ILogService / IEventService / ICoreRuntime / IToolService / ITranscriptSink / IRestorableRegistry / ISessionLifecycleService / ISessionRepository / ISessionIndex / ISessionService / ISessionQueryService / ISessionExportService / ISessionTranscriptService / IConfigService / IMessageService / IPromptService / IAuthSummaryService / IOAuthService / IEnvironmentService / IFileStore / ITerminalService / etc.)从 `registerSingleton` 迁到 `registerScopedService(Core, ...)`。 + - `registerScopedService(Core, ...)` 是 `registerSingleton` 的别名(P1.1),行为不变。 +- 测:每个 Core service 通过 registerScopedService 注册 + 解析 + 行为不变。 +- 验:全套 test green。 +- 依:P1, P3, P4 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §3 +- 耗:2–3d + +### P6.2 refactor(agent-core): Session scope services 迁移 + +- 改: + - 所有 Session scope service 的 `registerSingleton` → `registerScopedService(Session, ...)` + - ctor 注入 `ISessionContext` + - 对应 import 更新 +- 实现: + - Session scope service(ISessionRuntimeService / ISessionPromptService / ISessionMetaService / IApprovalService / ISessionGrantStore / IQuestionService / IMcpConnectionManagerService / IMcpOAuthService / ISessionSkillRegistry / IWorkspaceService / ISessionLogService / IHookEngine / IHookRunnerService / ISessionUsageView / ITranscriptSink / ILiveEventBus / etc.)从 `registerSingleton` 迁到 `registerScopedService(Session, ...)`。 + - ctor 注入 `ISessionContext`(取 sessionId / abortSignal / executionScope),方法签名去掉 sessionId。 + - ScopeBuilder.build(Session) 注入 ISessionContext。 +- 测:每个 Session service 通过 registerScopedService 注册 + 注入 ISessionContext + 行为不变。 +- 验:全套 test green。 +- 依:P6.1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §4 +- 耗:2–3d + +### P6.3 refactor(agent-core): Agent scope services 迁移 + +- 改: + - 所有 Agent scope service 的 `registerSingleton` / `perAgentServices.set` → `registerScopedService(Agent, ...)` + - ctor 注入 `IAgentContext` + - 对应 import 更新 +- 实现: + - Agent scope service(IAgentStatus / ISubagentHostService / IAgentProfileService / ICronService / IBackgroundService / IGoalService / ISwarmService / IRecordsService / IReplayService / IContextMemoryService / ICompactionService / IInjectionService / ITodoService / ITurnService / IAgentModeService / IAgentModelSelectionService / ISkillActivatorService / IPermissionManager / PlanMode / SwarmMode / GoalMode / BackgroundManager / CronManager / Compaction / AgentRecords / UsageView / TurnFlow / ToolManager / ConfigState / ContextMemory / etc.)从 `registerSingleton` / `perAgentServices.set` 迁到 `registerScopedService(Agent, ...)`。 + - ctor 注入 `IAgentContext`(取 agentId / parentId / abortSignal / executionScope),方法签名去掉 agentId。 + - ScopeBuilder.build(Agent) 注入 IAgentContext。 +- 测:每个 Agent service 通过 registerScopedService 注册 + 注入 IAgentContext + 行为不变。 +- 验:全套 test green。 +- 依:P6.2 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §4 +- 耗:3–4d + +### P6.4 refactor(agent-core): Turn scope services 迁移 + +- 改: + - 所有 Turn scope service 的 `registerSingleton` → `registerScopedService(Turn, ...)` + - ctor 注入 `ITurnContext` + - 对应 import 更新 +- 实现: + - Turn scope service(ActiveTurn / TurnHandle / AbortController / LLM stream / KosongLLM / ProviderRequestAuth / ExecutionScope / once/turn grants / per-turn LiveEventBus / ITurnGrantStore / etc.)从 `registerSingleton` 迁到 `registerScopedService(Turn, ...)`。 + - ctor 注入 `ITurnContext`(取 turnId / parentId / abortSignal / executionScope),方法签名去掉 turnId。 + - ScopeBuilder.build(Turn) 注入 ITurnContext。 +- 测:每个 Turn service 通过 registerScopedService 注册 + 注入 ITurnContext + 行为不变。 +- 验:全套 test green。 +- 依:P6.3 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §4 +- 耗:2–3d + +### P6.5 refactor(agent-core): ToolCall scope services 迁移 + +- 改: + - 所有 ToolCall scope service 的 `registerSingleton` → `registerScopedService(ToolCall, ...)` + - ctor 注入 `IToolCallContext` + - 对应 import 更新 +- 实现: + - ToolCall scope service(once grant / prepare buffer / single approval prompt handle / child AbortController / IToolCallScheduler / etc.)从 `registerSingleton` 迁到 `registerScopedService(ToolCall, ...)`。 + - ctor 注入 `IToolCallContext`(取 toolCallId / parentId / abortSignal / executionScope),方法签名去掉 toolCallId。 + - ScopeBuilder.build(ToolCall) 注入 IToolCallContext。 +- 测:每个 ToolCall service 通过 registerScopedService 注册 + 注入 IToolCallContext + 行为不变。 +- 验:全套 test green。 +- 依:P6.4 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §4 +- 耗:1–2d + +### P6.6 refactor(agent-core): 删除 registerSingleton + perAgentServices + +- 改: + - 所有 service 已迁到 registerScopedService;删除 `registerSingleton`(或保留为 registerScopedService(Core, ...) 的别名) + - `AgentFactory.buildServiceCollection` 的 `perAgentServices.set(...)` 删除(Agent scope service 已通过 registerScopedService 注册) + - import 更新 +- 实现: + - 所有 service 通过 registerScopedService 注册后,`registerSingleton` 成为 registerScopedService(Core, ...) 的别名(P1.1)。 + - `AgentFactory.buildServiceCollection` 不再手工 `perAgentServices.set(...)`;Agent scope service 通过 registerScopedService(Agent, ...) 自动注册到 ScopeRegistry,由 ScopeBuilder.build(Agent) 装配。 + - `AgentFactory` 简化为调用 ScopeBuilder.build(Agent)。 +- 测:typecheck green;全套 test green;`grep "perAgentServices.set" packages/agent-core/src` 0 命中。 +- 验:全套 test green。 +- 依:P6.5 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §3 +- 耗:1d + +**P6 acceptance:** 所有 service 标注 scope 并通过 registerScopedService 注册;I*Context 注入到位;registerSingleton / perAgentServices 删除;P7 / P8 可启动。 + +--- + +## P7 · Agent 收窄(3–5d) + +> 前置:P4 domain 拆分 + P6 scope 标注完成。本阶段把 Agent 瘦到 3–4 服务,剩余职责拆到 domain。 + +### P7.1 refactor(agent-core): Agent 收窄到 IAgentLifecycleService + IAgentStatus + IRestorableRegistry + ISubagentHostService + +- 改: + - `packages/agent-core/src/agent/index.ts`(收窄) + - `packages/agent-core/src/agent/lifecycle.ts`(IAgentLifecycleService + impl) + - `packages/agent-core/src/agent/status.ts`(IAgentStatus,derived event source) + - `packages/agent-core/src/agent/restorable.ts`(IRestorableRegistry + impl) + - `packages/agent-core/src/agent/subagentHost.ts`(ISubagentHostService + impl) + - `packages/agent-core/src/agent/tools/agent.ts`(AgentTool) + - import 更新 +- 实现: + - Agent 收窄到 4 个服务:IAgentLifecycleService(manager,住 Session scope)/ IAgentStatus(derived event source,住 Agent scope)/ IRestorableRegistry(住 Agent scope)/ ISubagentHostService(住 Agent scope)+ AgentTool。 + - 剩余职责(generate / llm → Kosong 域的 IAgentModelSelectionService;rpcMethods → Loop 域;resume → Records 域的 IReplayService;useProfile → Profile 域的 IAgentProfileService;emitStatusUpdated → IAgentStatus;emitEvent → IDomainEventBus)拆到对应 domain。 + - Agent 类只保留:type / id / 4 个服务句柄 / emitEvent(委托 IDomainEventBus)/ AgentTool。 +- 测:Agent 收窄后行为不变;server-e2e 0 diff(如可跑)。 +- 验:全套 test green。 +- 依:P4, P6 +- 源:`kimi-code-dev-2/plan/2026.06.22-Agent-Domain.md` +- 耗:2–3d + +### P7.2 refactor(agent-core): AgentFactory → AgentScopeBuilder + +- 改: + - `packages/agent-core/src/agent/factory.ts`(delete or simplify) + - `packages/agent-core/src/scope/builder.ts`(AgentScopeBuilder) + - import 更新 +- 实现: + - `AgentFactory` 简化为调用 `AgentScopeBuilder.build(parentScope, options)`。 + - AgentScopeBuilder 装配 Agent scope service(通过 registerScopedService(Agent, ...))+ 注入 IAgentContext。 + - Agent 构造通过 AgentScopeBuilder。 +- 测:Agent 构造行为不变;subagent / replay 路径不变。 +- 验:全套 test green。 +- 依:P7.1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Scope-Mechanism.md` §5 +- 耗:1d + +### P7.3 refactor(agent-core): Agent slim final cleanup + +- 改: + - `packages/agent-core/src/agent/index.ts`(delete dead code) +- 实现: + - 删除 Agent 收窄后的 dead code(已无用的 import / private method / field)。 + - 确认 Agent 公开句柄行为不变。 +- 测:Agent 公开句柄行为不变。 +- 验:全套 test green。 +- 依:P7.2 +- 源:M2.8 +- 耗:0.5d + +**P7 acceptance:** Agent 收窄到 3–4 服务;AgentFactory → AgentScopeBuilder;Agent 公开句柄行为不变;P8 可启动。 + +--- + +## P8 · bootstrap 生命周期(3–5d) + +> 前置:P6 scope 标注 + P7 Agent 收窄完成。本阶段落地 5 阶段启动 + shutdown 反向链 + Restorable resume。 + +### P8.1 feat(agent-core): bootstrap 5 阶段启动 + +- 改: + - `packages/agent-core/src/bootstrap.ts`(extend,5 阶段) + - `packages/server/src/start.ts`(对接 5 阶段) +- 实现: + - 5 阶段:① Pre-DI(import 触发 registerScopedService side effects);② Core Build(startServer 建 ServiceCollection + new InstantiationService);③ Listener Ready(registerAllBuiltinTools + app.ready + app.listen + process.started);④ Serve;⑤ Shutdown。 + - `registerAllBuiltinTools` 必须在 `app.listen()` 前。 +- 测:启动 5 阶段顺序正确;工具注册在 listen 前。 +- 验:全套 test green;server 启动正常。 +- 依:P5, P6 +- 源:`kimi-code-dev-2/plan/2026.06.22-Bootstrap-Lifecycle.md` §1–5 +- 耗:1.5d + +### P8.2 feat(agent-core): shutdown 反向链 + +- 改: + - `packages/agent-core/src/bootstrap.ts`(extend,shutdown) + - `packages/server/src/start.ts`(对接 shutdown) +- 实现: + - shutdown 反向链:stop accepting(app.close 开始)→ await ISessionLifecycleService.dispose()(sessions 串行 dispose)→ await coreScope.dispose()(DI 反向依赖 teardown)→ await app.close() → lockHandle.release() → process.exit(0)。 + - 每个 dispose 步骤 30s hard timeout;timeout 时 process.exit(1) + panic log。 + - lockfile release 是最后一步。 +- 测:shutdown 反向链顺序正确;timeout 处理正确。 +- 验:全套 test green;server shutdown 正常。 +- 依:P8.1 +- 源:`kimi-code-dev-2/plan/2026.06.22-Bootstrap-Lifecycle.md` §6 +- 耗:1d + +### P8.3 feat(agent-core): Restorable resume + +- 改: + - `packages/agent-core/src/agent/restorable.ts`(IRestorableRegistry + impl) + - `packages/agent-core/src/records/recordsService.ts`(IRecordsService.openSnapshot + registerRecordHandler) + - `packages/agent-core/src/bootstrap.ts`(resume chain) +- 实现: + - resume 完全 on-demand(无 process-level daemon,无启动时 auto-restore)。 + - resume chain:ISessionLifecycleService.open → AgentScopeBuilder.build → forceEagerPerAgentServices(accessor)(触发每个 per-agent service ctor registerRecordHandler + cron/background catchup)→ IRecordsService.openSnapshot → IRestorableRegistry.restoreAll(stream)(per-record-type dispatch,god-switch restoreAgentRecord 拆成 per-service handler)→ onDidCreateAgent。 + - record.type ↔ handler 一对一(duplicate 注册 throw;missing handler auto-skip + warn)。 + - sub-agent 不 auto-resume(v1)。 +- 测:resume chain 顺序正确;per-record-type dispatch 正确;fatal vs recoverable 处理正确。 +- 验:全套 test green。 +- 依:P8.2 +- 源:`kimi-code-dev-2/plan/2026.06.22-Restorable-Lifecycle.md` +- 耗:1.5d + +**P8 acceptance:** 5 阶段启动 + shutdown 反向链 + Restorable resume 就位;P9 可启动。 + +--- + +## P9 · 收尾 + 文档(2–3d) + +> 前置:P1–P8 完成。本阶段删除 deprecated 结构 + 终态文档 + changeset。 + +### P9.1 refactor(agent-core): 终态 import 审计 + 残留清理 + +- 改: + - 删除任何迁移残留的 re-export / 旧 barrel(如 P3 迁完后的 `services/` 旧 barrel) + - 修正任何残留的 deep import +- 实现: + - P2 / P3 迁移按新规则已不留 re-export alias;本步做最终审计,确认无残留。 + - 全量 grep 确认无旧路径 import:`grep -rEn "from ['\"][^'\"]*?/(di|base/common/event|utils|services)['\"]" packages/agent-core/src packages/agent-core/test` 0 命中。 + - 全量 grep 确认无 deep import:consumer 一律从 barrel(`#/` / `#/_base/` / `#/_utils/` / `@moonshot-ai/agent-core`)导入。 + - 删除 P3 迁完后可能残留的 `services/` 旧 barrel,改为通过各 domain barrel 暴露。 +- 测:typecheck green;全套 test green。 +- 验:上述 grep 0 命中;fence green。 +- 依:P1–P8 +- 源:— +- 耗:0.5d + +### P9.2 refactor(agent-core): decorator 字符串改名(可选) + +- 改: + - `createDecorator('coreProcessService')` → `createDecorator('coreRuntime')`(如需) + - 全 consumer audit + 更新 +- 实现: + - M0–M7 保留了 `'coreProcessService'` 等 decorator 字符串。本步(可选)改名。 + - 全 consumer audit + 更新(CLI / node-sdk / acp-adapter / server-e2e)。 + - 如不安全,跳过并文档化。 +- 测:typecheck green;全套 test green。 +- 验:全套 test green。 +- 依:P9.1 +- 源:M6.3 / M7.1 +- 耗:1d(如做) + +### P9.3 docs: 终态文档 + changeset + +- 改: + - `packages/agent-core/src/AGENTS.md`(终态 domain 布局) + - `AGENTS.md`(Project Map) + - `.agents/skills/service-skill/explanation/domains/`(更新各 domain 定稿) + - `.changeset/.md`(new,minor 或 major) +- 实现: + - 更新 `agent-core/src/AGENTS.md`:终态 domain 布局(20 个 domain)+ scope 机制 + 依赖方向 + 如何新增 domain。 + - 更新 root `AGENTS.md` Project Map。 + - 更新各 domain concept doc(与终态一致)。 + - changeset 总结 di-v3 重构。 +- 测:typecheck green(无代码改动)。 +- 验:阅读文档;确认终态一致。 +- 依:P9.2 +- 源:PLAN §2 +- 耗:1d + +**P9 acceptance:** deprecated 结构删除;终态文档一致;changeset 生成;di-v3 重构完成。 + +--- + +## Dependency graph + +```text +P0 (地基) + └─► P1 (scope 机制) + ├─► P2 (基础设施下沉) + │ └─► P3 (domain 目录迁移) + │ ├─► P4 (domain 拆分 → 20) + │ │ ├─► P5 (工具按域注册) + │ │ └─► P6 (service scope 标注) + │ │ └─► P7 (Agent 收窄) + │ │ └─► P8 (bootstrap 生命周期) + │ │ └─► P9 (收尾) +``` + +关键路径:`P0 → P1 → P2 → P3 → P4 → P5 → P6 → P7 → P8 → P9`。 +可并行:P3 各 domain 迁移可并行;P4 各 domain 拆分可并行;P5 各 register*Tools 可并行;P6 各 scope 迁移可并行(Core / Session / Agent / Turn / ToolCall 之间)。 + +--- + +## Cross-phase standing tasks + +- **doc-code 同步**:每个 phase 结束时,确认对应 domain 的 concept doc 与代码一致。 +- **API snapshot 0 diff**:每个 phase 结束时跑 P0.1 的 snapshot,确认 HTTP/WS / SDK 表面未变。 +- **fence 通过**:每个 phase 结束时跑 dependency-direction fence。 +- **server-e2e smoke**:改动运行时 wiring 的 step(如可跑;user 已接受无 e2e 风险)。 +- **无 `any` 引入**:每个 step 的 typecheck 不得新增 `any`。 +- **decorator 字符串不变**:P9.2 之前禁止改 `createDecorator('...')` 字符串。 +- **变更集纪律**:涉及 public API 变化的 step 需 changeset。 + +--- + +## Totals + +- Step count:约 60–70(P0:3 / P1:6 / P2:6 / P3:9 / P4:9 / P5:5 / P6:6 / P7:3 / P8:3 / P9:3) +- Solo working days:约 54–81d +- 3-engineer working days:约 18–27d(P3 / P4 / P5 / P6 可并行后压缩) +- LOC estimate(added / moved / deleted):约 15k–25k(大量是 move + split) + +--- + +> 本 ROADMAP 的每个 step 是「建议切片」。落地前实施者需按当时仓库状态(branch、并行 refactor、server-e2e 状态、各 domain 的 concept doc 进度)再次校准;任何 step `耗 > 2d` 或无法写出 `验` 时,必须进一步拆分。