从一条可观察的路径认识仓库
本页以同目录的 deepseek-harness-master 源码快照为依据。路径链接指向本地文件,源码框标题注明行号。先跟读 Headless 这条短路线,再看 Web 怎样汇合。源码框默认显示中文注释和整理后的缩进。标题的行号对应仓库原文,切换视图后「复制」会复制当前所见版本。Cordis、架构、启动和任务章节都配有 Archify 交互图。图中的 SRC 标注与本页摘录对应到同一份本地源码快照。
基础语法可以回看第一份教程。TypeScript 从第一行开始
读源码前的语法补课
先掌握后面实际会遇到的写法。每小节先看可运行的小例子,再定位仓库里的用法。
上一份 TypeScript 从第一行开始 已经讲过对象解构、交叉类型、字面量联合、keyof、infer、satisfies 和异步迭代。本章在这些基础上继续。下面的「练习」块可以单独运行。「仓库摘录」默认显示带中文注释的阅读版。点击「看原文」可切回同一行号范围的原始代码。摘录可能只包含函数的一部分。JSONC 配置含阅读注释,JSONL 日志会把每条事件展开成多行。复制配置或日志原文时,先切换到原文。
1. import.meta 与入口保护
import.meta 是模块元信息。import.meta.url 给出当前模块的 URL,可用 new URL("./x", import.meta.url) 相对文件定位资源。它和随启动目录变化的 process.cwd() 解决不同问题。支持 import.meta.main 的 Node 版本还可以用它区分入口和被导入的模块。顶层 await 让直接入口等待 runCli() 完成。仓库在 bin.ts 末尾正是这样做的。
// 直接运行这个文件时,main 为 true。被另一个模块导入时为 false。
console.log(import.meta.main);
// 用当前文件的 URL 作为基准,找同目录下的 settings.json。这里只看路径,不读文件。
const settings = new URL("./settings.json", import.meta.url);
console.log(settings.pathname.endsWith("/settings.json"));若从另一个 ESM 文件导入 main.mts,第一行变成 false。仓库同时使用 await import("./profile-boot.ts")。这是执行到某个分支时才加载的动态导入,解构出的 runProfile 是那个模块的命名导出。
2. 声明合并、模块扩充与空的 type import
同一个模块里的同名 interface 可以合并字段。declare module "模块名" { ... } 在已是模块的文件里扩充另一个模块的现有声明。下面先看同名接口的最小版本。
// 两个同名接口的字段会合并。这里先描述 clock 方法,再补 label 属性。
interface Services {
clock: () => number;
}
interface Services {
label: string;
}
// 对象要同时满足两份声明。clock() 返回数字 7。
const ctx: Services = {
clock: () => 7,
label: "demo",
};
console.log(ctx.label, ctx.clock());项目用它给 @deepseek-ai/cordis 的 Context 增加 tools、llm 等服务键,也用它给事件接口添加名字和参数。import type {} from "某包" 把该包的类型声明纳入检查,使扩充可见,发射的 JS 会擦除这一行。相反,import "某包" 会在运行时加载模块。某个属性在类型上可见后,运行时仍要靠 profile 装配服务。必需服务写进 inject,可选服务可通过 ctx.get("键") 查询并分支处理。
// 扩充 Cordis 已存在的 Context 接口。
declare module '@deepseek-ai/cordis' {
interface Context {
// 告诉 TS 这个上下文可以有 tools 服务。实际服务要等插件挂载后才可用。
tools: ToolsService
}
}declare module '@deepseek-ai/cordis' {
interface Context {
tools: ToolsService
}
}3. 类型与值可同名,品牌类型给 ID 分组
declare const BRAND: unique symbol 声明一个具有唯一类型身份的符号名字,本身不创建运行时变量。[BRAND] 在类型里作为计算属性键,与 string 交叉后便可区分两个原本都是字符串的 ID。该交叉只影响静态检查。仓库中 SessionId 同时可以在类型位置指类型,在 SessionId(raw) 中指函数。相同技巧也出现在 interface Config 和值 Config,一个供类型检查,另一个作为 Cordis 读取的配置 schema。
// tag 是只用于类型检查的唯一标记。
declare const tag: unique symbol;
// 两个类型在运行时都是字符串,品牌让检查器区分它们。
type SessionId = string & { readonly [tag]: "SessionId" };
type JobId = string & { readonly [tag]: "JobId" };
// 这个函数给字符串加上静态类型,运行时仍返回同一个字符串。
function sessionId(value: string): SessionId {
return value as SessionId;
}
// show 要求 SessionId,把 JobId 传进来会得到类型错误。
function show(id: SessionId) {
console.log(typeof id, id);
}
show(sessionId("demo"));若传入一个静态类型是 JobId 的变量,检查器会拒绝。品牌转换自身无法验证字符串是否对应一个真实 Session,文件或网络输入仍由所属边界校验。读 SessionEvent<'turn/end'>['data']['reason'] 时,从左到右读:挑出某种事件 → 取它的 data 类型 → 再取 reason。
// SessionId 是专门用作会话标识的字符串类型。
export type SessionId = Branded<'SessionId'>
// 同名函数把普通字符串交给品牌转换函数,返回带品牌的静态类型。
export function SessionId(id: string): SessionId {
return brandString<SessionId>(id)
}/** Identifies one session in the store (and its persistence artifacts). */
export type SessionId = Branded<'SessionId'>
/**
* Brand a string as a {@link SessionId}.
* @param id - the raw session id string.
* @returns the same string with the session-id brand.
*/
export function SessionId(id: string): SessionId {
return brandString<SessionId>(id)
}4. const 泛型与 NoInfer
<const T> 是类型参数的修饰符,让调用时直接写进去的对象和数组字面量优先保留精确值。它类似在调用处保留字面量信息。NoInfer<T> 仍接受 T,但提示检查器别从这个位置反推 T。先从模式或候选列表确定类型,再用它检查第二个位置。
// T 从 items 推断。NoInfer 让 fallback 只能从已有的候选值里选。
function choose<const T extends readonly string[]>(
items: T,
fallback: NoInfer<T[number]>,
) {
// 数组第一项存在时就返回它。缺少第一项时才用 fallback。
return items[0] ?? fallback;
}
// 候选项有 read 和 write,因此 write 可以作为备用值。
console.log(choose(["read", "write"], "write"));
// choose(["read", "write"], "delete"); // 取消注释后得到类型错误DSH 的 defineTool 把参数 schema、返回 schema 保留到类型层。InferArgs<S> 从输入 schema 推出字段,InferValue<NoInfer<O>> 让回调按输出 schema 给定的类型工作。运行时另有 JSON Schema 校验。再遇到 asserts value is X 时,把它读成「函数正常返回后,检查器可将参数收窄为 X」,失败路径应抛错。
// S 保留输入参数模式,O 保留返回值模式。const 泛型有利于保留模式里的字面量。
export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
// 用这两份模式描述整份工具选项,检查器据此检查输入、执行和渲染回调。
options: DefineToolOptions<S, O>,
): ToolDefinition {export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
options: DefineToolOptions<S, O>,
): ToolDefinition {5. 同步生成器与可撤销的 effect
上一份讲过 async function*。这里的 function* 创建同步迭代器,调用函数先拿到迭代器,.next() 才推进到 yield。在 DSH 的 ctx.effect(function* (...) { ... }) 中,Cordis 接手推进并管理产出的清理函数。this: ToolsService 是 TS 的假参数,用来约束函数的接收者,调用者无需在实参中传入它。.bind(this) 则在运行时绑定接收者。
// 调用生成器函数先拿到迭代器,函数体等 next() 才执行。
function* setup() {
console.log("register");
// yield 把清理函数交给调用者,并暂停生成器。
yield () => console.log("unregister");
}
const effect = setup();
console.log("iterator ready");
// 第一次 next() 打印 register,再给出刚才 yield 的清理函数。
const first = effect.next();
// 这个小例子手动执行清理,所以接着打印 unregister。
if (!first.done) first.value();这个练习手动调用清理函数。真实 ctx.effect 会在所属 fiber 释放时处理登记的清理动作。ctx.on 也将监听绑定到生命周期。读代码时查两处:注册发生在哪个插件上下文,清理由谁负责。
// 通过 effect 让注册随插件生命周期管理。this 是 TS 的接收者类型标注。
return this.ctx.effect(function* (this: ToolsService) {
// 为执行器的提示词片段逐项注册。yield 交出对应的清理动作。
for (const section of executor.sections ?? []) {
yield this.ctx.systemPrompt.section({
name: section.name,
order: section.order,
...section.interpolate === undefined ? {} : { interpolate: section.interpolate },
text: ({ scope, toolSet }) => {
// 当前作用域选中了别的执行器时,这段提示词返回空字符串。
if (this.selectExecutor(scope) !== executor) return ''
return typeof section.text === 'function' ? section.text(this.definitions(scope, toolSet)) : section.text
},
})
}
// 没有 scope 时写全局选择,否则写入该 scope 的记录。
if (scope === undefined) this.root = record
else this.scoped.set(scope, record)
// 释放 effect 时把记录里的执行器清掉,保留该选择位置以防悄悄退回其他执行器。
yield () => {
record.executor = undefined
}
}.bind(this), 'tools.setExecutor()')
} return this.ctx.effect(function* (this: ToolsService) {
for (const section of executor.sections ?? []) {
yield this.ctx.systemPrompt.section({
name: section.name,
order: section.order,
...section.interpolate === undefined ? {} : { interpolate: section.interpolate },
text: ({ scope, toolSet }) => {
if (this.selectExecutor(scope) !== executor) return ''
return typeof section.text === 'function' ? section.text(this.definitions(scope, toolSet)) : section.text
},
})
}
if (scope === undefined) this.root = record
else this.scoped.set(scope, record)
yield () => {
// Keep an unavailable selection so a program cannot fall through to a different executor.
record.executor = undefined
}
}.bind(this), 'tools.setExecutor()')
}
/**6. @Remote、类型断言与配置里的表达式
@Remote('prompt') 是附在方法声明上的装饰器写法。读成「该方法标记为远程调用面的一部分,对外名字是 prompt」。在这个仓库中还要联系 Typert 的 Host 编译和生成步骤,以及 API Gateway 的路由。仅看这一行无法知道请求怎样传输、认证或分发。下面的 Web 旁路会跟到控制器和命令层。
invocation satisfies never 是类型穷尽检查。如果联合新增分支而 switch 忘记处理,默认分支的值就不再是 never。throw 仍负责实际执行时的失败。void run(...).catch(...) 表示刻意不等待该表达式的返回值,catch 负责处理拒绝。
cordis.patch.yml 是 YAML。- 开始列表项,缩进决定层级,id 指向配置中的行,name 指插件模块名或路径。!!js 是仓库 Loader 识别的 YAML 标签。它在允许的位置把值解释为表达式,如 task: !!js ctx.headlessStartup.task 会读取已注入服务的数据。普通 YAML 文本保持字面值。配置也可以改变插件是否挂载。
# 这份 overlay 向配置树插入两行插件。
- insert:
# 先提供命令行启动数据。
- id: headless-startup
name: '@deepseek-ai/dsh-headless/startup'
# Runner 依赖 headlessStartup,由 Loader 在依赖就绪后挂载。
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
# 带有 !!js 的值会在配置求值时从服务读取。
task: !!js ctx.headlessStartup.task
sessionId: !!js ctx.headlessStartup.sessionId
- insert:
- id: headless-startup
name: '@deepseek-ai/dsh-headless/startup'
# Reads its task and run options from the ordinary headlessStartup provider.
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.headlessStartup.task
sessionId: !!js ctx.headlessStartup.sessionId7. using 与 Symbol.dispose
using 声明一个需要在块结束时释放的资源。它和 const 类似地绑定名称,但作用域退出时会调用该值的 [Symbol.dispose](),抛错离开时也会执行。[Symbol.dispose] 是用标准符号作为方法名。异步释放另有 await using 与 Symbol.asyncDispose。Web 命令层用 using binding 让附件绑定有明确的收尾位置,binding.commit() 则是业务确认,两个动作各司其职。
{
// using 将释放操作绑定到当前花括号块。
using resource = {
// 离开块时,运行时调用这个标准符号命名的方法。
[Symbol.dispose]() {
console.log("release");
},
};
console.log("work");
}
// 走到外面的语句时,块内资源已经释放。
console.log("after");DSH 的 Cordis 串讲
先搞懂插件如何找到依赖、什么时候执行,以及离开时谁负责清理,再看 DSH 怎样用配置把它们装在一起。
Cordis 是 DSH 仓库里自带的 TypeScript 插件框架,实现在 vendor/cordis。它解决的是应用组装问题。例如工具要注册到工具表,工具表可能又依赖系统提示词服务。Cordis 负责把这些组件挂起来、按服务依赖决定就绪时机、在它们之间传递事件,并管理卸载。模型请求、工具权限、对话历史这些业务规则仍由 DSH 各自的插件实现。
| 名字 | 先怎么理解 | DSH 里怎么看 |
|---|---|---|
| Plugin | 一块要挂载的代码。它可以提供服务,也可以贡献工具、监听器或配置。 | todo_write 的插件入口导出 apply(ctx, config)。 |
| Context | 插件访问服务和注册能力的入口。传进 apply 的 ctx 带着当前插件和作用域的信息。 | ctx.tools、ctx.llm、ctx.sessions。 |
| Service | 通过一个名字发布的能力。消费方使用这个名字和接口。 | 通过 ctx.tools 获取工具注册表,通过 ctx.agents 请求创建 Agent。 |
| Fiber | 某个插件实例的运行句柄,记录依赖、状态、子插件和清理动作。 | ctx.plugin(...) 返回 fiber,await fiber.dispose() 等它卸载完。 |
| Effect | 一项与插件存续期绑定的注册或资源,同时记录如何撤销。 | 注册的工具与事件监听随所属插件卸载。 |
| Loader / Include | 把配置中的条目变成插件实例。Include 处理配置树和 patch,Loader 处理条目、解析与挂载。 | 从选定的 profile 加载 YAML 条目,再交给 Cordis 核心管理生命周期。 |
1. 一个插件怎样提供服务,另一个怎样等它
下面直接调用仓库 vendored Cordis 做实验。完成本项目的依赖安装后,把示例存为仓库根目录的 scratch-cordis.mts,执行 pnpm exec tsx scratch-cordis.mts。示例只创建一个隔离的内存 Context,无需模型 API key。这里用代码挂载来观察机制,正式 DSH 的 Web 和 Headless 启动仍走 dsh profile。
import { Context, Service } from './vendor/cordis/src/index.ts';
// 这段只让检查器认识 ctx.greeter。实际实例由下面的插件提供。
declare module './vendor/cordis/src/context.ts' {
interface Context {
greeter: Greeter;
}
}
class Greeter extends Service {
constructor(ctx: Context) {
// 用 greeter 这个服务名注册当前实例。
super(ctx, 'greeter');
}
greet(name: string): string {
return `Hello, ${name}!`;
}
}
const app = new Context();
const consumer = app.plugin({
// 故意先挂载消费方。greeter 尚未出现,apply 暂时不会运行。
inject: ['greeter'],
apply(ctx: Context) {
console.log(ctx.greeter.greet('Ada'));
// 外层函数安装 effect,返回的内层函数用于卸载。
ctx.effect(() => () => console.log('consumer: 清理'));
},
});
await consumer;
console.log('consumer: 等待依赖');
// 提供方就绪后,Cordis 自动激活刚才等待的消费方。
const provider = app.plugin(Greeter);
await provider;
await consumer;
// 依赖消失后,消费方也要清理已注册的效果。
await provider.dispose();
await consumer;
console.log('provider: 已卸载');
await app.fiber.dispose();读一遍这三个位置就能分清分工。declare module 是静态类型提示,super(ctx, 'greeter') 是运行时注册,inject: ['greeter'] 是启动和持续依赖。需要这个服务才能工作的插件声明 inject。服务缺少时仍可正常工作的功能可以查询 ctx.get('服务名'),再处理缺失分支。
2. 底层为什么能做到先等待、再运行
new Context() 会创建一个 Proxy,并安装服务解析、事件、插件注册等内建能力。Proxy 可以接管属性读取,所以 ctx.tools 看着像访问字段,实际可能根据当前 fiber 的注入和作用域找到服务实例。服务提供方通过 ctx.provide() 或 Service 基类登记,依赖方看到的是当前作用域可用的实现。
/** Create the root context and install the built-in services. */
constructor() {
this[symbols.isolate] = Object.create(null)
this[symbols.intercept] = Object.create(null)
// 用 Proxy 包装 Context,属性读取交给 Cordis 的反射层处理。
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
this.baseUrl = undefined
// 根 Context 自带运行句柄和服务解析、注册表、事件、日志等基础设施。
this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])
this.reflect = new ReflectService(self)
this.registry = new RegistryService(self)
this.events = new EventsService(self)
this.logger = new LoggerService(self)
this.fiber._disposables.clear()
return self
} /** Create the root context and install the built-in services. */
constructor() {
this[symbols.isolate] = Object.create(null)
this[symbols.intercept] = Object.create(null)
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
this.baseUrl = undefined
this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])
this.reflect = new ReflectService(self)
this.registry = new RegistryService(self)
this.events = new EventsService(self)
this.logger = new LoggerService(self)
this.fiber._disposables.clear()
return self
}下一段是 Fiber 计算依赖是否可用的核心。它把当前修订和各服务实现的标识拼成 epoch。缺少必需服务时用 INACTIVE 表示当前绑定不能运行。实现被替换时标识会变化,Cordis 由此知道何时需要卸载旧绑定并重新加载。等待期间是 PENDING,执行插件时是 LOADING,成功后才到 ACTIVE。出错和主动卸载有各自的状态。
_refresh() {
let epoch = `${this._revision}`
// 逐个检查 inject 中声明的服务。
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
// 有任意必需服务缺失,本轮绑定就不可激活。
if (!impl) {
epoch = INACTIVE
break
}
// 记录具体实现的 ID。替换实现也会改变这一串标识。
epoch += ':' + impl.uid
}
// 交给状态机决定下一步。
this._setEpoch(epoch)
} _refresh() {
let epoch = `${this._revision}`
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) {
epoch = INACTIVE
break
}
epoch += ':' + impl.uid
}
this._setEpoch(epoch)
}两个常见误会。YAML 行顺序决定配置如何组合,插件能否启动还要看依赖是否就绪。ctx 也有作用域。DSH 可以为一个 Agent 组合不同的服务,在隔离范围内让相同名字的服务指向不同实例。排查依赖时要看消费方自己的 ctx。
3. 卸载为什么能把工具和监听器也一起撤掉
ctx.effect(() => { ...; return dispose; }) 把注册和撤销绑定成一件事。插件自己的定时器、连接或 watcher 可以在这里建立,再返回关闭操作。ctx.on 已经内建这种机制,所以插件通常直接注册事件。看底层实现,真正保存在 fiber 上的是一个负责登记监听、并返回取消登记函数的 effect。
private register(label: string, name: string | symbol, callback: any, options: EventOptions): () => void {
// prepend 决定监听器插到前面还是尾部。
const method = options.prepend ? 'unshift' : 'push'
// 这条监听属于当前 fiber。
return this.ctx.fiber.effect(() => {
const hooks = this._hooks[name] ??= []
hooks[method]({ ctx: this.ctx, callback, ...options })
// 卸载时从事件表移除这一条回调。
return () => this.unregister(name, callback)
}, label)
} private register(label: string, name: string | symbol, callback: any, options: EventOptions): () => void {
const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => {
const hooks = this._hooks[name] ??= []
hooks[method]({ ctx: this.ctx, callback, ...options })
return () => this.unregister(name, callback)
}, label)
}DSH 的工具注册也有类似的所有权。前面的服务实验卸载了 provider,消费插件随之清理。第 07 章的 todo_write 测试则直接验证,释放贡献工具的 fiber 后,ctx.tools.schemas() 中就找不到该工具。需要有序清理多个资源时,可以把它们放进一个 disposer,按顺序等待。
4. 服务是直接调用,事件用来通知或拦截
想做一件明确的事时,调用服务方法,例如 ctx.tools.execute(...)。想观察已经发生的事,可监听 session/event。想在原动作前后插入策略、审批或包装,先找这个扩展点定义的事件模式。
| 调用方式 | 顺序与返回 |
|---|---|
ctx.emit(...) | 同步通知监听器,不等待它们返回的 Promise。常用于发送观察事件。 |
await ctx.parallel(...) | 并发调用监听器,并等它们全部结算。 |
await ctx.serial(...) | 逐个调用并等待,遇到符合约定的返回值时停止。 |
ctx.bail(...) | 同步检查监听器,遇到符合约定的返回值时停止。和 serial 一样,undefined、null、false 不会触发停止。 |
ctx.waterfall(..., fallback) | 把监听器套在默认动作外面。监听器调用 next() 才继续,可以包装返回值,也可以提前结束。异步链由调用者等待。 |
import { Context } from './vendor/cordis/src/index.ts';
// 约定这个事件收一个字符串,以及继续执行的 next 函数。
declare module './vendor/cordis/src/events.ts' {
interface Events {
'demo/format'(text: string, next: () => string): string;
}
}
const app = new Context();
app.on('demo/format', (_text, next) => {
// 先执行下游,再加工它的返回值。
return '[' + next() + ']';
});
app.on('demo/format', (text, next) => {
// 拦截时直接返回,最里面的默认动作就不会运行。
if (text === 'block') return '已拦截';
return next();
});
for (const text of ['Ada', 'block']) {
const result = app.waterfall('demo/format', text, () => {
console.log('执行默认动作:' + text);
return 'Hello, ' + text;
});
console.log(result);
}
await app.fiber.dispose();这和 DSH 的 tools/pre-execute 是同一种结构。工具管线提供默认的 allow,插件监听器可以选择委托给下游,或按约定返回 ask、deny 等决定。ask 会走审批服务,最终 guard 和取消检查仍由 DSH 自己处理。只做记录的监听器应继续调用 next()。Cordis 的实现还会拒绝同一监听器重复调用 next()。
waterfall(...args: any[]) {
const [thisArg, callbacks] = this._resolve('waterfall', args)
// 把最后一个参数取出作为最内层的默认动作。
const inner = args.pop()
const dispatch = () => {
// 每次 dispatch 取出下一个监听器,链结束才运行默认动作。
const callback = callbacks.shift()
if (!callback) return inner()
let called = false
// 交给当前监听器的 next 会继续进入 dispatch,并且只能调用一次。
const next = () => {
if (called) throw new Error('next() called multiple times')
called = true
return dispatch()
}
return Reflect.apply(callback, thisArg, [...args, next])
}
return dispatch()
} waterfall(...args: any[]) {
const [thisArg, callbacks] = this._resolve('waterfall', args)
const inner = args.pop()
const dispatch = () => {
const callback = callbacks.shift()
if (!callback) return inner()
let called = false
const next = () => {
if (called) throw new Error('next() called multiple times')
called = true
return dispatch()
}
return Reflect.apply(callback, thisArg, [...args, next])
}
return dispatch()
}try {
// 按调用所属 Agent 选择策略作用域。
const carrier = scopeTarget(this.tools, exec.agent)
// 进入工具执行前的 waterfall,默认返回 allow。
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
// 遇到 ask 决定时,交给审批流程解析。
const askResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate)
: { decision: gate, approvalCancelled: false } try {
const carrier = scopeTarget(this.tools, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const askResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate)
: { decision: gate, approvalCancelled: false }5. Loader 怎样把 DSH 配置变成插件树
把 profile 当成某种应用的装配方案。bundle 是可组合的一层条目,patch 按 id 修改已有条目或插入新条目。组合后的每个条目告诉 Loader 到哪里找插件、给它什么配置、它还要等哪些服务。DSH 的 boot() 创建根 Context、安装 Loader、提供启动环境,最后通过 Include 挂载配置树。下一章的架构图和启动章会再把这条链画出来。
以 Headless 为例,headless-startup 解析命令行,通过 ctx.provide 发布名为 headlessStartup 的服务。另一个 YAML 条目声明要注入该服务。配置里的 !!js 会等到目标条目的依赖就绪后,在对应上下文里求值。任务字符串由此从命令行到达 Runner。
// 发布 headlessStartup 服务,Runner 从中读取已解析的启动数据。
ctx.provide(HEADLESS_STARTUP_SERVICE, {
task,
sessionId,
json: options.json === true,
// 只有明确提供的可选参数才进入服务值。
...options.permissionMode === undefined ? {} : { permissionMode: options.permissionMode.trim() },
...options.approvalPolicy === undefined ? {} : { approvalPolicy: options.approvalPolicy },
...options.addDir === undefined ? {} : { additionalWritableRoots: [...new Set(options.addDir)] },
} satisfies HeadlessStartupValues) ctx.provide(HEADLESS_STARTUP_SERVICE, {
task,
sessionId,
json: options.json === true,
...options.permissionMode === undefined ? {} : { permissionMode: options.permissionMode.trim() },
...options.approvalPolicy === undefined ? {} : { approvalPolicy: options.approvalPolicy },
...options.addDir === undefined ? {} : { additionalWritableRoots: [...new Set(options.addDir)] },
} satisfies HeadlessStartupValues)- insert:
# 先声明能提供启动数据的插件条目。
- id: headless-startup
name: '@deepseek-ai/dsh-headless/startup'
# 这行挂载实际跑任务的 Runner。
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
# Runner 要等 headlessStartup 可用。
inject: [headlessStartup]
config:
# 在目标插件上下文求值,读取任务和 Session ID。
task: !!js ctx.headlessStartup.task
sessionId: !!js ctx.headlessStartup.sessionId- insert:
- id: headless-startup
name: '@deepseek-ai/dsh-headless/startup'
# Reads its task and run options from the ordinary headlessStartup provider.
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.headlessStartup.task
sessionId: !!js ctx.headlessStartup.sessionId// Loader 接到配置处理事件时,先调用下游取得配置。
ctx.on('internal/config', (fiber, next) => {
const config = next()
if (!fiber.entry || fiber.parent.fiber?.entry === fiber.entry) return config
if (fiber.runtime?.callback[EntryGroup.key]) return config
// 普通条目用自己的 ctx 解析 config 里的表达式。
return interpolate(fiber.ctx, config)
}) ctx.on('internal/config', (fiber, next) => {
const config = next()
if (!fiber.entry || fiber.parent.fiber?.entry === fiber.entry) return config
// Tree carriers preserve the expressions owned by their child entries.
if (fiber.runtime?.callback[EntryGroup.key]) return config
return interpolate(fiber.ctx, config)
})6. 下次看到 DSH 插件,按这个顺序读
- 在 bundle 或 overlay 查
id和name,确认这个条目由哪种 profile 挂载。 - 在模块入口查
inject与apply。它是提供一个服务,还是给现有服务注册工具、监听器或其他贡献? - 从消费方的
ctx.服务名或事件名反查 provider、事件声明和分发位置。类型声明和运行时注册分别看。 - 最后查清理。找
ctx.effect、ctx.on或已封装生命周期的注册方法,再看对应的卸载测试。
自测 · 为什么我的插件在配置里有一行,apply 却没打印?
先用配置输出确认该条目确实存在且启用,再查模块路径和启动诊断。若状态是 PENDING,重点看 inject 里的每个服务是否在该作用域可用。配置里有条目、模块加载完成、服务依赖就绪、apply 成功结束,是四个不同的检查点。
继续读仓库的 Cordis 中文入门 或 逐章实验。想立刻把这些名字放回 DSH 的结构,去下一章 架构与项目地图。
架构与项目地图
从用户入口找到组装层,再找到处理请求、执行工具和保存会话的代码。
DeepSeek Harness(下面简称 DSH)把模型、工具、Agent 循环、会话存储和应用入口装进同一棵 Cordis 插件树。先找组装配置,再找调用点。 看到一个包存在,只能确定它提供了实现。它在哪个应用里实际启用,由选中的 profile 及其 patch 决定。
仓库目录各管什么
从仓库根目录看,apps/ 负责把能力组装成可以运行的应用,packages/ 放这些能力的实现。下面按真实目录列出分工。想读代码,先选一行,再打开右侧的入口。
| 顶层目录 | 负责什么 | 从哪看起 |
|---|---|---|
apps/ | 产品入口和应用组装。CLI 在这里,Web 有浏览器入口,桌面端另有 Electron 外壳和 Host 启动入口。 | CLI 入口 |
packages/ | 按能力分组的工作区包,形如 packages/<组>/<包>。Agent 循环、模型适配器、工具和存储实现主要在这里。 | 工作区包总表 |
vendor/ | 仓库内维护的第三方源码。Cordis 及其 Loader、Include 等组件在这里,可以顺着查插件注入与挂载。 | vendored 包说明 |
native/ | 平台相关的原生代码与构建。看 Node 原生扩展、进程约束或平台差异时从这里查。 | 原生组件说明 |
python/ | Python SDK 与运行时打包。Python 客户端通过 dsh 的 SDK profile 与 Harness 通信。 | Python SDK 说明 |
docs/ | 架构、开发规范、子系统文档和扩展教程。读总体设计先看 architecture,新增工具或包先看 cookbook。 | 中文架构文档 |
website/ | 把仓库文档构建成 VitePress 站点的工程,负责文档展示和链接投影。 | 文档站构建 |
scripts/ | 工程命令的实现,包含构建、类型生成、检查和发布脚本。根 package.json 的 scripts 往往指到这里。 | 构建脚本 |
snapshots/ | 按 session、sdk、acp 和 web 放录制场景,用于复现输入、模型行为与预期结果。 | todo_write 场景清单 |
benchmarks/ | 性能用例与门槛,覆盖长会话、浏览器、终端 I/O 等路径。 | 性能验证说明 |
patches/ | 安装时由 pnpm 应用到特定第三方依赖版本的补丁,映射写在 patchedDependencies。 | 工作区与依赖补丁 |
.github/ | GitHub CI workflow、Issue 模板与 PR 模板。想知道远端实际跑哪些检查时看 workflows。 | CI 目录说明 |
.agents/ | 开发代理使用的仓库技能与决策笔记。要理解某条约束的来由,可以从 notes 查。 | 决策笔记索引 |
.claude/ | 面向 Claude 的仓库技能目录。 | 仓库指引 |
apps/ 里有哪些入口
| 应用目录 | 负责什么 | 从哪看起 |
|---|---|---|
apps/cli/ | 拥有 dsh 命令,解析参数、选 profile、管理插件,然后把启动交给 boot。 | bin.ts |
apps/web/ | 浏览器应用的页面入口、Vite 配置和 Web 测试。聊天界面的许多功能实现分布在 packages/client。 | index.html |
apps/desktop/ | Electron 桌面外壳与安装打包。负责窗口和与本地 Host 的协作。 | 桌面应用说明 |
apps/desktop-host/ | 桌面应用启动的 Host 进程入口,接上共享的 CLI profile 启动与 Web 应用。 | Host 包入口 |
apps/plugin-market-generator/ | 生成插件市场数据的工程。 | 市场生成器说明 |
展开 packages/ 的完整分组索引
表中目录都在 packages/ 下面。点击组名可打开该组的 README,再顺着工作区包往下读。
| 职责 | 目录及作用 |
|---|---|
| 启动和组装 |
|
| Agent 和模型 |
|
| 会话和数据 |
|
| 进程和执行环境 |
|
| 文件与外部能力 |
|
| 任务和协作 | |
| 产品界面和接口 | |
| 设置与运行支持 |
|
最容易找错的三个地方
- 改聊天页面,先找
apps/web/和packages/client/。改模型的搜索、抓取工具,找packages/web/。改文档站,找website/。 - 理解 Session 事件或模型历史,先看
packages/core/session/。改落盘或恢复,找packages/session/。改历史搜索和导出,找packages/session-query/。 - 改启动命令,找
apps/cli/。改某个 profile 默认挂哪些插件,找packages/bundle/。改通用启动过程,找packages/boot/。
按你想改的功能找源码
| 你想找什么 | 先打开 | 顺着什么找 |
|---|---|---|
| 命令、参数、默认 profile | apps/cli/src/bin.ts;apps/cli/src/args.ts | parseDshArgs → invocation.mode → runProfile。 |
| 启动时到底挂了什么 | packages/bundle/base/cordis.patch.yml;packages/boot/app-boot/src/profile-context.ts | 按 YAML 的 id 找包名,再查该包 package.json 和 src/index.ts。 |
| 发消息、排队、跑 Agent | packages/core/agent/src/index.ts;packages/core/agent-loop/src/agent.ts | 前者是接口、注册表和事件,后者是默认循环实现。找 followup、step。 |
| 工具调用和权限拦截 | packages/core/tools/src/index.ts;packages/core/tools/src/pipeline.ts | 注册在 ctx.tools。工具实现可在 packages/todo、packages/fs、packages/shell 等组。 |
| 请求模型、解析流 | packages/llm/llm/src/index.ts;packages/llm/llm-deepseek/src/adapter.ts | 先找 prepareCall 和已绑定的流,再看适配器如何访问服务端与产生 chunk。 |
| 聊天记录、回放、落盘 | packages/core/session/src/index.ts;packages/session/session-persistence-jsonl/README.md | 核心 Session 提供逻辑事件和历史投影,JSONL provider 负责物理格式。 |
| 浏览器发消息、Host API、流回传 | packages/bundle/web-app/cordis.patch.yml;packages/api/session-controller/src/index.ts | 浏览器侧先查 packages/client 与 API 的 client 入口,Host 查 controller、gateway 与 packages/host。 |
| 其他接入形态、底层与测试 | packages/README.md;pnpm-workspace.yaml | apps/desktop 是 Electron 外壳,python/ 是 Python 接入,vendor/ 放 Cordis 等 vendored 代码,native/ 放原生扩展,snapshots/ 放录制会话。 |
用包名反查源码
这里是 pnpm monorepo。packages/<组>/<包> 是目录,@deepseek-ai/dsh-* 是跨包导入的名字,二者未必同形。例如 @deepseek-ai/dsh-tools 位于 packages/core/tools。同包源码写 ./schema.ts。workspace:* 和 workspace:~ 是 pnpm 工作区依赖声明。源码检查通过 tsconfig.base.json 的 paths 找到 src。构建产物的入口则看包的 exports,例如 headless 的 . 与 ./startup 分别指向两个 lib 文件。
// 包按 ESM 解释,main 是运行入口,types 指向 TypeScript 声明。
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
// exports 列出外部可以访问的入口。
"exports": {
// 只导入包名时走这一项。类型检查与运行时各取对应文件。
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
// 在包名后加 /startup 时走这个子入口。
"./startup": {
"types": "./lib/types/startup.d.ts",
"default": "./lib/startup.js"
},
// 还显式暴露配置文件、部分源码和资源。
"./cordis.patch.yml": "./cordis.patch.yml",
"./src/*": "./src/*",
"./locale/*.json": "./locale/*.json", "type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./startup": {
"types": "./lib/types/startup.d.ts",
"default": "./lib/startup.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
"./src/*": "./src/*",
"./locale/*.json": "./locale/*.json",# 只在 packages 下的 package.json 查名字。-n 显示匹配行号。
rg -n '"name": "@deepseek-ai/dsh-tools"' packages -g package.json分清三类跨模块连接
ctx.tools.register(...) 往工具服务注册定义。ctx.agents.create(...) 从 Agent 注册表请求实例。ctx.on("session/event", ...) 观察已提交事实。Waterfall 监听收到 next,调用它才把工作交给后续处理器,直接返回可截断。inject 声明所需服务,装配顺序由依赖与 Loader 协调。在 Windows 上从源码启动
先确认工作目录和构建产物,再选择 Web、Headless 或查看组合。
这份本地目录的 package.json 版本为 0.2.2-rc.1,声明 Node >=24.9.0 和 pnpm@11.28.5。当前目录没有 .git,也没有已安装的 node_modules。阅读这份快照可以直接点链接。要开始正式开发,用带 Git 元数据的项目检出,便于版本溯源和运行工作区 hooks。命令在包含根 package.json 和 pnpm-workspace.yaml 的目录运行。
"name": "@deepseek-ai/dsh-root",
"version": "0.2.2-rc.1",
"license": "MIT",
// private 禁止把 monorepo 根包作为产品包发布。
"private": true,
"type": "module",
// 根项目固定使用这个 pnpm 版本。
"packageManager": "pnpm@11.28.5",
// 最低 Node 版本写在 engines。
"engines": {
"node": ">=24.9.0"
},
"workspaces": [
"vendor/*", "name": "@deepseek-ai/dsh-root",
"version": "0.2.2-rc.1",
"license": "MIT",
"private": true,
"type": "module",
"packageManager": "pnpm@11.28.5",
"engines": {
"node": ">=24.9.0"
},
"workspaces": [
"vendor/*",# 先检查 Node、pnpm 和 Git 的版本。
node --version
pnpm --version
git --version
# 在仓库根目录安装工作区依赖。
pnpm install
# 生成本地启动需要的构建产物。
pnpm run build
# 通过 dsh 选择 web profile。
pnpm dsh web若 pnpm 由 Corepack 管理,按照仓库开发文档配置后检查 pnpm --version。原生 Windows 和 WSL 2 都是开发选择,各自安装依赖和工具链。构建脚本会加载 tsdown.config.ts,Node 类型擦除需保持开启。路径解析失败先检查是运行环境、安装还是构建阶段出错,再去查应用代码。
| 想验证什么 | 命令 | 该观察什么 |
|---|---|---|
| 命令行是否可用 | pnpm dsh --help | 可看到 profile 和管理命令的用法,不会发起模型任务。 |
| 配置叠加结果 | pnpm dsh --profile headless --dump-config | 打印组合后的 YAML,可搜索 headless-runner。适合区分「没挂载」和「挂载后运行失败」。 |
| Web 开发 | pnpm run dev:web --no-open --port 3081 | 构建后启动,并重建浏览器 bundle。已有产物时用 pnpm run start:web 启动。 |
| 真实模型跑一次 | pnpm dsh --profile headless "只回复 OK" | 需要有效的 DEEPSEEK_API_KEY。默认 stdout 是最终文本;模型的具体字样由模型决定。 |
| 用程序读取事件 | pnpm dsh --profile headless --json "只回复 OK" | stdout 改为逐行 JSON。普通模式的 reasoning 等进度可走 stderr。 |
真实模型凭据放在仓库忽略的根 .env 或进程环境。DEEPSEEK_BASE_URL 是可选覆盖。先读脚本与配置,学习调用流程可以用下面的录制日志,之后再选择实际调用。上述命令是本地快照提供的启动路径,本教程验证了源文件与示例,实际 DSH 应用启动和真实模型调用留给有依赖的检出环境。
// 先预加载 tsx 的 ESM 支持,再执行 CLI 的 TypeScript 源码。
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:ptc": "node scripts/demo-ptc.mjs",
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml",
// 这一项提供单独的测试模型服务入口。
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
// start:web 使用已有产物启动。
"start:web": "node --import tsx/esm apps/cli/src/bin.ts web",
// dev:web 走开发脚本,负责构建、运行和监听。
"dev:web": "tsx scripts/dev-web.ts --poll", "dsh": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:ptc": "node scripts/demo-ptc.mjs",
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml",
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
"start:web": "node --import tsx/esm apps/cli/src/bin.ts web",
"dev:web": "tsx scripts/dev-web.ts --poll",node --import tsx/esm 给 Node 预加载 ESM 的 TS 支持,然后执行源码入口 apps/cli/src/bin.ts。这条运行链与上一份小项目的「先 tsc 生成 dist,再 node dist」不同。它仍然依赖需要预先生成的 workspace 和 Web 产物。分别看三条路线:
pnpm run typecheck做 Typert 生成、引用项目与聚合检查,跳过原生和浏览器打包。pnpm run build:artifacts准备 native、Typert、Host/Client 的tsc与tsdown产物,以及 Web/桌面构建所需输出。pnpm run build:check检查聚合程序,读取已有声明。刚改包源码时,先刷新 artifacts,或直接用pnpm run build。
Host 和 Client 各有一个 TypeScript 聚合工程。两边可能给 Cordis 的 Context 合并同名但含义不同的服务键,分开检查才能保留各自的环境。具体脚本、检查和约束见 docs/development.zh.md。
顺着源码追 CLI 和插件挂载
以 pnpm dsh --profile headless 为例,先看到参数如何变成一次可启动的配置组合。
1. 从命令参数到分支
// CLI 的主要入口。options 没传时用空对象。
export async function runCli(options: RunCliOptions = {}): Promise<void> {
const version = getDshRuntimeVersion()
// 从 options 取出桌面 profile 开关,其余属性交给 profile 启动器。
const { manageDesktopProfile, ...profileOptions } = options
// 跳过 Node 与入口文件路径,只解析用户输入的参数。
const invocation = parseDshArgs(process.argv.slice(2), version, manageDesktopProfile)
// mode 是分派标签。这个摘录只展示普通 profile 启动分支。
switch (invocation.mode) {
case 'profile': {
// 走到 profile 分支时才加载启动模块。
const { runProfile } = await import('./profile-boot.ts')
try {
// 把环境、profile、补丁文件和剩余参数交给 runProfile。
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
fromDefaultProfile: invocation.fromDefaultProfile,
patchFiles: invocation.patches,
args: invocation.args,
...profileOptions,
})
} catch (error) {
// 只在这里接管 StartupError。其他异常继续向外抛。
if (!(error instanceof StartupError)) throw error
// 先写出启动诊断,再用退出码 1 报告失败。
await reportStartupFailure(error, { home: resolveDshHome(), version, profile: invocation.profile })
process.exit(1)export async function runCli(options: RunCliOptions = {}): Promise<void> {
const version = getDshRuntimeVersion()
const { manageDesktopProfile, ...profileOptions } = options
const invocation = parseDshArgs(process.argv.slice(2), version, manageDesktopProfile)
switch (invocation.mode) {
case 'profile': {
const { runProfile } = await import('./profile-boot.ts')
try {
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
fromDefaultProfile: invocation.fromDefaultProfile,
patchFiles: invocation.patches,
args: invocation.args,
...profileOptions,
})
} catch (error) {
if (!(error instanceof StartupError)) throw error
await reportStartupFailure(error, { home: resolveDshHome(), version, profile: invocation.profile })
process.exit(1) }
default:
// 编译期检查是否处理完所有 mode。新增分支后漏处理会得到类型错误。
invocation satisfies never
// 如果运行时真的落入默认分支,就抛出错误。
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
}
}
// 只有直接作为入口运行时才执行 CLI。
if (import.meta.main) {
await runCli()
} }
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
}
}
if (import.meta.main) {
await runCli()
}process.argv 的前两项是 Node 和入口路径,slice(2) 得到用户传的部分。parseDshArgs 返回带 mode 的联合。普通启动走 profile,再动态导入 runProfile。--dump-config 切到单独的配置输出分支。第一个摘录里的 StartupError 分支负责输出启动故障,其他异常继续向外抛。
2. 先提供进程事实,再挂应用
// 根配置位于选中的 profile 目录。
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
// 把 profile 名称、目录、bundle 列表和覆盖层打包成一份上下文数据。
const profileContext: ProfileContext = {
name: options.profile,
// 只在传入 packageManager 时才加这个属性。
...(options.packageManager === undefined ? {} : { packageManager: options.packageManager }),
dir: composed.profile.dir, patchPath: composed.profile.patchPath,
installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
startedBundles: composed.profile.layers.map(layer => layer.packageName),
cwd: process.cwd(), home: resolveDshHome(),
overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,
}
// 把配置和启动准备函数交给 boot。下面的回调会在插件树挂载前执行。
const ctx = await boot(
NAME,
rootConfig,
readProfilePatches(NAME, profileContext, composed.profile),
async (hostCtx) => {
app.current = hostCtx
// 先提供 profile 数据与本次进程的环境,再安装包解析服务。
hostCtx.provide('profileContext', profileContext)
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
await hostCtx.plugin(PluginPackages, {
resolution: composed.resolution,
})
// 向应用插件提供参数、退出入口和就绪信号。
provideCmdline(hostCtx, {
args: options.args,
exit: code => void shutdown.shutdown(code),
ready: appReady.service,
})
},
) const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
const profileContext: ProfileContext = {
name: options.profile,
...(options.packageManager === undefined ? {} : { packageManager: options.packageManager }),
dir: composed.profile.dir, patchPath: composed.profile.patchPath,
installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
startedBundles: composed.profile.layers.map(layer => layer.packageName),
cwd: process.cwd(), home: resolveDshHome(),
overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,
}
const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext, composed.profile), async (hostCtx) => {
app.current = hostCtx
hostCtx.provide('profileContext', profileContext)
// Before any config-tree entry mounts, so plugins resolve all launch-time
// environment values from the same immutable launch snapshot.
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
await hostCtx.plugin(PluginPackages, {
resolution: composed.resolution,
})
// The command line and bounded exit request are launcher facts available
// to every app plugin that injects the argument snapshot.
provideCmdline(hostCtx, {
args: options.args,
exit: code => void shutdown.shutdown(code),
ready: appReady.service,
})
})外层 boot(..., async (hostCtx) => { ... }) 最后传入一个准备函数。它把 profile 数据、启动环境和命令行/退出请求交给 Host Context。插件随后从 Context 取这些事实,读取同一份启动配置。app.current 使启动期间的信号也能找到当前树,信号和清理链仍由启动器拥有。
// 先记下当前启动阶段,失败时用它区分准备阶段和挂载阶段。
let stage = 'host preparation failed'
try {
// 以配置所在目录作为解析基准,并提供 DSH home 路径。
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
// 先安装 Loader,再运行调用方可选的 prepare 函数。
await ctx.plugin(Loader)
await prepare?.(ctx)
stage = 'plugin tree failed to load'
// 根 Include 负责挂载配置所声明的插件树。
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)
// 等待加载结算。应用若已自行退出,Loader 可能已经被释放。
await ctx.get('loader')?.await()
if (ctx.get('loader') === undefined) return ctx
// 存活的树还要检查关键启动项。
await auditStartupEntries(ctx, binName)
return ctx let stage = 'host preparation failed'
try {
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
await prepare?.(ctx)
stage = 'plugin tree failed to load'
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)
// A surface can finish and dispose the whole tree while startup is still
// in flight, before the last entry settles. The Loader service goes with
// it, and the activation audit describes a live tree — reading `ctx.loader`
// past this point would throw a TypeError over an app that exited exactly
// as asked. Re-check after settlement before auditing the tree.
await ctx.get('loader')?.await()
if (ctx.get('loader') === undefined) return ctx
await auditStartupEntries(ctx, binName)
return ctxnew Context() 在该函数前面创建根。Loader 挂上后先 prepare,再挂根 Include,等待 Loader 的挂载结算并检查启动项。await prepare?.(ctx) 表示没有传准备函数时跳过调用。
3. Profile 叠加决定最终树
export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] {
// 按优先级顺序展开所有层,并复制组合数据,避免调用方改到原配置。
const patches = structuredClone([
// 第一层:profile 选定的 bundle。
...readBundlePatches(binName, context, initialProfile),
// 第二层:profile 自己的补丁。缺少时用空数组。
...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []),
// 第三层:Harness home 下的补丁。
...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []),
// 第四层:这次启动传入的命令行覆盖。
...context.overlays,
])
// 根据启动环境和遥测条目判断是否追加一层遥测覆盖。
const telemetryPatch = resolveTelemetryPatch(context.telemetryDisabledEnv,
composeEntries([patches]).some(row => row.id === TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) patches.push(telemetryPatch)
return patchesexport function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] {
const patches = structuredClone([
...readBundlePatches(binName, context, initialProfile),
...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []),
...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []),
...context.overlays,
])
const telemetryPatch = resolveTelemetryPatch(context.telemetryDisabledEnv,
composeEntries([patches]).some(row => row.id === TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) patches.push(telemetryPatch)
return patches正常层次从低到高是 bundle 列表 → profile 自己的 patch → Harness home 的 patch → 命令行 overlay。这段代码还会按条件在尾部加遥测覆盖。...数组 把各层的行依次展开。同一个 id 上的 config 替换应视为整块配置替换,不能假定会递归合并。修改前先看 --dump-config 的最终行。
Headless 基于共享 base,再插入 headless-startup 与 headless-runner。回到第一章的 YAML 摘录,前者提供 headlessStartup,后者从它读取任务与选项。Web 选择另一套应用层,其中的 webserver 行决定监听地址。它们继续共享 Agent 和 Session。
# 这行把 Web 的 HTTP 服务挂进当前 profile。
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
# 先等 webStartup,再根据它提供的配置决定监听地址。
inject: [webStartup]
config:
# 没有显式 host 或 port 时,使用本机地址和 3080 端口。
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080 - id: webserver
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
config:
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080从任务字符串追到模型流
读清 Agent、Turn、Step 和 Tool Call 的层次,再看它们如何接起来。
| 名词 | 这里是什么意思 | 观察点 |
|---|---|---|
| Agent | 当前进程中的运行对象,持有 inbox、状态和执行能力。 | ReactLoopAgent 实现 Agent。 |
| Session | 身份和有序事实,可能在另一次进程启动后恢复。 | session.append(...) 写逻辑事件,deriveMessages() 产生模型历史。 |
| Turn | 从开始接手工作到本轮收束,可包含零个或多个 step。 | turn/start、turn/end。 |
| Step | 一次模型请求及其关联工具执行。被拒绝或初始输入被改写为空时可以没有 step。 | step/start、step/end,中间有 assistant 结果和 tool 结果。 |
1. Headless 把 prompt 放进 inbox
run 按 sessionId 选择创建 Agent 或接管确切的既存 Session。AgentLoop 在安装时给 ctx.agents 设置 factory,注册表因此能调用默认的 Agent 实现。最终传给 followup 的是结构化用户消息。
const ctx = this.runtime.ctx
const ownership = this.ownership
// 给 Loop 注册资源清理,避免卸载时留下未完成的生命周期工作。
ctx.effect(() => () => ownership.dispose(), 'agentLoop.transactions()')
// 从 Session 日志派生模型选择与收件箱的状态。
this.modelProjectionHandle = await ctx.sessionProjections.register(modelSelectionProjectionDefinition)
this.inboxProjectionHandle = await ctx.sessionProjections.register(inboxProjectionDefinition)
// 把当前 AgentLoop 注册为 Agent 工厂。ctx.agents 后续可通过它创建实例。
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
// 给系统提示词提供当前 provider,优先使用选定模型的数据。
ctx.systemPrompt.variable('provider', context => context.modelSelection?.provider ?? context.agent?.options.provider) const ctx = this.runtime.ctx
const ownership = this.ownership
// Agent projections remain readable until teardown finishes.
ctx.effect(() => () => ownership.dispose(), 'agentLoop.transactions()')
this.modelProjectionHandle = await ctx.sessionProjections.register(modelSelectionProjectionDefinition)
this.inboxProjectionHandle = await ctx.sessionProjections.register(inboxProjectionDefinition)
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
ctx.systemPrompt.variable('provider', context => context.modelSelection?.provider ?? context.agent?.options.provider)try {
try {
// 把任务字符串包装为用户消息,提交给 Agent 的下一轮。
agent.followup(createUserMessage({
content: [{ type: 'text', text: task }],
source: { kind: 'user' },
}))
// 等待 Agent 以及相关子 Agent 的工作稳定。
const subagents = ctx.get('subagents')
while (true) {
await agent.whenIdle()
// 记录观察到的事件序号。如果等待期间状态或序号变了,就继续等待。
const idleSeq = agent.session.seq
if (!await subagents?.waitForTreeWork(agent)
&& agent.status === 'idle' && agent.session.seq === idleSeq) break
}
// 无论成功还是失败,都停止监听推理流。
} finally {
stopReasoning?.()
}
// 先要求 Session 刷新,再处理失败并汇总这一轮的结果。
await sessions.flush(agent.session)
if (failure !== undefined) throw failure.error
const outcome = summarize(agent.session, firstSeq)
// 普通模式写最终文本,JSON 模式用投影输出结束记录。
if (projection === undefined) io.stdout.write(outcome.text + '\n')
else projection.finish(outcome.text, outcome.reason)
if (outcome.reason?.kind === 'error') {
io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
}
// 只有 completed 以 0 退出,其他结果以 1 退出。
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1) try {
try {
agent.followup(createUserMessage({
content: [{ type: 'text', text: task }],
source: { kind: 'user' },
}))
const subagents = ctx.get('subagents')
while (true) {
await agent.whenIdle()
const idleSeq = agent.session.seq
// Child settlement can wake the parent before the child wait observes it.
if (!await subagents?.waitForTreeWork(agent)
&& agent.status === 'idle' && agent.session.seq === idleSeq) break
}
} finally {
stopReasoning?.()
}
await sessions.flush(agent.session)
if (failure !== undefined) throw failure.error
const outcome = summarize(agent.session, firstSeq)
if (projection === undefined) io.stdout.write(outcome.text + '\n')
else projection.finish(outcome.text, outcome.reason)
if (outcome.reason?.kind === 'error') {
io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
}
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)顺着 followup 进入 send,消息插入 inbox 后唤醒 driver。steer 以 next-step 为目标并唤醒。inject 也以 next-step 为目标,传 false 所以单独注入上下文不会唤醒 idle Agent。上面的 runner 等待 Agent 和关联子任务稳定,刷新 Session,再从事件摘要写最终文本或结束 JSON 投影,并请求进程按完成原因退出。
// 这是 followup、steer、inject 共用的入队入口。
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
// 如果要唤醒的是已取消的活动,把消息改投到下一轮。
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
// 把新消息放入目标队列。wakeup 决定要不要启动执行器。
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
if (wakeup) this.wakeDriver(wakingAfterAbort)
}
// followup 是新的用户跟进,目标是下一轮,并唤醒执行器。
followup(input: UserMessage): void {
this.send(input, 'next-turn', true)
}
// steer 让接下来的 Step 处理消息,并唤醒执行器。
steer(input: UserMessage): void {
this.send(input, 'next-step', true)
}
// inject 将上下文排到下一 Step,单独调用时不会唤醒空闲 Agent。
inject(input: UserMessage): void {
this.send(input, 'next-step', false)
} send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
// Waking input cannot join an aborted activity, so it starts the next turn.
// Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
if (wakeup) this.wakeDriver(wakingAfterAbort)
}
followup(input: UserMessage): void {
this.send(input, 'next-turn', true)
}
steer(input: UserMessage): void {
this.send(input, 'next-step', true)
}
inject(input: UserMessage): void {
this.send(input, 'next-step', false)
}2. 开启 Turn,审查下一 Step
// 记录新的 Turn 边界。Turn 可以包含多次模型 Step。
const turn = phase.turn + 1
try {
this.session.append('turn/start', { turn })
} catch (error: unknown) {
this.throwError(error)
}
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
try {
while (true) {
// 每次循环先检查取消信号,再通过 preStep 让策略审查下一步输入。
signal.throwIfAborted()
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
// 策略拒绝时结束本轮,状态记为 blocked。
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
try {
if (turnEnds && decision.messages.length === 0) break
// 首次输入变为空时正常结束,尚未发起模型请求。
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
signal.throwIfAborted()
// 通过这些检查后,才记录 step/start。
this.session.append('step/start', { turn, step })
phase.step = step
const toolRecovery = new ToolCallRecovery() const turn = phase.turn + 1
try {
this.session.append('turn/start', { turn })
} catch (error: unknown) {
this.throwError(error)
}
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
try {
while (true) {
signal.throwIfAborted()
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
try {
if (turnEnds && decision.messages.length === 0) break
// A removed waking message or an enter decision rewritten to empty
// still owns the initial turn boundary, but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
signal.throwIfAborted()
this.session.append('step/start', { turn, step })
phase.step = step
const toolRecovery = new ToolCallRecovery()先追加 turn/start,再从 inbox 处理下一批输入。preStep 会走 agent/pre-step waterfall,监听器可以接受、重写或拒绝。拒绝时收束为 blocked;第一次接受的输入为空时可以直接结束。这两条分支都先于 step/start。真正进入 step 后,才准备模型调用。
3. 绑定适配器,用日志重建请求,消费流
默认 base 把 llm-deepseek 行指向 API key 插件。它把 DeepSeek provider 注册到 ctx.llm。Loop 拿到选定 provider/model 后通过 prepareCall 绑定适配器,再构建本次请求。区分三个入口有助于定位故障:
- 凭据或 provider 安装。packages/llm/llm-deepseek-api-key/src/index.ts
- 适配器登记和通用模型接口。packages/llm/llm-deepseek/src/host.ts;packages/llm/llm/src/index.ts
- DeepSeek 请求与流解析。packages/llm/llm-deepseek/src/adapter.ts
?? Promise.resolve({ fields: {}, accept: () => Promise.resolve() }),
})
// 把具体 adapter 登记到 provider 名下。之后模型服务可按这个名字查找。
const registration = ctx.llm.registerAdapter([provider], adapter)
// 保存注册时的重试策略。配置变化时重新读取。
let registeredPolicy = dependencies.options().retryPolicy
ctx.on('loader/volatile-update', () => {
let policy: typeof registeredPolicy
try { policy = dependencies.options().retryPolicy } ?? Promise.resolve({ fields: {}, accept: () => Promise.resolve() }),
})
const registration = ctx.llm.registerAdapter([provider], adapter)
let registeredPolicy = dependencies.options().retryPolicy
ctx.on('loader/volatile-update', () => {
let policy: typeof registeredPolicy
try { policy = dependencies.options().retryPolicy }// 冻结本次请求的头部数据。
deepFreeze(header)
// 从 Session 的已提交事件派生模型可见历史。
const boundaryMessages = session.deriveMessages()
// 逐条冻结消息,已经冻结过的对象直接复用。
for (const message of boundaryMessages) {
if (this.frozenMessages.has(message)) continue
deepFreeze(message)
this.frozenMessages.add(message)
}
Object.freeze(boundaryMessages)
// 组装并冻结本次请求。这里包含历史、工具、Session ID 和取消信号。
const request = markAgentLoopRequest(Object.freeze({
...header.config,
messages: boundaryMessages,
toolHistory: session.toolHistory(),
// 有工具声明时才往请求对象里加 tools。
...header.tools !== undefined ? { tools: header.tools } : {},
sessionId: this.session.id,
signal,
}))
return request // canonicalHeader is shallow; append logs a detached snapshot, not these local values.
deepFreeze(header)
const boundaryMessages = session.deriveMessages()
for (const message of boundaryMessages) {
if (this.frozenMessages.has(message)) continue
deepFreeze(message)
this.frozenMessages.add(message)
}
Object.freeze(boundaryMessages)
const request = markAgentLoopRequest(Object.freeze({
...header.config,
messages: boundaryMessages,
toolHistory: session.toolHistory(),
...header.tools !== undefined ? { tools: header.tools } : {},
sessionId: this.session.id,
signal,
}))
return requestsession.deriveMessages() 读到本次可见的历史。请求对象同时带上工具历史、工具声明、Session 身份和取消信号。条件展开 ...header.tools !== undefined ? { tools: header.tools } : {} 可在心里加括号读成 ...(条件 ? 有字段的对象 : 空对象)。随后对流逐块 for await,同时检查取消信号。
// 用已准备的配置、工具和 turn/step 坐标组装请求。
const request = this.buildRequest(
config,
preparedCall,
assembly.tools,
{ turn, step },
startsRequestSeries,
signal,
)
// 为这次模型尝试创建流记录,实时帧通过事件发给订阅者。
const live = new AssistantStreamAttempt(
this.session.id,
++this.assistantAttemptCounter,
() => ++this.assistantStreamRevision,
turn,
step,
(frame) => { this.dispatch.emit('agent/assistant-stream', { frame }) },
)
let started = false
try {
// 优先走已绑定的模型调用,缺少绑定时走通用流入口。
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
signal.throwIfAborted()
live.start()
started = true
// 逐个等待并消费模型 chunk。收到取消时跳出这一执行路径。
for await (const chunk of stream) {
signal.throwIfAborted()
live.push(chunk)
}
signal.throwIfAborted() const request = this.buildRequest(config, preparedCall, assembly.tools, { turn, step }, startsRequestSeries, signal)
const live = new AssistantStreamAttempt(
this.session.id,
++this.assistantAttemptCounter,
() => ++this.assistantStreamRevision,
turn,
step,
(frame) => { this.dispatch.emit('agent/assistant-stream', { frame }) },
)
let started = false
try {
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
signal.throwIfAborted()
live.start()
started = true
for await (const chunk of stream) {
signal.throwIfAborted()
live.push(chunk)
}
signal.throwIfAborted()4. 模型要求调用工具时
// 把流式尝试结算成完整 assistant/message,并把事件追加到 Session。
live.settle(
'assistant/message',
() => this.session.append('assistant/message', {
turn,
step,
message,
...live.usage === undefined ? {} : { usage: live.usage },
stream: live.stream,
}, { surfaceOp: 'append' }).seq,
)
// 达到 token 上限时,保留对应的 Step 结果。
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
// 从模型消息里找工具调用。没有工具时,这条路径完成。
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
const maxParallelToolCalls = this.loopCtx.agentLoop.config.maxParallelToolCalls.get()
try {
// 把调用、可用工具集合、并发上限和日志位置交给工具执行器。
const result = await this.loopCtx.tools.execute({
calls: toolCalls, signal, agent: this, log: { turn, step },
maxParallelToolCalls, toolSet: decision.toolSet,
})
// 执行器返回的额外上下文进入下一 Step 队列。
for (const context of result.additionalContexts) {
this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context])
}
// 批次已结束就返回 completed,否则由外层决定如何继续。
return result.concluded ? { kind: 'completed' } : null live.settle(
'assistant/message',
() => this.session.append('assistant/message', {
turn,
step,
message,
...live.usage === undefined ? {} : { usage: live.usage },
stream: live.stream,
}, { surfaceOp: 'append' }).seq,
)
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
const maxParallelToolCalls = this.loopCtx.agentLoop.config.maxParallelToolCalls.get()
try {
const result = await this.loopCtx.tools.execute({
calls: toolCalls, signal, agent: this, log: { turn, step },
maxParallelToolCalls, toolSet: decision.toolSet,
})
for (const context of result.additionalContexts) {
this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context])
}
return result.concluded ? { kind: 'completed' } : null先结算完整的 assistant/message,再从内容块中找 type === "tool-call"。没有工具调用,这条路径完成。存在工具调用,则把调用数组、Agent、取消信号、turn/step 和并发限制交给 ctx.tools.execute。若工具批次还需要模型继续,返回 null 交由外层继续调度。finally 会在该 step 收尾写入 step/end。失败或取消的 assistant 尝试还可能结算为 assistant/attempt,见同一文件的错误分支。
自测 · 为什么只看到 turn/start 也可能是正常路径?
preStep 拒绝或首次重写为空时,可以从 turn/start 收束到 turn/end,没有 step/start。如果连结束事件也没有,再去检查异常和中途退出。
精读 todo_write · 一个工具如何进入系统
用真实注册、schema、执行结果和测试理解扩展点。
todo_write 一次替换整个清单。Base YAML 中的 id: tool-todo 把包挂上。入口声明依赖 tools 和 sessionProjections,在 apply 内注册投影和工具。项目中的品牌 ID、可配置策略、结构化返回值在这一条路径上都能看到。
# base bundle 默认挂上待办工具包。
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
config:
# 这一层允许多个事项同时处于 in_progress。
allowParallelInProgress: true - id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
config:
allowParallelInProgress: true// 插件名字供 Loader 标识。注册前需要工具服务和 Session 投影服务。
export const name = 'tool-todo'
export const inject = ['tools', 'sessionProjections']
// 这三个字符串是待办事项允许的状态。as const 让类型记住精确字面量。
const STATUSES = ['pending', 'in_progress', 'completed'] as const
// 接口声明 TypeScript 中的配置形状。
export interface Config {
// 开关决定能否同时有多个进行中的待办。
allowParallelInProgress: boolean
}
// 同名 Config 值是运行时 schema。这个字段必须是布尔值。
export const Config: z<Config> = z.object({
allowParallelInProgress: z.boolean().required(),
})export const name = 'tool-todo'
export const inject = ['tools', 'sessionProjections']
/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */
const STATUSES = ['pending', 'in_progress', 'completed'] as const
/** Model-facing todo tool configuration. */
export interface Config {
/**
* Required deployment choice for whether several todos may be `in_progress` at once. True suits
* agents that run work concurrently — subagents, background commands, workflow fan-out — and the
* description then instructs the model to mark every actively worked task. False restores the
* single-active discipline: the description asks for exactly one, and a call marking more is
* rejected.
*/
allowParallelInProgress: boolean
}
/** Schemastery configuration for the todo tool consumer. */
export const Config: z<Config> = z.object({
allowParallelInProgress: z.boolean().required(),
})export async function apply(ctx: Context, config: Config): Promise<void> {
// 从配置读出是否允许并行进行的事项。
const allowParallel = config.allowParallelInProgress
// 先登记 todos 投影。状态是待办数组,或还没有清单时的 null。
await ctx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
stateSchema: todosProjectionSchema,
init: () => null,
apply: (state, event) => {
// 收到 todo/write 就用整份新清单。开启下一轮时清空。其他事件不变。
if (event.type === 'todo/write') return event.data.todos
if (event.type === 'turn/start') return null
return state
},
// 向读取方提供投影视图。stateVersion 用于区分投影状态版本。
wire: { viewSchema: todosProjectionSchema, view: state => state, summary: false },
stateVersion: 2,
})
// 再注册模型能调用的 todo_write 工具。
ctx.tools.register(defineTool({
name: 'todo_write',
description: describe(allowParallel),
parameters: {
// 输入要带完整的 todos 数组。调用时替换的是整份清单。
todos: {
type: 'array',
required: true,
description: 'The COMPLETE task list, replacing any previous list.',
items: {
type: 'object',export async function apply(ctx: Context, config: Config): Promise<void> {
const allowParallel = config.allowParallelInProgress
// Standing-plan fold: latest whole todo/write list, cleared by the next
// turn/start (turn/end keeps the finished checklist visible); null before the
// first write or after a later turn begins; every other event returns the
// same state reference.
await ctx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
stateSchema: todosProjectionSchema,
init: () => null,
apply: (state, event) => {
if (event.type === 'todo/write') return event.data.todos
if (event.type === 'turn/start') return null
return state
},
wire: { viewSchema: todosProjectionSchema, view: state => state, summary: false },
stateVersion: 2,
})
ctx.tools.register(defineTool({
name: 'todo_write',
description: describe(allowParallel),
parameters: {
todos: {
type: 'array',
required: true,
description: 'The COMPLETE task list, replacing any previous list.',
items: {
type: 'object',把输入模式、业务动作、输出模式和文字分开
- 输入:
parameters.todos规定数组、项目结构和允许的状态。defineTool同时用它推导args类型,并为模型传入的 JSON 做运行时校验。 - 业务检查:
toTodoList清理并验证内容,allowParallelInProgress控制允许几个进行中的事项。exec.agent指这次调用的所有者。没有 Agent 就无法把清单写入所属 Session,因此抛出错误。 - 结构化结果:
execute写todo/write,返回todos和counts。output.schema描述这个返回值。 - 模型可见内容:
output.render把返回值投影成文本块,工具管线据此产生 tool result。网页卡片是另一个呈现面。
// 把执行结果的计数渲染成发给模型的文本块。
render: (_args, value) => [{
type: 'text',
text: `Updated todo list: ${value.counts.pending} pending, ${value.counts.inProgress} in progress, ${value.counts.completed} completed.`,
}],
},
// execute 负责业务动作。先整理并检查传进来的清单。
execute(args, exec) {
const todos = toTodoList(args.todos, allowParallel)
// 没有调用者 Agent,就没有归属的 Session,直接报错。
if (!exec.agent) {
throw new Error('todo_write requires an owning agent session')
}
// 把整份清单记为一条 Session 领域事件。
exec.agent.session.append('todo/write', { todos })
// 根据状态分组计数,连同清单作为结构化值返回。
const count = (status: TodoItem['status']): number =>
todos.filter(t => t.status === status).length
return Promise.resolve({
todos: todos.map(todo => ({ content: todo.content, status: todo.status })),
counts: {
pending: count('pending'),
inProgress: count('in_progress'),
completed: count('completed'),
},
})
},
// 另有调用卡片的展示描述,与发给模型的结果分开。
presentCall: args => ({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: args.todos }), render: (_args, value) => [{
type: 'text',
text: `Updated todo list: ${value.counts.pending} pending, ${value.counts.inProgress} in progress, ${value.counts.completed} completed.`,
}],
},
execute(args, exec) {
const todos = toTodoList(args.todos, allowParallel)
if (!exec.agent) {
// The list is per-agent-session state; a non-agent caller (no owning
// session) has nowhere to write it. Reject rather than silently no-op.
throw new Error('todo_write requires an owning agent session')
}
exec.agent.session.append('todo/write', { todos })
const count = (status: TodoItem['status']): number => todos.filter(t => t.status === status).length
return Promise.resolve({
todos: todos.map(todo => ({ content: todo.content, status: todo.status })),
counts: {
pending: count('pending'),
inProgress: count('in_progress'),
completed: count('completed'),
},
})
},
presentCall: args => ({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: args.todos }),工具管线从 tools/pre-execute 做准入,tools/execute 包裹实际分发,tools/post-execute 处理结果策略。若要计时或观察,使用对应扩展点并在应该委托时调用 next()。若修改权限策略,应在它所属的 guard 或前置钩子处理。三个调用点都在 packages/core/tools/src/pipeline.ts。
// 在当前插件的生命周期内注册工具。effect 返回解除注册的操作。
return this.layers.effect(
this.ctx,
// 用工具名登记定义和启用条件。
layer => layer.tools.insert(name, { definition, when: options.when }),
{ label: 'tools.register()' },
)
} return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, { definition, when: options.when }),
{ label: 'tools.register()' },
)
}register 最终把定义放进当前层的 effect,返回 disposer。挂载和卸载测试验证了同一个 registry:挂上工具时 schema 可见,释放 fiber 后不可见。
// 测试插件卸载时,工具定义是否也一起消失。
it('unregisters the tool when its contributing fiber is disposed (HMR-safety)', async () => {
// 先搭建插件运行所需的几个服务。
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(NativeToolExecutor)
await ctx.plugin(SessionProjectionRegistry)
// 挂载待办插件,检查注册表能找到 todo_write。
const fiber = await ctx.plugin(tool, { allowParallelInProgress: true })
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true)
// 释放插件 fiber 后,再查一次。工具此时应不可见。
await fiber.dispose()
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(false)
}) it('unregisters the tool when its contributing fiber is disposed (HMR-safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(NativeToolExecutor)
await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(tool, { allowParallelInProgress: true })
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true)
await fiber.dispose()
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(false)
})先把预期结果写得具体
// 这个测试要同时检查工具结果与所属 Session 的事件。
it('appends a todo/write event carrying the whole list to the calling session', async () => {
const ctx = await setup(true)
const agent = agentWithSession('writer')
// 输入两条待办,一条进行中,一条待处理。
const todos: TodoItem[] = [
{ content: 'plan', status: 'in_progress' },
{ content: 'build', status: 'pending' },
]
// 通过工具调用入口执行,先确认没有错误。
const result = await callTodo(ctx, { todos }, { agent })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected todo_write success')
// 验证结构化结果里的清单和计数。
expect(result.value).toEqual({
todos,
counts: { pending: 1, inProgress: 1, completed: 0 },
})
// 也检查模型收到的文本。
expect(text(result)).toContain('1 pending, 1 in progress, 0 completed')
// 最后去 Agent 的 Session 日志里确认整份清单确实写入。
const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!
expect(event.data.todos).toEqual(todos)
}) it('appends a todo/write event carrying the whole list to the calling session', async () => {
const ctx = await setup(true)
const agent = agentWithSession('writer')
const todos: TodoItem[] = [
{ content: 'plan', status: 'in_progress' },
{ content: 'build', status: 'pending' },
]
const result = await callTodo(ctx, { todos }, { agent })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected todo_write success')
expect(result.value).toEqual({
todos,
counts: { pending: 1, inProgress: 1, completed: 0 },
})
expect(text(result)).toContain('1 pending, 1 in progress, 0 completed')
const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!
expect(event.data.todos).toEqual(todos)
})这个测试给两项,分别处于 in_progress 和 pending。它同时检查三个出口:程序值中的计数、文本里的计数、所属 Session 的 todo/write。这比只检查 execute 返回成功覆盖更多真实行为。
Session 记录、投影与 Web 旁路
区分实时 chunk、已提交事件、模型历史,以及 Web 入口如何抵达同一个 Agent。
1. 从仓库录制的结果倒读
这里可以直接打开实际录制的 snapshots/session/todo-write/session.v4.jsonl。下列几行来自同一份文件。日志用于说明真实事件数据的形状,和上一章单元测试的两项输入分别来自不同用例。
// todo/write 是领域事件。data.todos 保存这次写入的完整清单。
{
"type": "todo/write",
"data": {
"todos": [
{
"content": "read the code",
"status": "in_progress"
},
{
"content": "watch the background build",
"status": "in_progress"
},
{
"content": "write the fix",
"status": "pending"
}
]
}
}
// tool/result 是工具管线记录的结果。关联调用 ID,并保存发给模型的文本。
{
"type": "tool/result",
"data": {
"turn": 1,
"step": 1,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "call_00_UHvM5RrwIkjNJ9xh3S735164"
},
"toolCallId": "call_00_UHvM5RrwIkjNJ9xh3S735164",
"content": [
{
"type": "text",
"text": "Updated todo list: 1 pending, 2 in progress, 0 completed."
}
],
"isError": false,
"id": "{{message:5}}"
}
},
"sourceEventSeqs": [
14
],
"surfaceOp": "append"
}{"type":"todo/write","data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"watch the background build","status":"in_progress"},{"content":"write the fix","status":"pending"}]}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_UHvM5RrwIkjNJ9xh3S735164"},"toolCallId":"call_00_UHvM5RrwIkjNJ9xh3S735164","content":[{"type":"text","text":"Updated todo list: 1 pending, 2 in progress, 0 completed."}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}// turn/end 记录本轮结束原因。这里的 completed 表示正常完成。
{
"type": "turn/end",
"data": {
"turn": 1,
"reason": {
"kind": "completed"
}
}
}{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}todo/write 是清单的领域事实。tool/result 内有一条模型可见的工具消息,录制的文字是 Updated todo list: 1 pending, 2 in progress, 0 completed.。turn/end 给出该轮结束原因。相同 Session 里的 agent/assistant-stream 是进程内的实时事件通道;已经结算的 assistant 消息或尝试会有自己的耐久事件形状。实时 UI 可以先看到 chunk,持久历史根据结算事件恢复。
一份日志,可派生两种不同读法
todo/write 时采用最新完整列表,下一次 turn/start 清空,其他事件保留当前状态。// 从 Session 的 surface 投影中派生本次请求能看到的消息。
deriveMessages(): Message[] {
const surface = this.surface
const nodes = surface.nodes
const generation = surface.contentGeneration
// 投影代次变化时,旧缓存失效,从头开始重建。
if (generation !== this.derivedGeneration) {
this.derived = []
this.derivedNodes = 0
this.derivedGeneration = generation
}
// 代次未变化时,从上次处理的位置继续,只读新增节点。
for (const seq of nodes.slice(this.derivedNodes)) {
// seq 来自日志本身。末尾的 ! 表示作者在这里依赖下标有效的约束。
const msg = this.deriveEventMessage(this.log[seq]!)
// 仅把实际派生出消息的事件放入结果。
if (msg) this.derived.push(msg)
}
this.derivedNodes = nodes.length deriveMessages(): Message[] {
const surface = this.surface
const nodes = surface.nodes
const generation = surface.contentGeneration
if (generation !== this.derivedGeneration) {
this.derived = []
this.derivedNodes = 0
this.derivedGeneration = generation
}
for (const seq of nodes.slice(this.derivedNodes)) {
// Surface sequences are built from this.log — seq is always a valid
// index by construction. The non-null assertion expresses that invariant.
// oxlint-disable-next-line typescript/no-non-null-assertion
const msg = this.deriveEventMessage(this.log[seq]!)
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
// usage) derives to null and must not enter the transcript.
if (msg) this.derived.push(msg)
}
this.derivedNodes = nodes.length不是每条事件都属于模型消息历史。todo/write 用于领域投影,而带对应 surface 操作的 tool/result 可进入模型历史。改变模型所见内容需要保留可重建的日志事实。保存到磁盘、识别版本、迁移历史代次属于持久化 provider。做第一次扩展时先用已有事件与结构,碰到格式或迁移再读 docs/session-format-status.md。
2. Web 用不同入口,复用 Agent
// 在等待远程调用之前,先在客户端标记首条提示已发出。
const firstPrompt = this.blankBit && !this.firstPromptPendingTurn
if (firstPrompt) this.firstPromptPendingTurn = true
let result: RemoteResult<{ accepted: true }>
// 没有子目标地址时,走普通 Session 的远程提交入口。
if (this.address === undefined) {
const clientTimeZone = resolvedClientTimeZone()
// 发送请求 ID、会话 ID、交付模式、内容和时区,signal 用于取消。
result = await this.remote.session.prompt({
requestId: requestId ?? randomUUID() as SessionRequestId,
sessionId: this.sessionId,
mode,
content,
clientTimeZone,
}, signal)
// 目标分支另有对文件内容的限制。这里展示的是分支入口。
} else if (content.some(part => part.type === 'file')) {
result = {
ok: false, // Before the first await, so a turn observed ahead of the receipt clears it.
const firstPrompt = this.blankBit && !this.firstPromptPendingTurn
if (firstPrompt) this.firstPromptPendingTurn = true
let result: RemoteResult<{ accepted: true }>
if (this.address === undefined) {
const clientTimeZone = resolvedClientTimeZone()
result = await this.remote.session.prompt({
requestId: requestId ?? randomUUID() as SessionRequestId,
sessionId: this.sessionId,
mode,
content,
clientTimeZone,
}, signal)
} else if (content.some(part => part.type === 'file')) {
result = {
ok: false,// 把这个 Host 方法注册为名叫 prompt 的远程入口。
@Remote('prompt')
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
// 先检查请求是否取消,再拒绝无效的非空内容列表。
signal.throwIfAborted()
if (request.content.length > 0 && !hasPromptContent(request.content)) {
throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {})
}
// 客户端提供时区时做规范化。
const clientTimeZone = request.clientTimeZone === undefined
? undefined
: canonicalClientTimeZone(request.clientTimeZone)
// 提供了值但规范化失败时,抛出结构化远程错误。
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
throw new RemoteError(
'session/invalid-time-zone',
'clientTimeZone must be UTC or a valid IANA Area/Location name',
{ value: request.clientTimeZone },
) @Remote('prompt')
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
signal.throwIfAborted()
if (request.content.length > 0 && !hasPromptContent(request.content)) {
throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {})
}
const clientTimeZone = request.clientTimeZone === undefined
? undefined
: canonicalClientTimeZone(request.clientTimeZone)
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
throw new RemoteError(
'session/invalid-time-zone',
'clientTimeZone must be UTC or a valid IANA Area/Location name',
{ value: request.clientTimeZone },
)// 先接收并验证附件内容。如果请求 ID 已处理,就返回接受回执。
const content = await this.ctx.attachments.admitPromptContent(admission.content)
if (this.hasPromptRequest(agent.session, request.requestId)) return { accepted: true }
const message: UserMessage = createUserMessage({ content, source })
// 等待期间 Agent 可能已销毁,重新确认注册表里还是同一个实例。
if (this.ctx.agents.get(agent.id) !== agent) {
throw new RemoteError(
'session/not-found',
`session "${agent.id}" was disposed during prompt admission`,
{ sessionId: agent.id },
)
}
// using 让附件绑定在退出当前作用域时自动清理。
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
// steer 进入下一 Step,否则用 followup 提交下一轮。
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
// 消息成功提交后,确认这次附件绑定。
binding.commit() const content = await this.ctx.attachments.admitPromptContent(admission.content)
if (this.hasPromptRequest(agent.session, request.requestId)) return { accepted: true }
const message: UserMessage = createUserMessage({ content, source })
if (this.ctx.agents.get(agent.id) !== agent) {
throw new RemoteError(
'session/not-found',
`session "${agent.id}" was disposed during prompt admission`,
{ sessionId: agent.id },
)
}
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
binding.commit()控制器校验边界数据后把请求交给命令层。命令层完成附件输入的准入并检查 Agent 仍然存在,按模式选择 steer 或 followup。这里的 using binding 把附件绑定资源的生命周期限制在块内。成功走 commit(),退出作用域时仍走资源的 dispose 契约。Web 请求的接受回执与最终助手答复是不同阶段。追回传时可继续查 SessionController 上的 page 和 stream Remote,或从 docs/api-gateway.md 跟路由和生成的 Remote 声明。
自己开发 · 从一个插件到可验证的改动
先用 overlay 挂一段最小代码,再做模型工具。用仓库已有测试的方式验证行为。
1. 先让 Loader 调到你的代码
完成「在 Windows 上从源码启动」中的安装和构建后,在开发检出的根目录建 scratch-plugin/src,把下面官方教程的代码存为 scratch-plugin/src/my-plugin.ts。这个练习只验证插件是否成功加载。
// 只导入 Context 的类型。这里无需加载 Cordis 的运行时代码。
import type { Context } from '@deepseek-ai/cordis'
// 插件名字由 Loader 读取。
export const name = 'hello-plugin'
// 挂载插件时,Cordis 会调用 apply 并传入当前上下文。
export function apply(ctx: Context) {
// 这行日志用来确认你的插件确实被挂载。
console.log('[hello-plugin] plugin loaded!')
}import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
// Required dependencies are ready before apply runs.
console.log('[hello-plugin] plugin loaded!')
}创建 scratch-plugin/cordis.yml。把路径替换成你实际开发检出中的绝对路径。在 PowerShell 中可用 (Resolve-Path .\scratch-plugin\src\my-plugin.ts).Path.Replace('\', '/') 得到可填入的路径。这里用正斜杠和 YAML 单引号,路径里的空格仍留在引号内。
# 通过 overlay 给配置树插入一行。
- insert:
# id 用来定位配置行。
- id: scratch-greet
# name 指向插件文件。请替换为你自己的绝对路径。
name: 'C:/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'# 第一条只打印组合结果,确认配置包含 scratch-greet。
pnpm dsh web --dump-config --patch ./scratch-plugin/cordis.yml
# 确认配置后,用相同 overlay 启动 Web。
pnpm dsh web --patch ./scratch-plugin/cordis.yml--patch 只给这次启动增加覆盖层。想撤掉练习就用不带该 overlay 的启动命令。id 是 patch 对这行的地址,name 是 Loader 要加载的模块,模块里 export const name 则是插件自身的名字。
2. 做成模型可以调用的 greet
把同一个 my-plugin.ts 替换成下方官方 greet 示例,然后重新启动带同一 overlay 的 Web。逐行对照上一节的 todo_write:依赖声明 → 注册 → 输入 schema → execute → 返回 schema → 文本渲染。
// Context 是类型。defineTool 是运行时会调用的工具定义函数。
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
// 声明依赖 tools,等这个服务可用再运行 apply。
export const inject = ['tools']
export function apply(ctx: Context) {
// 把工具定义登记到当前插件的上下文。
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
// 输入必须有字符串 name,检查器也据此推断 execute 的参数。
parameters: {
name: {
type: 'string',
required: true,
description: 'The name to greet',
},
},
// 成功返回的值必须是字符串,再把值包装为模型可读的文本块。
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
// 执行工具主体,返回带名字的问候语。
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}工具名 greet
模型传入 {"name":"Ada"}
execute 返回 "Hello, Ada!"
render 返回 [{"type":"text","text":"Hello, Ada!"}]配置能组合出插件,只说明它被选中。工具进入模型请求还取决于当前 Agent 可用工具集合与策略。故障按层定位:配置输出没有该行,查 patch 路径和顺序;有该行但没有启动日志,查绝对路径、依赖服务和启动诊断;工具已挂载但无调用,查是否进了当前请求的工具 schema;有调用却失败,查入参、前置策略、执行器和返回 schema。
3. 把练习变成一次正式修改
| 需求 | 从哪个已有实现照着改 | 优先验证 |
|---|---|---|
| 改现有工具的行为 | 从对应包的 defineTool 和 execute 开始。todo_write 的配置、投影、输出分开放。 | 输入、结构化结果、模型文本、应写的事件、卸载清理。 |
| 给请求或工具加策略 | 从 docs/cordis-primer.md 的 waterfall 入手,跟到 agent/pre-step 或 tools/pre-execute。 | 允许、拒绝和异常三条路径。观察或包装的监听器应委托 next()。 |
| 接新模型 provider | 从现有 llm-deepseek-api-key 的注册查到 LlmAdapter,再看 docs/cookbook/adding-an-llm-adapter.md。 | 路由选择、凭据缺失、stream chunk、取消信号和适配器测试。 |
| 改 Web UI 或 RPC | 从 browser client 或 SessionController 的 @Remote 切入。先确认字段属于 wire、Host 业务还是 UI。 | Typert 生成、Host/Client 各自检查,选对应 Web 证据。 |
| 引入持久事件或新包 | 事件从 SessionEventMap 和投影看起。包从 docs/cookbook/adding-a-package.md 查工作区清单、编译面和测试。 | 已发布格式、重放、版本与迁移规则。检查引用路径与包声明。 |
第一道真实改动练习
选一个小的 todo_write 行为,从需求写到测试。例如「输入内容两端空白时,存下修剪后的文本」。打开 packages/todo/tool-todo/tests/tool-todo.spec.ts,先找已有的 trim 测试,再反查 toTodoList。随后为你的变体补一条有意义的边界用例。若变更模型看到的文本,检查录制 Session 中的 tool/result,以仓库测试策略决定应更新的 snapshot。Session 格式和核心循环都有更广的兼容性要求,第一次扩展适合落在已有插件契约上。
# 先只跑待办工具相关的单测。
pnpm exec vitest run packages/todo/tool-todo/tests/tool-todo.spec.ts
# 显式使用录制回放模式,避免调用真实模型。
$env:DSH_SNAPSHOT = 'replay'
# 只跑 Headless 快照里名为 todo-write 的场景。
pnpm exec vitest run --config vitest.snapshot.config.ts snapshots/session/headless.snapshot.ts -t todo-write
# 清除本次设置的环境变量。
Remove-Item Env:DSH_SNAPSHOT
# 最后检查 TypeScript 的跨包类型关系。
pnpm run typecheck上面的回放命令适用于已按开发文档完成准备的环境。若看到零个测试匹配,检查 -t 传参和场景名。回放配置在 vitest.snapshot.config.ts,测试驱动在 snapshots/session/headless.snapshot.ts,测试分层在 docs/testing.md。涉及打包后的入口,再选对应 built smoke;Provider 的真实 API e2e 单独用凭据执行。提交前按 AGENTS.md 的改动范围选择检查。
下一次读源码时的顺序
- 写下你要观察的输入和结果,例如
todo_write的两个状态计数。 - 用配置找到行的
id和模块name,打开对应package.json与实现。 - 追实现入口的
inject,分别定位服务定义、provider、调用者。定位到一个方法后,再查它的调用和测试。 - 标记模型所见、UI 所见、Session 所存各属于哪个出口。改动后到对应的结果处验证。