docs(analysis): add plugin-system analysis notes

Structured walkthrough of the dsh plugin system across seven layers:
Cordis framework internals, boot composition (cordis.yml/profile/bundle/
patch), the capability-seam three-role pattern (traced via shell),
the tool registry and execution pipeline, a real plugin anatomy
(todo_write), cross-cutting invariants, and a source file index.

Analysis-only notes under analysis/, not part of the gated docs/
pipeline. Snapshot base: abe560f81e (0.1.0-rc.5).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
lambert.li
2026-08-14 11:05:25 +08:00
co-authored by Claude
parent 47f943859b
commit 740c3208c5
8 changed files with 1199 additions and 0 deletions
@@ -0,0 +1,199 @@
# 01 - Cordis 框架底座
Cordis 是 dsh 之下的 vendored 插件框架。本篇解析它的四个核心机制:Context 代理、Service、Fiber 生命周期、五种事件分派。源码位于 `vendor/cordis/src/`
## 一、Context 是一个代理
Cordis 最关键的设计是 **`Context` 是一个 Proxy**。`ctx.tools``ctx.llm``ctx.sessions` 这些属性读取不走普通属性查找,而是走 `ReflectService.handler.get` 陷阱(`vendor/cordis/src/reflect.ts:135-171`):
```ts
get: (target, prop, ctx) => {
if (isSpecialProperty(prop)) return Reflect.get(target, prop, ctx)
if (Reflect.has(target, prop)) return getTraceable(ctx, Reflect.get(target, prop, ctx))
// 否则:通过 waterfall 在 fiber 链上解析服务实现
return ctx.events.waterfall('internal/get', ctx, prop, error, () => {
let fiber = ctx.fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value) // 找到服务实现
if (prop in fiber.inject) throw error // 依赖未就绪
fiber = fiber.parent.fiber // 沿父链向上查找
}
})
}
```
这意味着 `ctx.<key>` 是**按名查找服务**,而非导入具体实现。所有插件通过 `ctx.tools``ctx.llm` 这样的稳定 key 互相发现,不直接 import 对方的实现类--这是「可替换性」的根基。
### 三种作用域操作
Context 提供三种作用域操作(`vendor/cordis/src/context.ts:99-145`):
- `extend(meta)` - 创建原型继承的子 context,不修改父级
- `isolate(name, label)` - 为某个服务名开独立作用域(不同 agent 可以有各自的 `ctx.tools`
- `intercept(name, config)` - 为某服务注入拦截配置(祖先优先级最低)
`isolate` 是实现「一个 agent 一套能力集」的关键:同一个服务名在不同隔离作用域里可以指向不同实现。
## 二、Service:插件如何挂载到 ctx
`Service` 是暴露命名 API 的基类(`vendor/cordis/src/service.ts`)。子类构造时调用 `super(ctx, name)`,立即注册:
```ts
constructor(protected ctx: Context, name: string) {
// ...
self.ctx.reflect.provide(name, self, this[Service.check]) // 注册到 reflect store
return self
}
```
但**绝大多数 dsh 插件不是 Service 子类**,而是更轻量的三种入口形态之一(`vendor/cordis/src/registry.ts:92-134`):
| 形态 | 签名 | 用途 |
|------|------|------|
| 函数插件 | `(ctx, config) => any` | 最常见,如 `todo_write` |
| 类插件 | `new (ctx, config)` | 需要状态的服务/Provider,如 `LocalBashExecutor` |
| 对象插件 | `{ apply(ctx, config) }` | 带 `inject`/`Config` 等元数据 |
每个插件可声明静态元数据(`registry.ts:99-111`):
- `name` - 显示名
- `inject` - **依赖声明**(所需服务列表)
- `Config` - standard-schema 配置校验器
- `provide` - 提供的服务名
### 依赖注入是声明式而非手工排序
`inject` 表达加载顺序:声明了 `inject` 的插件会**等到所需服务存在后才激活**。`ctx.inject(deps, callback)``ctx.plugin({inject, apply: callback})` 的简写(`registry.ts:300-302`)。当依赖服务注册/注销时,Cordis 会重新评估该插件的 fiber(见下文 `notify`)。
## 三、Fiber:生命周期与「注册即可逆 effect」
**「Registrations are reversible effects」** 是整个系统最重要的运行时保证。`Fiber` 是一个插件的运行实例(`vendor/cordis/src/fiber.ts`),状态机为:
```
PENDING -> LOADING -> ACTIVE -> (UNLOADING) -> DISPOSED
↓ FAILED ↗
```
`ctx.effect(execute, label)` 是核心(`fiber.ts:418-561`):`execute` 立即执行,它返回的 **disposer** 被收集起来,在 fiber 卸载时**按注册的逆序**执行。返回的 wrapper 本身也是 disposer,可提前调用:
```ts
effect(execute, label) {
// execute 立即运行,collect 收集其产出的 disposer
// 卸载时:disposables.splice(0).reverse().forEach(runDisposable)
}
```
关键在于:**每一个注册操作都走 effect**。例如注册事件监听器(`events.ts:254-260`):
```ts
register(label, hooks, callback, options) {
return this.ctx.fiber.effect(() => {
hooks.push({ ctx: this.ctx, callback, ...options })
return () => this.unregister(hooks, callback) // disposer
}, label)
}
```
注册服务(`reflect.ts:277-305`)同理:`provide()` 包在 `ctx.fiber.effect()` 里,返回的 disposer 删除 store 中的实现并通知依赖者。所以:
- 卸载一个插件 -> 它注册的工具、监听器、服务提供都自动撤销
- 依赖它的插件被唤醒重新评估(`notify()``reflect.ts:314-336`
这就是「热重载/卸载可预测回卷」的机制。disposer 可以是 generator`yield` 多个),也可以是 async。
### 依赖驱动的重载(epoch 机制)
Fiber 用一个 `epoch` 字符串追踪依赖状态。`_refresh()``fiber.ts:611-623`)把当前所有 inject 服务的 fiber uid 拼成 epoch
```ts
_refresh() {
let epoch = ''
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) { epoch = INACTIVE; break } // 任一依赖缺失 -> INACTIVE
epoch += ':' + impl.fiber.uid
}
this._setEpoch(epoch)
}
```
epoch 变化触发 `_setEpoch``fiber.ts:625-639`):从 INACTIVE 变可用就 `_reload()`(运行插件回调),反之 `_unload()`(运行所有 disposer)。`reload`/`unload` 通过 `inertia` 串行化,避免并发状态机竞态。`_reload` 内部有 epoch 失效检查(`fiber.ts:654`),保证过期的加载不会执行插件代码。
## 四、事件系统:五种分派模式
事件是插件间的通信手段,但**分派模式是事件契约的一部分**(`vendor/cordis/src/events.ts` + `docs/cordis-primer.md`):
| 模式 | await? | 顺序 | 返回值 | 方法 |
|------|--------|------|--------|------|
| `emit` | 否 | 注册序 | 无 | 同步广播 |
| `waterfall` | 否 | 注册序 | 有(around 中间件) | `next()` 委托 |
| `parallel` | 是 | 全部并行 | 无 | `Promise.allSettled` |
| `serial` | 是 | 注册序,bail 止 | 第一个 bail 值 | 顺序 await |
| `bail` | 否 | 注册序 | 第一个 bail 值 | 同步直到 bail |
### waterfall 是最关键的扩展点
`waterfall` 是 around 中间件(`events.ts:234-243`):最后一个参数是 `next`,监听器包裹整条链。调用 `next()` 委托给下一个,不调用则**短路否决**:
```ts
waterfall(...args) {
const cbs = this.dispatch('waterfall', args)
const inner = args.pop() // 内层 next
const next = () => { const cb = cbs.shift() ?? inner; return cb(...args) }
args.push(next)
return next()
}
```
dsh 的 `agent/pre-step``agent/request``llm/stream``tools/pre-execute``tools/execute``tools/post-execute` 都是 waterfall--这决定了策略/拦截/沙箱挂在哪里。协作型监听器通常修改共享请求/决策对象后委托;单决策事件中,拥有决策权的监听器可以不调 `next()` 直接短路。
### 类型化事件与声明合并
类型化事件通过 **declaration merging** 声明:
```ts
declare module './context.ts' {
export interface Events {
'agent/pre-step'(this: Context, msg: Message, next: () => Message): Message
// ...
}
}
```
JSDoc 用 `@mode` 标注分派模式,生成目录会据此校验声明与分派点一致。作用域 key 若不在 payload 中需要标注 `@dshScopeScan unsupported`
### dispatch 与上下文过滤
`dispatch()``events.ts:165-175`)解析某次分派的监听器并应用 context 过滤:
```ts
dispatch(type, args) {
const thisArg = typeof args[0] === 'object' ? args.shift() : null
const name = args.shift()
this.emit('internal/dispatch', type, name, args, thisArg) // 诊断事件
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))
.map(hook => hook.callback.bind(thisArg))
}
```
监听器记录在 fiber 上(`on()` -> `register()` -> `fiber.effect()`),所以随 fiber 卸载自动移除。`global: true` 绕过 filter`prepend: true` 插到队列前。
## 关键源码索引
| 主题 | 文件 |
|------|------|
| Context 类 + 三作用域 | `vendor/cordis/src/context.ts:42-146` |
| Context 代理陷阱 | `vendor/cordis/src/reflect.ts:135-206` |
| Service 基类 | `vendor/cordis/src/service.ts:1-115` |
| 插件形态 + inject 元数据 | `vendor/cordis/src/registry.ts:92-187` |
| `ctx.plugin()` / `ctx.inject()` | `vendor/cordis/src/registry.ts:300-336` |
| Fiber 状态机 | `vendor/cordis/src/fiber.ts:147-154` |
| `ctx.effect()` | `vendor/cordis/src/fiber.ts:418-561` |
| epoch 重载机制 | `vendor/cordis/src/fiber.ts:611-696` |
| 五种分派模式 | `vendor/cordis/src/events.ts:131-243` |
| `ctx.on()` 注册 | `vendor/cordis/src/events.ts:288-302` |
| `provide()` 可逆注册 | `vendor/cordis/src/reflect.ts:277-305` |
| 依赖通知 `notify()` | `vendor/cordis/src/reflect.ts:314-336` |
@@ -0,0 +1,238 @@
# 02 - 启动组合层
这是 dsh 在 Cordis 之上加的**配置组合层**。运行中的 dsh 是一棵从有序层在启动时组合出的插件树。本篇解析 `cordis.yml` 行格式、`!!js` 延迟求值、profile/bundle/patch 四层叠加、以及 patch 算法。
源码:`vendor/loader/``vendor/include/``packages/boot/app-boot/``apps/cli/`
## 一、cordis.yml 的行格式
每个插件是顶层 YAML 数组的一行,`EntryOptions``vendor/loader/src/config/entry.ts:8-22`):
```yaml
- id: timer # 稳定 id,供 patch 定位
name: '@deepseek-ai/cordis-plugin-timer' # 模块说明符
config: # 传给插件的配置
root: ['.']
inject: [...] # 依赖声明
disabled: false # 禁用整行 + 后代
group: false # 标记嵌套入口组
```
真实示例(`packages/bundle/base/cordis.patch.yml:16-22`):
```yaml
- id: timer
name: '@deepseek-ai/cordis-plugin-timer'
- id: hmr
name: '@deepseek-ai/cordis-plugin-hmr'
config:
root: ['.']
```
## 二、`!!js`:延迟求值的表达式节点
`!!js` 是自定义 YAML 标签,把值解析成 `{__jsExpr: "..."}`,**挂载时**才求值,而非解析时。注册在 `vendor/include/src/index.ts:9-23`
```ts
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: (data) => typeof data === 'string',
construct: (data) => ({ __jsExpr: data }),
predicate: isJsExpr,
represent: (data) => data['__jsExpr'],
})
export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)
```
求值器是 `with(ctx){ return eval(expr) }` 动态函数(`vendor/loader/src/config/utils.ts:5-9`),表达式能看到 entry 的 fiber 上下文。两处使用:
1. **`disabled` 字段**`entry.ts:104-108`)- 对 entry 自己的上下文求值:
```ts
private disabledOf(options: EntryOptions): boolean {
return isJsExpr(options.disabled)
? Boolean(this.evaluate(options.disabled.__jsExpr))
: Boolean(options.disabled)
}
```
用法:`disabled: !!js process.platform === 'win32'`
2. **配置值** - Loader 注册全局 `internal/config` hook`loader/src/index.ts:92-101`),对每个非 tree-carrier entry 的 config 调 `interpolate(ctx, config)`,递归把每个 `__jsExpr` 节点替换成 `evaluate(ctx, expr)` 结果。
boot 把 `dshHomePath` 提供进该作用域(`app-boot/src/index.ts:770``ctx.provide('dshHomePath', dshHomePath)`),`ctx` 本身也在作用域内,所以 `task: !!js ctx.headlessStartup.task` 成立(见 `packages/bundle/headless/cordis.patch.yml:35`)。
**Tree carrier`Group`、`Include`)豁免插值** - `loader/src/index.ts:98-100` 检查 `plugin?.[EntryGroup.key]`,对其 config 原样返回,因为它**包含**其他行自己的 `!!js` 表达式,那些属于各自行的 fiber。
### dump-config 时 `!!js` 原样打印
`dsh --profile web --dump-config` 走 `renderConfigDump()``app-boot/src/index.ts:379-442`),解析用同一个 `entryListSchema`,所以 `__jsExpr` 往返回 `!!js` **不执行**。这是为什么 dump 等于实际挂载的配置,却不运行任何代码。
## 三、挂载序列
`boot()``packages/boot/app-boot/src/index.ts:757-802`):
1. 创建根 `Context`,设置 `baseUrl` 为配置目录
2. `ctx.provide('dshHomePath', dshHomePath)` - 把 home-path 解析器暴露给 `!!js`
3. `await ctx.plugin(Loader)` - 安装 Loader 服务
4. 运行可选 `prepare` hookhost 在任何 tree entry 挂载前的设置)
5. `await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)`
`mountRootInclude()``app-boot/src/index.ts:486-529`):
- 注册 `cordis:include`(可选 host 解析的子类,把裸名锚定到安装)和 `cordis:group` 为 Loader 内建
- 创建一个根 entrypin 住 id
```ts
const rootInclude: EntryOptions = {
id: 'include',
name: 'cordis:include',
config: { path: pathToFileURL(absoluteConfigPath).href, ...patches.length > 0 ? { patches: [...patches] } : {} },
}
```
- `ctx.loader.create(rootInclude)` 挂载它;Include 插件读文件、应用 patches、调 `root.update(data)`
`EntryGroup.update()``vendor/loader/src/config/group.ts:59-106`)事务式协调新旧列表:用 `Promise.allSettled` 创建每个 `Entry`,移除孤儿 id,失败回滚。每个 `Entry.init()``entry.ts:259-289`)通过 `EntryTree.import()``tree.ts:145-162`)导入模块--处理 `cordis:` 内建、Node 内部模块 loader、相对说明符、裸包名--然后 `ctx.registry.plugin(plugin, config)` 启动。
## 四、profiles 和 bundles 组合
### `dsh.bundle` vs `dsh.profile`package.json 的 dsh 字段)
`package.json` 的 `dsh` 节有两个半(`packages/boot/app-boot/src/profile.ts:42-62`):
- **`dsh.bundle`**`DshBundleManifest`,bundle 包声明):一个字段 `{patch: string}` - 指向其 `cordis.patch.yml`。三盒内置 bundle 都一样声明:
```json
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
```
见 `packages/bundle/base/package.json:36-40`、`web-app/package.json:41-45`、`headless/package.json:41-45`。
- **`dsh.profile`**`DshProfileManifest`,profile 目录声明):一个字段 `{bundles?: string[]}` - 有序 bundle 层列表。`initProfile()` 写入(`profile.ts:156-162`):
```json
{ "name": "dsh-profile-<name>", "private": true, "dependencies": {},
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] } } }
```
### 四层叠加顺序(后者覆盖前者)
从 `composeProfile()``apps/cli/src/profile-boot.ts:142-171`)和 `loadProfile()``profile.ts:371-403`),patch 列表按此顺序构建(后者 = 胜出):
1. **bundle 层** - 按 `dsh.profile.bundles` 列出顺序。模板(`profile.ts:114-117`):
- `web` -> `['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']`
- `headless` -> `['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']`
2. **profile 自己的 `cordis.patch.yml`** - `$DSH_HOME/profiles/<name>/cordis.patch.yml`
3. **home 级 `cordis.patch.yml`** - `$DSH_HOME/cordis.patch.yml`(机器本地偏好,高于 profile 层)
4. **`--patch` 覆盖层** - argv 顺序
5. **遥测开关** - 当 `DSH_TELEMETRY_DISABLED` 置位且行存在时,追加 `{id: 'session-telemetry-otel', disabled: true}``profile-boot.ts:80-83, 168-169`
`allPatches()` flattener`profile-boot.ts:122-129`)确认 live-boot 顺序:
```ts
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [...composed.bundlePatches, ...composed.profile.patches,
...composed.homePatches, ...composed.overlays]
}
```
### 合并算法:先 flatten 再一次性应用
关键:层**不是**逐层增量合并,而是 flatten 成一个数组后单次 `applyEntryPatches` 调用。`composeEntries()``profile.ts:413-420`):
```ts
export function composeEntries(layers: readonly PatchOptions[][], warn = () => {}): EntryOptions[] {
return applyEntryPatches([], structuredClone(layers.flat()), ...)
}
```
空根 `[]` 是基础--每个行都由 bundle patch 插入。写入每个 profile 的根 `cordis.yml` 字面就是 `[]` 加注释(`apps/cli/src/profile-boot.ts:60-64``PROFILE_ROOT_CONFIG`)。
### bundle 解析(双锚)
`resolveBundleDir()``profile.ts:344-355`)按两锚顺序解析每个 bundle 名:先 dsh 安装的 `package.json`,再 profile 目录。这保证内置 bundle 永远来自运行的 dsh,而非 profile 本地副本。`loadProfile()``profile.ts:388-397`)读每个 bundle 的 `dsh.bundle.patch` 字段,声明的包缺 `dsh.bundle` 则 fail loud。
`healProfilesModuleFallback()``profile.ts:223-255`)维护一个扁平的 `$DSH_HOME/profiles/node_modules` 软链(对 app 依赖闭包 BFS),使裸插件名能从任何 profile 通过 Node 的父级遍历解析。
## 五、patch 算法:基于 id 的整字段替换/插入
核心算法 `applyEntryPatches()``vendor/include/src/index.ts:58-128`),**挂载和离线 config dump 共用**。
`PatchOptions` 形状(`include/src/index.ts:145-156`):
```ts
export interface PatchOptions {
id?: string
insert?: EntryOptions[]
name?: string
config?: any
group?: boolean | null
disabled?: boolean | null
inject?: any
intercept?: any
isolate?: any
[key: string]: any
}
```
算法(`include/src/index.ts:63-127`):
1. `structuredClone(data)` - 输入永不被修改,结果总是 detached(使热重载可回退)
2. 构建 `entryMap`id -> entry),递归进 group
3. 对每个 patch
- **`insert` 存在**:若 `id` 指向 group,把 insert 列表推入该 group 的 `config` 数组;无 `id` 则推到根。然后 `buildMap(insert)` 重新索引新行,使**同一列表里后续 patch** 能定位它们(96-101 行)
- **`id` 定向覆盖**(无 insert):`id` 必填。找到目标;若给了 `name` 且不匹配,警告并跳过(防 patch 错行)。然后逐 key 直接赋值 `target[key] = value`(121-124 行)。这是**整字段替换,非深合并**--覆盖 `config` 会整体替换,必须重述要保留的字段
4. 匹配不到的 patch 警告并跳过(110-113 行)
例如 `dsh-headless` 覆盖 base 层(`packages/bundle/headless/cordis.patch.yml:7-15`):
```yaml
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. ...
- id: hmr
disabled: true
```
## 六、`dsh --profile web --dump-config`
CLI flag 在 `apps/cli/src/args.ts:83-102``resolveBoot`)解析:`--dump-config`(带 user 层 + overlay)或 `--dump-default-config`(仅 bundle 层,互斥,不带 app 参数)。`apps/cli/src/bin.ts:45-49` 分派到 `runDumpConfig`。
`runDumpConfig()``apps/cli/src/dump-config.ts:30-52`):
1. `prepareProfile(profile, !defaultOnly)` - 加载 profile、heal module fallback、重写空根 `cordis.yml`
2. 构建 `ConfigDumpLayer[]` - 每个 bundle 一个(`label: packageName`),然后(非 defaultOnly 时)profile patch 文件、home patch 文件、每个 `--patch` overlay
3. 锚定空根:`renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers)`
`renderConfigDump()``app-boot/src/index.ts:379-442`):
- 用 `entryListSchema` 解析基础配置(与 include 同方言)
- 取增量快照:snapshot_k = `applyEntryPatches(base, layers[0..k].flat())` - 正是 `boot()` 在每个前缀会挂载的
- 逐行追踪来源:哪个文件起源、哪些层 patch 了它(位置 diff
- patch 匹配不到行 -> 带层 label 警告
- `groupedDump()``index.ts:445-473`)把同源连续行渲染到一个 `# == <origin>, patched by <layers>` 注释下,保持输出为单个可加载 YAML 文档
## 关键源码索引
| 主题 | 位置 |
|------|------|
| `EntryOptions` 行格式 | `vendor/loader/src/config/entry.ts:8-22` |
| `!!js` YAML 标签 + `entryListSchema` | `vendor/include/src/index.ts:9-23` |
| `evaluate` / `interpolate` / `isJsExpr` | `vendor/loader/src/config/utils.ts:5-27` |
| `disabled` 的 `!!js` 求值 | `vendor/loader/src/config/entry.ts:104-108` |
| Loader `internal/config` 插值 hook | `vendor/loader/src/index.ts:92-101` |
| `EntryTree.import`(模块解析) | `vendor/loader/src/config/tree.ts:145-162` |
| `EntryGroup.update`(事务式应用) | `vendor/loader/src/config/group.ts:59-106` |
| `applyEntryPatches`patch 算法) | `vendor/include/src/index.ts:58-128` |
| `PatchOptions` 形状 | `vendor/include/src/index.ts:145-156` |
| `boot()` | `packages/boot/app-boot/src/index.ts:757-802` |
| `mountRootInclude()` | `packages/boot/app-boot/src/index.ts:486-529` |
| `renderConfigDump()` + `groupedDump()` | `packages/boot/app-boot/src/index.ts:379-473` |
| `watchUserPatches()` | `packages/boot/app-boot/src/index.ts:232-265` |
| `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` | `packages/boot/app-boot/src/profile.ts:114-125` |
| `resolveBundleDir`(双锚) | `packages/boot/app-boot/src/profile.ts:344-355` |
| `loadProfile` | `packages/boot/app-boot/src/profile.ts:371-403` |
| `composeEntries`(单次 flatten | `packages/boot/app-boot/src/profile.ts:413-420` |
| `composeProfile`(层顺序) | `apps/cli/src/profile-boot.ts:142-171` |
| `allPatches` / `composeLive` | `apps/cli/src/profile-boot.ts:122-129, 240-245` |
| `runDumpConfig` | `apps/cli/src/dump-config.ts:30-52` |
| CLI flag 解析/分派 | `apps/cli/src/args.ts:83-102`、`apps/cli/src/bin.ts:45-49` |
| `dshHomePath`(暴露给 `!!js` | `packages/util/home-paths/src/index.ts:98-100`、`app-boot/src/index.ts:770` |
| bundle 声明(base/web/headless | `packages/bundle/{base,web-app,headless}/package.json` |
@@ -0,0 +1,205 @@
# 03 - 能力接缝三角色模式
Capability Seam 是 dsh 复用性最强的模式。一个**可替换能力**由三个角色组成,**缺一不可**,当角色独立演化时拆到不同包。本篇以 shell seam 完整追踪。
源码:`packages/shell/`。文档契约:`docs/capability-seams.md``docs/cookbook/adding-a-tool.md`
## 三角色定义
| 角色 | 职责 | shell 例子 |
|------|------|-----------|
| **Service Definition** | 声明抽象接口 | `dsh-shell``ctx.shell` |
| **Service Provider** | 实现接口 | `dsh-bash-local` / `dsh-bash-sandbox` / `dsh-pwsh-local` |
| **Consumer** | 使用能力(通常是模型工具) | `dsh-tool-bash` |
一个角色不构成 seam。文档把这套映射在 `docs/capability-seams.md:448` 标注:
> `ctx.shell` | `seam` | `shell` | `bash-local`, `bash-sandbox`, `pwsh-local` | `tool-bash`, `tool-pwsh`, `hooks-claude-code`, `hooks-codex` | … 模型面向的 shell 工具和 hook 桥消费此 seam;沙箱化、远程或 PowerShell 执行器替换 bash-local 而不触碰它们。
## 一、Service Definition - `packages/shell/shell/`
包:`@deepseek-ai/dsh-shell``packages/shell/shell/package.json:3` - *「Abstract bash executor seam (ctx.shell) for the DeepSeek Harness」*
抽象服务在 `packages/shell/shell/src/index.ts`augment Cordis `Context` 接口暴露 `ctx.shell`,声明继承 `Service` 的抽象类:
```ts
// packages/shell/shell/src/index.ts:40-44
declare module '@deepseek-ai/cordis' {
interface Context {
shell: ShellExecutor
}
}
```
```ts
// packages/shell/shell/src/index.ts:65-101
export abstract class ShellExecutor extends Service {
constructor(ctx: Context) {
super(ctx, 'shell') // <-- 注册为 ctx.shell
}
get sandboxMode(): SandboxMode | undefined { return undefined }
abstract resolve(request: ShellExecRequest): ShellExecSpec
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
abstract start(spec: ShellExecSpec): ShellProcess
}
```
### request -> spec 分裂:seam 的核心设计模式
请求/结果类型在 `packages/shell/shell/src/types.ts`。seam 的中心设计--**request->spec 分裂**--体现在两者对比:
- `ShellExecRequest``types.ts:38-79`):调用方形状。`workdir``timeoutMs``stdoutMaxBytes``signal``stdin``env``dshEnv``sandboxPolicy` 全**可选** - *「由 `ShellExecutor.resolve` 从实现配置填充」*`types.ts:34-36`
- `ShellExecSpec``types.ts:86-110`):已解析形状。`workdir``timeoutMs``stdoutMaxBytes``sandboxPolicy` 全**必填** - *「`ShellExecutor.resolve` 填充并限制必填字段」*`types.ts:82-85`
`resolve()` 是实现层填默认值和上限的**唯一**地方,`run()`/`start()` 只接收完全解析好的 spec,**不再 `?? default`**。这把「显式优于隐式」约束到了包边界(`CLAUDE.md`):*「默认值是 owning 实现里显式的 `resolve(request): Spec` 步骤,never a hidden `?? default` inside `run()``dsh-shell` 的 request/spec 分裂是模板)」*。
## 二、Service Provider - `packages/shell/bash-local/`
包:`@deepseek-ai/dsh-bash-local``packages/shell/bash-local/package.json:3` - *「Local-subprocess implementation of the … bash executor seam」*。其 `peerDependencies``@deepseek-ai/dsh-shell``package.json:34-40`- provider 依赖 definition。
具体 provider 在 `packages/shell/bash-local/src/index.ts``LocalBashExecutor extends ShellExecutor`,以**类插件**加载(Cordis 用 `(ctx, config)` 实例化 `Service` 子类;其构造调 `super(ctx)` -> `super(ctx, 'shell')` 注册为 `ctx.shell`):
```ts
// packages/shell/bash-local/src/index.ts:102-137
export class LocalBashExecutor extends ShellExecutor {
static inject = ['subprocess']
static Config: z<Config> = z.object({
cwd: z.string(),
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(64_000),
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
// ...
constructor(ctx: Context, config: Config) {
super(ctx)
const entry = config as ResolvedConfig
assertServiceableBashConfig(entry)
this.source = () => entry
installSettingsSection(ctx, SHELL_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, { ... })
}
```
`apply`/`name` export - `export default LocalBashExecutor``index.ts:333`)。组合层在 `cordis.yml` 接线:
```yaml
# examples/headless-agent/cordis.yml:37-40
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 60000
```
### `resolve(request): Spec` 模板
`index.ts:146-171`
```ts
resolve(request: ShellExecRequest): ShellExecSpec {
const timeoutMs = clampTimeout(
request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'bash-local: request.timeoutMs')
const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
return {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
stdoutMaxBytes,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
sandboxPolicy: request.sandboxPolicy, // 此 executor 惰性 - 从不限制
}
}
```
`run`/`start` 委托 `ctx.subprocess.spawn(...)``index.ts:226``257`)。分裂的价值在兄弟 provider `bash-sandbox`,它**重写 `resolve()`** 给 spec 盖沙箱默认值,留 `run`/`start` 和工具不动:
```ts
// packages/shell/bash-sandbox/src/index.ts:44-45, 84-86
export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
override resolve(request: ShellExecRequest): ShellExecSpec {
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
```
## 三、Consumer / Tool - `packages/shell/tool-bash/`
包:`@deepseek-ai/dsh-tool-bash``packages/shell/tool-bash/package.json:3` - *「Model-facing bash tool …」*。Consumer inject **Service Definition**,不是任何 provider
```ts
// packages/shell/tool-bash/src/index.ts:30-31
export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
```
模型面向工具用 `defineTool` 注册(`index.ts:242`)。其 `execute` body 构建 `ShellExecRequest`,调 `ctx.shell.resolve(request)` 得 spec,再交给 `ctx.shell.run`(前台)或 `ctx.shell.start`(后台):
```ts
// packages/shell/tool-bash/src/index.ts:341-348 (构建 REQUEST)
const dshEnv = ctx.shellEnv.collect(exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv,
...policy !== undefined ? { sandboxPolicy: policy } : {},
}
```
```ts
// packages/shell/tool-bash/src/index.ts:380-383 (前台: resolve -> run)
const result = await ctx.shell.run(ctx.shell.resolve({
...request,
signal: exec.signal,
}))
```
```ts
// packages/shell/tool-bash/src/index.ts:370 (后台: resolve -> start)
const proc = ctx.shell.start(ctx.shell.resolve(request))
```
它还在加载时读 `ctx.shell.sandboxMode``index.ts:192`)决定是否广告 `sandbox_permissions`/`justification` 升级参数(`index.ts:259-269`)。`presentCall`/`presentResult` presenter102-136 行)声明 `terminal` card - 即「工具的 UI render intent 是其设计的一部分」约定。
## 四、事件:shell seam 声明 NONE
shell seam 是**同步请求/响应服务,无能力事件**。grep `packages/shell/` 下所有 `interface Events` augmentation - 没有。Service Definition 只 augment `interface Context``shell/src/index.ts:40-44`),never `interface Events`,所以没有 `shell/*` 事件供监听。
包自己的 invariant companion 明示(`packages/shell/bash-local/src/invariant.ts:15-17`):
```ts
/**
* No runtime invariant: this stateless Service Definition owns request/result types,
* while executors and policy own observations.
*/
const install: InvariantInstaller = () => {}
```
观测(退出码、signal、stdout/stderr、沙箱事实)作为 `ShellRunResult`/`ShellProcess` 返回值回来,不是事件。对比 `ctx.credentials` 这类 seam **会**声明 `credentials/updated` 事件(`packages/credentials/credentials/src/types.ts:15-30`)。所以本 seam 问题 4 的答案:无 `shell/*` 事件、因此无策略/适配器监听--seam 的契约纯是三个方法加 `sandboxMode` getter。
## 五、三角色包分裂(及它是真 seam 的证据)
| 角色 | 包 | 路径 | `peerDependencies`(相关) |
|------|----|------|------|
| Service Definition | `@deepseek-ai/dsh-shell` | `packages/shell/shell` | `dsh-invariants``dsh-subprocess``dsh-sandbox``cordis``dsh-settings` - **无 provider、无 consumer** |
| Service Provider | `@deepseek-ai/dsh-bash-local` | `packages/shell/bash-local` | 含 **`@deepseek-ai/dsh-shell`**provider -> definition |
| Consumer | `@deepseek-ai/dsh-tool-bash` | `packages/shell/tool-bash` | 含 **`@deepseek-ai/dsh-shell`**`@deepseek-ai/dsh-bash-local` 只在 **`devDependencies`**`package.json:65` |
最后一行是承重证据:Consumer 的运行时依赖在 **Service Definition**,不在 Provider。`dsh-bash-local` 仅为 devDependency,使工具测试能挂具体执行器。这正是文档能宣称 *「沙箱化、远程或 PowerShell 执行器替换 bash-local 而不触碰它们」*`docs/capability-seams.md:448`)的原因--在 `cordis.yml``bash-local``bash-sandbox``pwsh-local``tool-bash` 零行改动,因为它只跟 `ctx.shell` 对话。
`docs/capability-seams.md` 的 mermaid 图编码同样的箭头:`pkg_bash_local --> svc_shell`provider 实现,206 行)和 `svc_shell --> pkg_tool_bash`consumer 消费,362 行)。`docs/cookbook/adding-a-tool.md:5` 指向这个包为参考:*「`packages/shell/tool-bash` 是生产级三包范例」*。
## 关键源码索引
| 主题 | 位置 |
|------|------|
| Service Definition | `packages/shell/shell/src/index.ts``packages/shell/shell/src/types.ts` |
| Service Provider | `packages/shell/bash-local/src/index.ts` |
| 兄弟 Provider 重写 | `packages/shell/bash-sandbox/src/index.ts:84-86` |
| Consumer / Tool | `packages/shell/tool-bash/src/index.ts` |
| 组合接线 | `examples/headless-agent/cordis.yml:37-40` |
| 文档契约 | `docs/capability-seams.md:448`(表)、mermaid 图 206-207/240/362-363 行;`docs/cookbook/adding-a-tool.md:5` |
+103
View File
@@ -0,0 +1,103 @@
# 04 - 工具注册表与执行流水线
`ctx.tools``ToolRuntime``packages/core/tools/`)是插件贡献的汇聚点。本篇解析工具注册、执行流水线、canonical 输出契约、以及 Code Mode。
源码:`packages/core/tools/`。文档:`docs/tool-execution-pipeline.md``docs/cookbook/adding-a-tool.md``packages/core/tools/README.md`
## 一、注册即 effect
```ts
ctx.tools.register(defineTool({
name, description, parameters, output, execute, presentCall, ...
}))
```
返回 disposerfiber 卸载时自动注销。schema 自动流入 system-prompt 组装(`ctx.systemPrompt.tools()`)。`defineTool` 提供**类型化参数 schema**`ParameterSchemaSpec`),`execute``args` 已是 `InferArgs` 推断的类型,registry 在 `execute` 前已校验。
### 注册 API
- `ctx.tools.register(definition)` - 注册可信 typed 同进程定义,必带 canonical `output` 声明。调用上下文的 scope 即注册层:普通插件 context 全局注册;agent 的 `agent.ctx` 为该 agent 单独注册,shadow 同名全局工具。同层重复名抛错
- `ctx.tools.presentAs(mode)` - 选该 agent 的模型面向呈现,shadow `mode` config
- `ctx.tools.restrict(filter)` - agent-scoped allow/deny mask,多 mask 相交,scope-local 工具后合并
- `ctx.tools.get(name, scope?)` - 单 scope 解析(shadowing 应用;restricted-away 的全局读为缺)
- `ctx.tools.schemas(scope?)` - 该 scope 可见全部 schema(无 `execute`
- `ctx.tools.guard(guard)` - 注册单调同步执行 guard`tools/pre-execute` 之后),返回 reason 拒绝、`undefined` 不变。后续 waterfall 监听器无法把 guard 拒绝翻回许可
- `ctx.tools.execute(exec)` - lossless 快照+冻结参数、分配不透明 token、跑完整策略/分派/结果流水线、再独立快照权威结果
- `ctx.tools.executionMode(exec)` - 仅当可见定义的 `isConcurrencySafe(args)` 返回**精确 `true`** 才返回 `parallel`
## 二、执行流水线
```
tool/call (会话事件,执行前先记录)
-> tools/pre-execute (waterfall: allow/deny/ask 策略门)
-> 注册的 monotonic guards (最终否决,后置监听无法翻案)
-> tools/execute (waterfall: 超时/重试/指标,around dispatch)
-> execute() 函数体
-> tools/post-execute (waterfall: 替换内容/值、阻断、附加 context)
-> finalizeContent (定义拥有的最后内容不变量)
-> tools/result (只读观察,不可变结果)
-> tool/result (durable 会话事件)
```
`tools/pre-execute` 是可重排的 allow/deny/ask 门;`ctx.tools.guard()` 在其后加单调 owner 策略。`tools/execute` 包规范 canonical 分派用于超时/重试/指标。`tools/post-execute` 可替换呈现内容、替换 canonical 值、带反馈阻断、附加有序 context。
### 关键不变量
- **执行身份受保护** - registry 把 `arguments` 做成 detached lossless JSON 递归快照并冻结,分配不透明 `exec.token``callId`/`name`/`arguments`/`agent`/`token`/必填的 caller-owned `signal`/可选 enclosing-transport `parent` token 全程不可变。`parent` 仅身份、不暴露活的外层执行。`args` 视为 readonly 输入。只有 around-dispatch wrapper 收到可变视图,且只能替换和恢复必填的 `exec.signal` 施加 deadline,不能删除它
- **args 已校验** - `defineTool``execute` 前校验 model-generated `arguments`(类型、必填 key、literal 约束、exact-one union、嵌套值),`execute` 内 args 匹配 `InferArgs`。仍需手检 DSL 表达不了的约束(非空串、正数、跨字段规则)
- **canonical 输出契约** - `output.schema``ValueSchemaSpec`,可为 object/array/scalar/null 根。`execute` 只返回推断值;registry 快照、校验、冻结后交给 `output.render(args, value)`。不要从 body 返回 content block
- **抛错或返回非法值 = `isError`** - registry 捕获 throw 并在观察者前包含 schema/renderer/metadata-projector/lossless-JSON 失败。基础设施失败用 throw;成功域结果(即便非理想,如非零退出)用 canonical 值表示
- **响应 `exec.signal`** - 触发时取消在途工作
- **`exec.agent` 异步通知** - `agent.inject({ content, source: { kind: 'plugin', plugin: '<name>' } })` 追加 durable context 下次模型请求可见(非唤醒)
### 取消是协作且静止的
每次 typed 调用提供 caller-owned `AbortSignal`tool body 收到必填 readonly `exec.signal`,只有 `tools/execute` wrapper 可临时替换。registry 保留 caller 取消穿过替换。分派前取消 = `ABORTED_BEFORE_DISPATCH`;调用后取消只能把成功换成 `ABORTED`。denial、wrapper 失败、tool 失败、post-policy 失败、timeout 的 `TOOL_TIMEOUT` 仍更具体。
## 三、工具拥有的 UI 呈现
工具可选拥有纯 `presentCall()`/`presentResult()` render intentUI 不特殊处理工具名:
- **call 视图**`{ card: 'generic', title, kind?, rawInput?, content?, locations? }``{ card: 'terminal', title, description?, cwd? }``{ card: 'diff', title, diffs, locations? }`
- **result 视图**`generic``terminal``diff``search`(发现搜索)、`read`(文件读)、`web`web 检索)
返回 `undefined` 选 generic fallback。presenter 只依赖参数和 durable 结果(UI 在流式和日志回放时都调)。`output.presentationMeta(args, value)` 派生顶层调用的 JSON metadata;该 metadata 持久化在 `tool/result` 并回到 `presentResult`,而 canonical 值本身是 execution-local 永不回放。嵌套 Code dispatch 不计算 metadata。`defineTool` 软校验旧 logged 参数并 fallback 而非崩回放。
### 硬规则
- **纯净** - 流式和日志回放都跑,所以必须是 `args`(+结果)的纯函数:无 I/O、不读会话状态、不用时钟/随机。需要旧文件内容或工作目录时,放 durable result metadata 或 adapter,不放 presenter
- **UI-only 格式不进模型结果** - fenced console 块、diff、相对化路径不属于 canonical 值或 Native 内容。`output.render` 管模型面向 prose`presentationMeta` + card presenter 管可回放 UI 状态
## 四、Code Mode
`tools.mode: native | code | both``native` 贡献可见工具为函数定义。`code` 贡献保留 `run_code` transport、生成的 `tools:sdk` section、`tools:code-only` 规则(只有 `run_code` 可直接调)。`both` 贡献两者且无此规则。非 native 模式需 `ctx.codeRuntime.language` 有注册 SDK rendererTS 经 `dsh-code-runtime-worker-thread`Python 内建)。
### `run_code` 是唯一入口(`code` 模式)
模型直接调其他可见工具在执行创建时解析为 `UNKNOWN_TOOL`,在 `tools/pre-execute`、approval `ask`、guards 前,所以无物观察或批准只会失败的调用。denial 名回路由(`only \`run_code\` is callable directly - call \`<name>\` from inside a \`run_code\` program instead`)。
### 分派桥(`run_code` 的 execute
每个 binding call 分派前 lossless JSON 快照(`undefined`/`BigInt`/cycles/sparse arrays/`-0`/exotic objects 拒绝该调用),经每 run pool 调度重用 native 并发契约 - 调用严格按提交序开始,连续 `isConcurrencySafe` 调用重叠至多 `maxParallelSubCalls`(默认 10`1` 恢复串行),exclusive 调用清空 pool 独跑。成功返回策略后最终 canonical 值;失败到 worker 成一条消息变 `ToolCallError(toolName, message)`。每 started sub-call 记 `tool/code-dispatch-start` 事件,结算带一条 `tool/code-dispatch` 事件。
### 结算纪律
桥拥有 run-scoped abort 跟随外层 signalrun 结算时触发(预算到期 abort 在途 sub-tool 而非孤儿它);桥在返回前清空 queue,使每条 `tool/code-dispatch` 落在打开的 turn 内。失败 run 抛 `CodeRunFailedError`,流水线转成结构化 `isError` 供模型自纠。
### 结果大小
中间 binding 值整体跨 worker,无 per-binding 字节上限。`run_code` 返回 canonical `{ logs: string[], result?: JsonValue }`;串原文渲染,其他 JSON 根经 stack-safe pretty JSON 遍历(缩进上限 10 字符)。worker 的可配置 `maxOutputBytes`(默认 64 MiB)只用于组合序列化外层 log-array/completion-value/failure-message;只有此外层结果可 ordinary spill。
## 关键源码索引
| 主题 | 位置 |
|------|------|
| 工具注册表服务 | `packages/core/tools/README.md`、`packages/core/tools/src/` |
| 工具流水线图 | `docs/tool-execution-pipeline.md` |
| 工具编写契约 | `docs/cookbook/adding-a-tool.md` |
| canonical 输出契约 note | `.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md` |
| render-intent union note | `.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md` |
| Code Mode foundation note | `.agents/notes/implemented/feature/2026-06-15-code-mode.md` |
| code-mode typed-return note | `.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md` |
| 并行工具调用 note | `.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md` |
| 协作工具取消 note | `.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md` |
+169
View File
@@ -0,0 +1,169 @@
# 05 - 解剖真实插件:`todo_write`
`packages/todo/tool-todo/src/index.ts` 是一个最小的生产插件(226 行),集中体现了 dsh 插件系统的所有机制。本篇逐段解剖。
## 完整源码骨架
```ts
export const name = 'tool-todo'
export const inject = ['tools'] // 依赖声明:等 tools 服务就绪
export const Config = z.object({ allowParallelInProgress: z.boolean().required() })
export function apply(ctx: Context, config: Config): void {
const allowParallel = config.allowParallelInProgress
// 条件依赖:仅当 sessionProjections seam 被组合时激活
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
schema: todosProjectionSchema,
init: () => null,
apply: (state, event) => { // 从会话日志投影
if (event.type === 'todo/write') return event.data.todos
if (event.type === 'turn/start') return null
return state
},
view: state => state,
stateVersion: 2,
})
})
// 注册工具(返回 disposerfiber 卸载即注销)
ctx.tools.register(defineTool({
name: 'todo_write',
description: describe(allowParallel),
parameters: { todos: { type: 'array', required: true, items: { ... } } },
output: { schema: { type: 'object', ... }, render: (_args, value) => [{ type: 'text', text: ... }] },
execute(args, exec) {
const todos = toTodoList(args.todos, allowParallel) // 校验 DSL 表达不了的约束
if (!exec.agent) throw new Error('todo_write requires an owning agent session')
exec.agent.session.append('todo/write', { todos }) // 追加 durable 会话事件
return Promise.resolve({ todos: ..., counts: { ... } })
},
presentCall: args => ({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: args.todos }),
}))
}
```
## 体现的机制
### 1. 函数插件形态 + 元数据
```ts
export const name = 'tool-todo'
export const inject = ['tools']
```
这是 Cordis 的**函数插件**形态(`registry.ts:121-123`):导出 `name``inject``Config``apply``inject = ['tools']` 声明依赖--插件只在 `ctx.tools` 服务存在时激活。Named export 保留 loader injection metadata。
### 2. 配置驱动行为(无硬编码 tunable)
```ts
export interface Config {
allowParallelInProgress: boolean
}
export const Config = z.object({
allowParallelInProgress: z.boolean().required(),
})
```
`allowParallelInProgress` 是部署选择,从 `cordis.yml` config 传入。它改变两件事:
- **工具描述**`describe(allowParallel)`,74-78 行):并行时让模型把每个活跃任务标 `in_progress`;单活时要求恰好一个
- **校验逻辑**`toTodoList`91-111 行):`!allowParallel && active > 1` 时拒绝
这体现了 `CLAUDE.md`*「No hardcoded tunables in plugins: 部署变化的选择是校验过的 `Config` 字段,可从 cordis.yml 改」*
### 3. 条件依赖(ctx.inject
```ts
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register(...)
})
```
`ctx.inject(['sessionProjections'], cb)``ctx.plugin({inject, apply: cb})` 的简写(`registry.ts:300-302`)。这意味着:**仅当 `sessionProjections` seam 被组合时**这个子块才激活。headless 装配不含投影 seam 时,此块不执行,工具仍正常注册。注释也明示:*「headless assemblies without the seam stay unaffected」*。
### 4. 注册即 effect
```ts
ctx.tools.register(defineTool({ ... }))
```
`register()` 返回 disposer,随调用 fiber 卸载自动注销工具(见 [01-cordis-framework.md](01-cordis-framework.md) 第三节「注册即可逆 effect」)。同样 `sessionProjections.register()` 也是 effect。注释 *「Registration is effect-based: disposing the plugin fiber unregisters the tool」*
### 5. model-visible ⟺ logged
```ts
execute(args, exec) {
// ...
exec.agent.session.append('todo/write', { todos })
return Promise.resolve({ todos: ..., counts: { ... } })
}
```
工具**不直接改状态**,而是 `exec.agent.session.append('todo/write', { todos })` 追加一条 durable 会话事件。UI 从事件流投影渲染(见下条)。这体现 *「model-visible means logged: 任何进入模型请求的内容都能从会话日志重建;新模型可见输入必须新 session event」*。canonical 返回值是 execution-localUI 状态来自投影。
### 6. 从会话日志投影 UI 状态
```ts
projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
init: () => null,
apply: (state, event) => {
if (event.type === 'todo/write') return event.data.todos
if (event.type === 'turn/start') return null
return state
},
view: state => state,
stateVersion: 2,
})
```
`apply(state, event)` 是 fold:最新 `todo/write` 整列表(last-write-wins),`turn/start` 清空(新 turn 重置),其他事件返回原状态。`init: () => null` 是 pre-first-write 或 turn 开始后的值。`view` 直接返回状态。`stateVersion: 2` 是投影 schema 版本。这是 *「render from the log」* 模式:UI 不调工具拿状态,而是从 durable 事件流投影。
### 7. canonical 输出 + 纯渲染
```ts
output: {
schema: { type: 'object', additionalProperties: false, properties: { todos: {...}, counts: {...} } },
render: (_args, value) => [{
type: 'text',
text: `Updated todo list: ${value.counts.pending} pending, ...`,
}],
},
```
`execute` 返回结构化 canonical 值(`todos` + `counts`),registry 快照+校验+冻结后交给 `render``render` 只做面向模型的文本。`presentCall` 返回 `card: 'generic'` UI render intent。`additionalProperties: false` 保证 logged 快照等于模型相信它写的(嵌套/扩展 item 形状在 schema 边界 fail loud 而非静默扁平化)。
### 8. 执行身份保护
```ts
if (!exec.agent) {
throw new Error('todo_write requires an owning agent session')
}
```
非 agent 调用者无 owning session 可写列表,**拒绝而非静默 no-op**。这是 *「misconfiguration fails loud」* 的体现。
### 9. 校验 DSL 表达不了的约束
`toTodoList`91-111 行)在 registry schema 校验后额外手检:
- `content` trim 后非空(`content.length === 0` 抛错)
- content 唯一(`seen.has(content)` 抛错)
- 非并行模式下至多一个 `in_progress``active > 1` 抛错)
这对应 *「Args are validated for you... You still hand-check constraints the DSL does not express, such as non-empty strings」*
## 关键源码索引
| 主题 | 位置 |
|------|------|
| 插件主源码 | `packages/todo/tool-todo/src/index.ts` |
| 类型(TodoItem 等) | `packages/todo/tool-todo/src/types.ts` |
| invariant companion | `packages/todo/tool-todo/src/invariant.ts` |
| 客户端 | `packages/todo/tool-todo/src/client.ts` |
| 测试 | `packages/todo/tool-todo/tests/*.spec.ts` |
| README | `packages/todo/tool-todo/README.md` |
+120
View File
@@ -0,0 +1,120 @@
# 06 - 贯穿全局的不变量与工程约束
这些是把插件系统各机制粘合起来的强约束(来源 `AGENTS.md`)。每条都有对应的设计决策 Agent Note 或文档。
## 一、注册与生命周期
### 注册即 effect
每个贡献走 `ctx.effect()` / `ctx.on()`registry 的 `register()` 返回 disposer。卸载插件 -> 它注册的工具、监听器、服务提供全部自动撤销(见 [01](01-cordis-framework.md) 第三节)。
### 运行时不变量断言自有关系
检查**权威事件流或可变数据**,而非服务或方法存在性、插件 metadata 或 effect、固定纯例子。无合理关系时,解释过的空 companion 是正确的(`packages/AGENTS.md` 的 package invariant rules)。
### 类型化事件用 declaration merging
事件 JSDoc 需要 `@mode` 和 payload `@param`scope key 缺于 payload 需 `@dshScopeScan unsupported``SessionEventMap` 成员默认 read-on-required - 不知其类型的 build 拒绝 log,除非事件带 envelope 的 `ignorable: true`;只有结构格式变更 bump `SESSION_FORMAT_VERSION`
## 二、模型可见性
### model-visible ⟺ logged
任何到达模型请求的内容必须能从会话日志重建;新模型可见输入需新 session event。运行时不变量断言此点(见 [05](05-plugin-anatomy.md) 第 5 点)。
## 三、扩展点哲学
### 插件,非 loop 改动
新行为挂到文档化的扩展点;改 `agent-loop` 需更新 `docs/architecture.md`
### 能力 seam 三角色完整
一个 seam 包含 Service Definition / Service Provider / Consumer 三角色,**完整,非单一角色**;仅当角色独立演化时拆分(见 [03](03-capability-seam.md))。
### 显式优于隐式(包边界)
默认值是 owning 实现里显式的 `resolve(request): Spec` 步骤,**never a hidden `?? default` inside `run()`**`dsh-shell` 的 request/spec 分裂是模板,见 [03](03-capability-seam.md) 第二节)。
### 无硬编码 tunable
部署变化的选择是校验过的 `Config` 字段,可从 `cordis.yml` 改;`DEFAULT_*` 常量或测试 hook 不是可配置性。协议常量、外部 spec、安全不变量保持固定(见 [05](05-plugin-anatomy.md) 第 2 点)。
### 误配置 fail loud
自包含时在加载时 fail,否则在最早可解析点;never silently skip missing referent。
## 四、类型安全
### 跨边界不透明 id 用 branded
`Branded<B>``dsh-brand`),never bare `string`。如 `ToolExecutionToken` 是 fresh branded `Symbol`,支持 equality correlation only,不跨 model/log/worker 边界。
### 信任 TS 在 typed 同进程边界
不为静态接口要求的值加运行时校验、fallback 行为、敌意输入测试;在 parser/config、queued、model/tool JSON、durable/file、worker、process、wire 边界校验。
### switch on discriminant tags
闭合 union 以 `assertNever` 结尾;merge-extensible union fall through 文档化的 default。
### waterfall 监听器必须调 `next()`
返回不调 `next()` 短路整条链(见 [01](01-cordis-framework.md) 第四节)。
## 五、工程纪律
### 偏好维护的依赖而非手搓
当依赖真的删除自有代码和测试时(policy:`.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md`)。
### Source plane vs artifact plane 不混
静态 gate 和 test 通过 tsconfig `paths` 解析 workspace import 到 `src`clean tree 上过;消费 built `lib/` 的 gate 声明该依赖。
### 每个 package 一个 aggregate compiler face
`api/remotes` 例外;repo-wide 程序 seed 一个 face confignever root solution。
### 空 catch 命名吞了什么
并解释为何无他物可达;`try` 保持一句。
### 不注释代码显然的事实
### 偏好对称(平行值)
未解释的不对称通常信号漏了提取。
### 测试描述行为非正确性
用测试改过时行为;在 PR 解释为何。
### 非平凡改动必带 Agent Note
同一 PR;仅机械/局部编辑豁免(scope:`.agents/notes/README.md#when-to-write-one`)。归档 note 冻结:never edit 或当作当前权威。
### 测试策略
每个非平凡模型/产品用户可见行为变更通过真实可运行 example 在同 PR 加/更新 keyless snapshotpackage test、e2e-only assertion、mock-only fixture 不替代装配应用 transcript。fixture 需 macOS/Linux 可重放;修 fixture 不修 normalizer。
### 工具 UI render intent 是设计的一部分
前置决定(`generic`/`terminal`/`diff``locations`);presentation 方法是 `args` 的纯函数。
### PR 历史
拆独立变更;propagation 前修 introducing PR。standalone PR 和 official stack 可 review 后 merge-forward 或 rebase。rewrite 用 `--force-with-lease`remote 移动则 abortnever raw `--force`
## 关键引用
| 主题 | 文档 |
|------|------|
| 防御模式 | `docs/defensive-patterns.md` |
| 类型安全与文档 | `AGENTS.md` 的 Type safety and documentation 节 |
| 测试策略 | `docs/testing.md` |
| Cordis 语义 primer | `docs/cordis-primer.md` |
| 术语表 | `docs/glossary.md` |
| capability seam glossary | `docs/glossary.md#capability-seam` |
| package invariant rules | `packages/AGENTS.md` |
| Agent Note scope | `.agents/notes/README.md#when-to-write-one` |
+124
View File
@@ -0,0 +1,124 @@
# 07 - 关键源码文件索引
本索引汇总分析中引用的全部源码文件与行号,按主题分组。便于按文件查阅或追踪实现。
## 框架层(vendored Cordis
| 主题 | 文件 | 行 |
|------|------|-----|
| Context 类 + 三作用域 | `vendor/cordis/src/context.ts` | 42-146 |
| Context 代理陷阱 | `vendor/cordis/src/reflect.ts` | 135-206 |
| Service 基类 | `vendor/cordis/src/service.ts` | 1-115 |
| 插件形态 + inject 元数据 | `vendor/cordis/src/registry.ts` | 92-187 |
| `ctx.plugin()` / `ctx.inject()` | `vendor/cordis/src/registry.ts` | 300-336 |
| Fiber 状态机 | `vendor/cordis/src/fiber.ts` | 147-154 |
| `ctx.effect()` | `vendor/cordis/src/fiber.ts` | 418-561 |
| epoch 重载机制 | `vendor/cordis/src/fiber.ts` | 611-696 |
| 五种分派模式 | `vendor/cordis/src/events.ts` | 131-243 |
| `ctx.on()` 注册 | `vendor/cordis/src/events.ts` | 288-302 |
| `provide()` 可逆注册 | `vendor/cordis/src/reflect.ts` | 277-305 |
| 依赖通知 `notify()` | `vendor/cordis/src/reflect.ts` | 314-336 |
| 框架 index | `vendor/cordis/src/index.ts` | - |
## 启动组合层
| 主题 | 文件 | 行 |
|------|------|-----|
| `EntryOptions` 行格式 | `vendor/loader/src/config/entry.ts` | 8-22 |
| `!!js` YAML 标签 + `entryListSchema` | `vendor/include/src/index.ts` | 9-23 |
| `evaluate` / `interpolate` / `isJsExpr` | `vendor/loader/src/config/utils.ts` | 5-27 |
| `disabled``!!js` 求值 | `vendor/loader/src/config/entry.ts` | 104-108 |
| Loader `internal/config` 插值 hook | `vendor/loader/src/index.ts` | 92-101 |
| `EntryTree.import`(模块解析) | `vendor/loader/src/config/tree.ts` | 145-162 |
| `EntryGroup.update`(事务式应用) | `vendor/loader/src/config/group.ts` | 59-106 |
| `applyEntryPatches`patch 算法) | `vendor/include/src/index.ts` | 58-128 |
| `PatchOptions` 形状 | `vendor/include/src/index.ts` | 145-156 |
| `boot()` | `packages/boot/app-boot/src/index.ts` | 757-802 |
| `mountRootInclude()` | `packages/boot/app-boot/src/index.ts` | 486-529 |
| `renderConfigDump()` + `groupedDump()` | `packages/boot/app-boot/src/index.ts` | 379-473 |
| `watchUserPatches()` | `packages/boot/app-boot/src/index.ts` | 232-265 |
| `loadOptionalPatches` / overlay 解析 | `packages/boot/app-boot/src/index.ts` | 278-338 |
| `userPatchesSchema = entryListSchema` | `packages/boot/app-boot/src/index.ts` | 207 |
| `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` | `packages/boot/app-boot/src/profile.ts` | 114-125 |
| `initProfile` / `healProfilesModuleFallback` | `packages/boot/app-boot/src/profile.ts` | 152-168, 223-255 |
| `resolveBundleDir`(双锚) | `packages/boot/app-boot/src/profile.ts` | 344-355 |
| `loadProfile` | `packages/boot/app-boot/src/profile.ts` | 371-403 |
| `composeEntries`(单次 flatten | `packages/boot/app-boot/src/profile.ts` | 413-420 |
| `composeProfile`(层顺序) | `apps/cli/src/profile-boot.ts` | 142-171 |
| `allPatches` / `composeLive` | `apps/cli/src/profile-boot.ts` | 122-129, 240-245 |
| `prepareProfile` / `homePatchPath` / `PROFILE_ROOT_CONFIG` | `apps/cli/src/profile-boot.ts` | 49-51, 60-64, 98-103 |
| `runDumpConfig` | `apps/cli/src/dump-config.ts` | 30-52 |
| CLI flag 解析 | `apps/cli/src/args.ts` | 83-102 |
| CLI 分派 | `apps/cli/src/bin.ts` | 45-49 |
| `dshHomePath`(暴露给 `!!js` | `packages/util/home-paths/src/index.ts``packages/boot/app-boot/src/index.ts` | 98-100, 770 |
| bundle 声明(base/web/headless | `packages/bundle/{base,web-app,headless}/package.json` | 36-40 / 41-45 |
| patch 实例 | `packages/bundle/{base,headless}/cordis.patch.yml` | - |
## 能力 seamshell 例子)
| 主题 | 文件 |
|------|------|
| Service Definition | `packages/shell/shell/src/index.ts``packages/shell/shell/src/types.ts` |
| Service Providerlocal | `packages/shell/bash-local/src/index.ts` |
| Service Providersandbox 重写) | `packages/shell/bash-sandbox/src/index.ts:84-86` |
| Consumer / Tool | `packages/shell/tool-bash/src/index.ts` |
| 组合接线 | `examples/headless-agent/cordis.yml:37-40` |
| 文档契约 | `docs/capability-seams.md:448`(表)、mermaid 图 206-207/240/362-363 |
| cookbook 参考 | `docs/cookbook/adding-a-tool.md:5` |
## 工具注册表
| 主题 | 文件 |
|------|------|
| 工具服务 README | `packages/core/tools/README.md` |
| 工具服务源码 | `packages/core/tools/src/` |
| 工具流水线图 | `docs/tool-execution-pipeline.md` |
| 工具编写契约 | `docs/cookbook/adding-a-tool.md` |
| canonical 输出契约 note | `.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md` |
| render-intent union note | `.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md` |
| Code Mode foundation note | `.agents/notes/implemented/feature/2026-06-15-code-mode.md` |
| code-mode typed-return note | `.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md` |
| 并行工具调用 note | `.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md` |
| 协作工具取消 note | `.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md` |
## 真实插件范例
| 主题 | 文件 |
|------|------|
| 插件主源码 | `packages/todo/tool-todo/src/index.ts` |
| 类型(TodoItem 等) | `packages/todo/tool-todo/src/types.ts` |
| invariant companion | `packages/todo/tool-todo/src/invariant.ts` |
| 客户端 | `packages/todo/tool-todo/src/client.ts` |
| 测试 | `packages/todo/tool-todo/tests/*.spec.ts` |
| README | `packages/todo/tool-todo/README.md` |
## 核心架构文档
| 文档 | 位置 |
|------|------|
| 架构总览 | `docs/architecture.md` |
| Cordis primer | `docs/cordis-primer.md` |
| Cordis tutorial | `docs/cordis-tutorial/index.md` |
| 防御模式 | `docs/defensive-patterns.md` |
| 术语表 | `docs/glossary.md` |
| 测试策略 | `docs/testing.md` |
| 开发指南 | `docs/development.md` |
| 扩展 cookbook | `docs/cookbook/extension-cookbook.md` |
| 加包 cookbook | `docs/cookbook/adding-a-package.md` |
| 加 LLM 适配器 cookbook | `docs/cookbook/adding-an-llm-adapter.md` |
| 事件 producer/consumer | `docs/event-producer-consumer.md` |
| 能力 seams | `docs/capability-seams.md` |
| agent 生命周期 | `docs/agent-lifecycle.md` |
| module graph(生成) | `docs/module-graph.md` |
| tool catalog(生成) | `docs/tool-catalog.md` |
| config catalog(生成) | `docs/config-catalog.md` |
## 规模与背景
- 版本:`0.1.0-rc.5`developer preview
- 可发布 npm 包:226 个(`@deepseek-ai/dsh-*`
- 包分组:~50 个
- `packages/*/src` TS~198K 行
- 测试:692 spec + 44 e2e
- commit 总数:~12,293;贡献者:37
-`.codegraph/` 索引
+41
View File
@@ -0,0 +1,41 @@
# DeepSeek Harness 插件系统分析
本目录是对 dsh 插件系统实现的详细分析,基于源码阅读整理。覆盖从底层框架(vendored Cordis)到组合层、能力层、工具层,并解剖一个真实插件。
## 文档结构
| 文档 | 主题 | 关键源码 |
|------|------|----------|
| [01-cordis-framework.md](01-cordis-framework.md) | 框架底座:Context 代理、Service、Fiber 生命周期、可逆 effect、五种事件分派 | `vendor/cordis/src/` |
| [02-boot-composition.md](02-boot-composition.md) | 启动组合:cordis.yml 行格式、`!!js` 延迟求值、profile/bundle/patch 四层叠加、patch 算法 | `vendor/loader/``vendor/include/``packages/boot/app-boot/` |
| [03-capability-seam.md](03-capability-seam.md) | 能力接缝三角色模式(Service Definition / Provider / Consumer),以 shell seam 完整追踪 | `packages/shell/` |
| [04-tool-registry.md](04-tool-registry.md) | 工具注册表与执行流水线、canonical 输出契约、Code Mode | `packages/core/tools/` |
| [05-plugin-anatomy.md](05-plugin-anatomy.md) | 解剖真实插件 `todo_write`,集中体现所有机制 | `packages/todo/tool-todo/src/index.ts` |
| [06-invariants.md](06-invariants.md) | 贯穿全局的不变量与工程约束 | `AGENTS.md` |
| [07-file-index.md](07-file-index.md) | 关键源码文件索引 | — |
## 速览:一句话总结
dsh 的插件系统是「Cordis 代理 + 声明式依赖 + 可逆 effect 注册 + 五种事件分派」作底座,叠上「cordis.yml 行 + profile/bundle/patch 层叠加」的配置组合层,再用「Service Definition/Provider/Consumer 三角色 seam」封装可替换能力,所有插件通过 `ctx.<key>` 互相发现、通过 waterfall 事件挂策略——所以换一个 provider 就能连带迁移一整类能力,而工具和 loop 一行不改。
## 数据背景
- 版本:`0.1.0-rc.5`developer preview,预告破坏性变更)
- 规模:226 个可发布 npm 包、~50 个包分组、`packages/*/src` ~198K 行 TS
- 测试:692 个 spec + 44 个 e2e
-`.codegraph/` 索引,分析基于源码直接阅读
## 使用说明
本目录是分析笔记,**不属于 dsh 官方 docs 体系**(未走 doc-sync 门禁、非双语)。查阅时建议按编号顺序阅读,每篇文档末尾附相关源码的文件路径与行号引用。
## 快照基准
本分析基于以下 commit 的检出整理:
- commit`abe560f81edebe5f6a5b62706ff502daa0dccd40``abe560f81e`
- 版本:`0.1.0-rc.5`
- 日期:2026-08-13
- 提交:`release(dsh): 0.1.0-rc.5`
后续代码演进可能使引用的 `file:line` 偏移;结构层面的分析在架构大改前应当稳定。若需核对,可在该 commit 上 `git checkout` 后比对。