DSH 源码导读
目录
DeepSeek Harness / Local source walkthrough

读懂 DeepSeek Harness从启动到自己写插件

跟着本地源码,看一条输入怎样穿过 CLI、Cordis、Agent、模型和工具,最终写进 Session。读完用同一套注册机制加载你的第一个工具。

先读语法补课 ↓
跟读路线 · 9 章
  1. 先补语法和 Cordis · 读懂插件、服务和生命周期
  2. 定位入口 · 项目地图、启动与插件配置
  3. 追一次请求 · Agent、工具、Session 与 Web
  4. 自己动手 · overlay、greet、定向检查

从一条可观察的路径认识仓库

本页以同目录的 deepseek-harness-master 源码快照为依据。路径链接指向本地文件,源码框标题注明行号。先跟读 Headless 这条短路线,再看 Web 怎样汇合。源码框默认显示中文注释和整理后的缩进。标题的行号对应仓库原文,切换视图后「复制」会复制当前所见版本。Cordis、架构、启动和任务章节都配有 Archify 交互图。图中的 SRC 标注与本页摘录对应到同一份本地源码快照。

基础语法可以回看第一份教程。TypeScript 从第一行开始

CHAPTER 01

读源码前的语法补课

先掌握后面实际会遇到的写法。每小节先看可运行的小例子,再定位仓库里的用法。

上一份 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.mts,用 Node.js 24 运行
// 直接运行这个文件时,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"));
node main.mts true true

若从另一个 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());
输出 demo 7

项目用它给 @deepseek-ai/cordis 的 Context 增加 tools、llm 等服务键,也用它给事件接口添加名字和参数。import type {} from "某包" 把该包的类型声明纳入检查,使扩充可见,发射的 JS 会擦除这一行。相反,import "某包" 会在运行时加载模块。某个属性在类型上可见后,运行时仍要靠 profile 装配服务。必需服务写进 inject,可选服务可通过 ctx.get("键") 查询并分支处理。

仓库摘录 · packages/core/tools/src/index.ts · L32–36
// 扩充 Cordis 已存在的 Context 接口。
declare module '@deepseek-ai/cordis' {
  interface Context {
    // 告诉 TS 这个上下文可以有 tools 服务。实际服务要等插件挂载后才可用。
    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"));
输出 string demo

若传入一个静态类型是 JobId 的变量,检查器会拒绝。品牌转换自身无法验证字符串是否对应一个真实 Session,文件或网络输入仍由所属边界校验。读 SessionEvent<'turn/end'>['data']['reason'] 时,从左到右读:挑出某种事件 → 取它的 data 类型 → 再取 reason。

仓库摘录 · packages/core/session/src/types.ts · L20–30
// SessionId 是专门用作会话标识的字符串类型。
export type SessionId = Branded<'SessionId'>

// 同名函数把普通字符串交给品牌转换函数,返回带品牌的静态类型。
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"); // 取消注释后得到类型错误
输出 read

DSH 的 defineTool 把参数 schema、返回 schema 保留到类型层。InferArgs<S> 从输入 schema 推出字段,InferValue<NoInfer<O>> 让回调按输出 schema 给定的类型工作。运行时另有 JSON Schema 校验。再遇到 asserts value is X 时,把它读成「函数正常返回后,检查器可将参数收窄为 X」,失败路径应抛错。

仓库摘录 · packages/core/tools/src/schema.ts · L555–557
// S 保留输入参数模式,O 保留返回值模式。const 泛型有利于保留模式里的字面量。
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 何时执行函数体
// 调用生成器函数先拿到迭代器,函数体等 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();
输出 iterator ready register unregister

这个练习手动调用清理函数。真实 ctx.effect 会在所属 fiber 释放时处理登记的清理动作。ctx.on 也将监听绑定到生命周期。读代码时查两处:注册发生在哪个插件上下文,清理由谁负责。

仓库摘录 · packages/core/tools/src/index.ts · L331–352
  // 通过 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()')
}

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 文本保持字面值。配置也可以改变插件是否挂载。

仓库摘录 · packages/bundle/headless/cordis.patch.yml · L13–24
# 这份 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

7. 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");
输出 work release after
CHAPTER 02

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();
输出 consumer: 等待依赖 Hello, Ada! consumer: 清理 provider: 已卸载

读一遍这三个位置就能分清分工。declare module 是静态类型提示,super(ctx, 'greeter') 是运行时注册,inject: ['greeter'] 是启动和持续依赖。需要这个服务才能工作的插件声明 inject。服务缺少时仍可正常工作的功能可以查询 ctx.get('服务名'),再处理缺失分支。

2. 底层为什么能做到先等待、再运行

new Context() 会创建一个 Proxy,并安装服务解析、事件、插件注册等内建能力。Proxy 可以接管属性读取,所以 ctx.tools 看着像访问字段,实际可能根据当前 fiber 的注入和作用域找到服务实例。服务提供方通过 ctx.provide() 或 Service 基类登记,依赖方看到的是当前作用域可用的实现。

仓库摘录 · vendor/cordis/src/context.ts · L70–84
/** 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
}

下一段是 Fiber 计算依赖是否可用的核心。它把当前修订和各服务实现的标识拼成 epoch。缺少必需服务时用 INACTIVE 表示当前绑定不能运行。实现被替换时标识会变化,Cordis 由此知道何时需要卸载旧绑定并重新加载。等待期间是 PENDING,执行插件时是 LOADING,成功后才到 ACTIVE。出错和主动卸载有各自的状态。

仓库摘录 · vendor/cordis/src/fiber.ts · L617–628
_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)
}

两个常见误会。YAML 行顺序决定配置如何组合,插件能否启动还要看依赖是否就绪。ctx 也有作用域。DSH 可以为一个 Agent 组合不同的服务,在隔离范围内让相同名字的服务指向不同实例。排查依赖时要看消费方自己的 ctx。

3. 卸载为什么能把工具和监听器也一起撤掉

ctx.effect(() => { ...; return dispose; }) 把注册和撤销绑定成一件事。插件自己的定时器、连接或 watcher 可以在这里建立,再返回关闭操作。ctx.on 已经内建这种机制,所以插件通常直接注册事件。看底层实现,真正保存在 fiber 上的是一个负责登记监听、并返回取消登记函数的 effect。

仓库摘录 · vendor/cordis/src/events.ts · L263–270
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)
}

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() 才继续,可以包装返回值,也可以提前结束。异步链由调用者等待。
实跑 · waterfall 的放行与短路
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();
输出 执行默认动作:Ada [Hello, Ada] [已拦截]

这和 DSH 的 tools/pre-execute 是同一种结构。工具管线提供默认的 allow,插件监听器可以选择委托给下游,或按约定返回 ask、deny 等决定。ask 会走审批服务,最终 guard 和取消检查仍由 DSH 自己处理。只做记录的监听器应继续调用 next()。Cordis 的实现还会拒绝同一监听器重复调用 next()。

仓库摘录 · vendor/cordis/src/events.ts · L246–261
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()
}
仓库摘录 · packages/core/tools/src/pipeline.ts · L197–205
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 }

5. Loader 怎样把 DSH 配置变成插件树

把 profile 当成某种应用的装配方案。bundle 是可组合的一层条目,patch 按 id 修改已有条目或插入新条目。组合后的每个条目告诉 Loader 到哪里找插件、给它什么配置、它还要等哪些服务。DSH 的 boot() 创建根 Context、安装 Loader、提供启动环境,最后通过 Include 挂载配置树。下一章的架构图和启动章会再把这条链画出来。

以 Headless 为例,headless-startup 解析命令行,通过 ctx.provide 发布名为 headlessStartup 的服务。另一个 YAML 条目声明要注入该服务。配置里的 !!js 会等到目标条目的依赖就绪后,在对应上下文里求值。任务字符串由此从命令行到达 Runner。

仓库摘录 · packages/bundle/headless/src/startup.ts · L148–155
// 发布 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)
仓库摘录 · packages/bundle/headless/cordis.patch.yml · L14–24
- 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
仓库摘录 · vendor/loader/src/index.ts · L104–110
// 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)
})

6. 下次看到 DSH 插件,按这个顺序读

  1. 在 bundle 或 overlay 查 id 和 name,确认这个条目由哪种 profile 挂载。
  2. 在模块入口查 inject 与 apply。它是提供一个服务,还是给现有服务注册工具、监听器或其他贡献?
  3. 从消费方的 ctx.服务名 或事件名反查 provider、事件声明和分发位置。类型声明和运行时注册分别看。
  4. 最后查清理。找 ctx.effect、ctx.on 或已封装生命周期的注册方法,再看对应的卸载测试。
自测 · 为什么我的插件在配置里有一行,apply 却没打印?

先用配置输出确认该条目确实存在且启用,再查模块路径和启动诊断。若状态是 PENDING,重点看 inject 里的每个服务是否在该作用域可用。配置里有条目、模块加载完成、服务依赖就绪、apply 成功结束,是四个不同的检查点。

继续读仓库的 Cordis 中文入门 或 逐章实验。想立刻把这些名字放回 DSH 的结构,去下一章 架构与项目地图。

CHAPTER 03

架构与项目地图

从用户入口找到组装层,再找到处理请求、执行工具和保存会话的代码。

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,再顺着工作区包往下读。

职责目录及作用
启动和组装
  • boot/ · 准备 Host、加载 profile 和插件管理
  • bundle/ · 给各 profile 提供 YAML 组合层
  • preset/ · 按 Agent 或 Session 装配能力
  • extensions/ · 运行期检查、挂载或卸载插件
Agent 和模型
  • core/ · Agent、循环、提示词、工具服务和 Session 逻辑事件
  • llm/ · 模型接口和 provider
  • context/ · 给模型准备工作区说明等上下文
  • compaction/ · 压缩上下文
  • guard/ · 执行期限和循环约束
会话和数据
进程和执行环境
文件与外部能力
  • fs/ · 文件系统服务和读写搜索工具
  • lsp/ · 语言服务器接入
  • skill/ · 技能发现、读取和加载
  • web/ · 搜索网页和抓取内容
  • browser-use/ · 浏览器操控服务
  • computer-use/ · 桌面操控服务
  • document/ · Host 侧文档转 PDF
  • mcp/ · 把 MCP 服务接为工具
任务和协作
  • subagent/ · 子 Agent 的委派和控制
  • team/ · 多 Agent 任务协调
  • jobs/ · 后台作业与生命周期
  • workflow/ · 工作流定义和执行
  • todo/ · todo_write 清单
  • plan/ · 计划协作状态与命令
  • goal/ · 同一会话目标
  • schedule/ · 计划触发后续任务
  • interaction/ · 命令、权限、批准和询问用户
产品界面和接口
  • client/ · 浏览器 UI、客户端服务与插槽
  • host/ · Web 服务宿主、目录选择和应用集成
  • api/ · Host 控制器及 RPC 网关
  • typert/ · 从 TS 类型生成和加载远程接口信息
  • sdk/ · 进程外 JSON-RPC 协议与 TS SDK
  • acp/ · 自动化用 Agent Client Protocol 服务端
设置与运行支持

最容易找错的三个地方

  • 改聊天页面,先找 apps/web/ 和 packages/client/。改模型的搜索、抓取工具,找 packages/web/。改文档站,找 website/。
  • 理解 Session 事件或模型历史,先看 packages/core/session/。改落盘或恢复,找 packages/session/。改历史搜索和导出,找 packages/session-query/。
  • 改启动命令,找 apps/cli/。改某个 profile 默认挂哪些插件,找 packages/bundle/。改通用启动过程,找 packages/boot/。

按你想改的功能找源码

你想找什么先打开顺着什么找
命令、参数、默认 profileapps/cli/src/bin.ts;apps/cli/src/args.tsparseDshArgs → invocation.mode → runProfile。
启动时到底挂了什么packages/bundle/base/cordis.patch.yml;packages/boot/app-boot/src/profile-context.ts按 YAML 的 id 找包名,再查该包 package.json 和 src/index.ts。
发消息、排队、跑 Agentpackages/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.yamlapps/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 文件。

仓库摘录 · packages/bundle/headless/package.json · L13–27 JSONC · 讲解用
// 包按 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",
PowerShell · 在源码根目录查包
# 只在 packages 下的 package.json 查名字。-n 显示匹配行号。
rg -n '"name": "@deepseek-ai/dsh-tools"' packages -g package.json
匹配位置 packages/core/tools/package.json(Windows 上路径分隔符可能显示为反斜杠)

分清三类跨模块连接

服务调用 · 要一个能力ctx.tools.register(...) 往工具服务注册定义。ctx.agents.create(...) 从 Agent 注册表请求实例。
事件 · 观察或干预过程ctx.on("session/event", ...) 观察已提交事实。Waterfall 监听收到 next,调用它才把工作交给后续处理器,直接返回可截断。
配置 · 决定用哪个实现同一服务接口可以配不同 provider。插件的 inject 声明所需服务,装配顺序由依赖与 Loader 协调。
生命周期 · 知道怎么拆注册和监听属于插件 fiber。效果随卸载撤销,便于重载和正常关停。
CHAPTER 04

在 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 的目录运行。

仓库摘录 · package.json · L2–12 JSONC · 讲解用
"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/*",
PowerShell · 从源码启动 Web
# 先检查 Node、pnpm 和 Git 的版本。
node --version
pnpm --version
git --version
# 在仓库根目录安装工作区依赖。
pnpm install
# 生成本地启动需要的构建产物。
pnpm run build
# 通过 dsh 选择 web profile。
pnpm dsh web
验收点(按源码说明,实际日志随环境变化) Node 满足 engines,pnpm 与 packageManager 一致。 安装与构建退出码为 0。 默认地址 http://127.0.0.1:3080,以启动日志和当前配置为准。

若 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 应用启动和真实模型调用留给有依赖的检出环境。

仓库摘录 · package.json · L211–216 JSONC · 讲解用
// 先预加载 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",

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。

CHAPTER 05

顺着源码追 CLI 和插件挂载

以 pnpm dsh --profile headless 为例,先看到参数如何变成一次可启动的配置组合。

1. 从命令参数到分支

仓库摘录 · apps/cli/src/bin.ts · L26–46
// 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)
仓库摘录 · apps/cli/src/bin.ts · L69–78
    }
    default:
      // 编译期检查是否处理完所有 mode。新增分支后漏处理会得到类型错误。
      invocation satisfies never
      // 如果运行时真的落入默认分支,就抛出错误。
      throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
  }
}

// 只有直接作为入口运行时才执行 CLI。
if (import.meta.main) {
  await runCli()
}

process.argv 的前两项是 Node 和入口路径,slice(2) 得到用户传的部分。parseDshArgs 返回带 mode 的联合。普通启动走 profile,再动态导入 runProfile。--dump-config 切到单独的配置输出分支。第一个摘录里的 StartupError 分支负责输出启动故障,其他异常继续向外抛。

2. 先提供进程事实,再挂应用

仓库摘录 · apps/cli/src/profile-boot.ts · L313–339
// 根配置位于选中的 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,
    })
  },
)

外层 boot(..., async (hostCtx) => { ... }) 最后传入一个准备函数。它把 profile 数据、启动环境和命令行/退出请求交给 Host Context。插件随后从 Context 取这些事实,读取同一份启动配置。app.current 使启动期间的信号也能找到当前树,信号和清理链仍由启动器拥有。

仓库摘录 · packages/boot/app-boot/src/index.ts · L1220–1236
// 先记下当前启动阶段,失败时用它区分准备阶段和挂载阶段。
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

new Context() 在该函数前面创建根。Loader 挂上后先 prepare,再挂根 Include,等待 Loader 的挂载结算并检查启动项。await prepare?.(ctx) 表示没有传准备函数时跳过调用。

3. Profile 叠加决定最终树

仓库摘录 · packages/boot/app-boot/src/profile-context.ts · L74–84
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 patches

正常层次从低到高是 bundle 列表 → profile 自己的 patch → Harness home 的 patch → 命令行 overlay。这段代码还会按条件在尾部加遥测覆盖。...数组 把各层的行依次展开。同一个 id 上的 config 替换应视为整块配置替换,不能假定会递归合并。修改前先看 --dump-config 的最终行。

Headless 基于共享 base,再插入 headless-startup 与 headless-runner。回到第一章的 YAML 摘录,前者提供 headlessStartup,后者从它读取任务与选项。Web 选择另一套应用层,其中的 webserver 行决定监听地址。它们继续共享 Agent 和 Session。

仓库摘录 · packages/bundle/web-app/cordis.patch.yml · L183–188
# 这行把 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
CHAPTER 06

从任务字符串追到模型流

读清 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 的是结构化用户消息。

仓库摘录 · packages/core/agent-loop/src/index.ts · L329–336
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)
仓库摘录 · packages/bundle/headless/src/index.ts · L418–443
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)

顺着 followup 进入 send,消息插入 inbox 后唤醒 driver。steer 以 next-step 为目标并唤醒。inject 也以 next-step 为目标,传 false 所以单独注入上下文不会唤醒 idle Agent。上面的 runner 等待 Agent 和关联子任务稳定,刷新 Session,再从事件摘要写最终文本或结束 JSON 投影,并请求进程按完成原因退出。

仓库摘录 · packages/core/agent-loop/src/agent.ts · L170–189
// 这是 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)
}

2. 开启 Turn,审查下一 Step

仓库摘录 · packages/core/agent-loop/src/agent.ts · L334–363
// 记录新的 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()

先追加 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 绑定适配器,再构建本次请求。区分三个入口有助于定位故障:

仓库摘录 · packages/llm/llm-deepseek/src/host.ts · L79–85
    ?? 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 }
仓库摘录 · packages/core/agent-loop/src/agent.ts · L726–743
// 冻结本次请求的头部数据。
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

session.deriveMessages() 读到本次可见的历史。请求对象同时带上工具历史、工具声明、Session 身份和取消信号。条件展开 ...header.tools !== undefined ? { tools: header.tools } : {} 可在心里加括号读成 ...(条件 ? 有字段的对象 : 空对象)。随后对流逐块 for await,同时检查取消信号。

仓库摘录 · packages/core/agent-loop/src/agent.ts · L479–498
// 用已准备的配置、工具和 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()

4. 模型要求调用工具时

仓库摘录 · packages/core/agent-loop/src/agent.ts · L574–597
// 把流式尝试结算成完整 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

先结算完整的 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。如果连结束事件也没有,再去检查异常和中途退出。

CHAPTER 07

精读 todo_write · 一个工具如何进入系统

用真实注册、schema、执行结果和测试理解扩展点。

todo_write 一次替换整个清单。Base YAML 中的 id: tool-todo 把包挂上。入口声明依赖 tools 和 sessionProjections,在 apply 内注册投影和工具。项目中的品牌 ID、可配置策略、结构化返回值在这一条路径上都能看到。

仓库摘录 · packages/bundle/base/cordis.patch.yml · L445–449
# base bundle 默认挂上待办工具包。
- id: tool-todo
  name: '@deepseek-ai/dsh-tool-todo'
  config:
    # 这一层允许多个事项同时处于 in_progress。
    allowParallelInProgress: true
仓库摘录 · packages/todo/tool-todo/src/index.ts · L22–43
// 插件名字供 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(),
})
仓库摘录 · packages/todo/tool-todo/src/index.ts · L118–145
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',

把输入模式、业务动作、输出模式和文字分开

  1. 输入:parameters.todos 规定数组、项目结构和允许的状态。defineTool 同时用它推导 args 类型,并为模型传入的 JSON 做运行时校验。
  2. 业务检查:toTodoList 清理并验证内容,allowParallelInProgress 控制允许几个进行中的事项。exec.agent 指这次调用的所有者。没有 Agent 就无法把清单写入所属 Session,因此抛出错误。
  3. 结构化结果:execute 写 todo/write,返回 todos 和 counts。output.schema 描述这个返回值。
  4. 模型可见内容:output.render 把返回值投影成文本块,工具管线据此产生 tool result。网页卡片是另一个呈现面。
仓库摘录 · packages/todo/tool-todo/src/index.ts · L188–211
  // 把执行结果的计数渲染成发给模型的文本块。
  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 }),

工具管线从 tools/pre-execute 做准入,tools/execute 包裹实际分发,tools/post-execute 处理结果策略。若要计时或观察,使用对应扩展点并在应该委托时调用 next()。若修改权限策略,应在它所属的 guard 或前置钩子处理。三个调用点都在 packages/core/tools/src/pipeline.ts。

仓库摘录 · packages/core/tools/src/index.ts · L148–153
  // 在当前插件的生命周期内注册工具。effect 返回解除注册的操作。
  return this.layers.effect(
    this.ctx,
    // 用工具名登记定义和启用条件。
    layer => layer.tools.insert(name, { definition, when: options.when }),
    { label: 'tools.register()' },
  )
}

register 最终把定义放进当前层的 effect,返回 disposer。挂载和卸载测试验证了同一个 registry:挂上工具时 schema 可见,释放 fiber 后不可见。

仓库摘录 · packages/todo/tool-todo/tests/tool-todo.spec.ts · L217–227
// 测试插件卸载时,工具定义是否也一起消失。
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)
})

先把预期结果写得具体

仓库摘录 · packages/todo/tool-todo/tests/tool-todo.spec.ts · L72–90
// 这个测试要同时检查工具结果与所属 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)
})

这个测试给两项,分别处于 in_progress 和 pending。它同时检查三个出口:程序值中的计数、文本里的计数、所属 Session 的 todo/write。这比只检查 execute 返回成功覆盖更多真实行为。

CHAPTER 08

Session 记录、投影与 Web 旁路

区分实时 chunk、已提交事件、模型历史,以及 Web 入口如何抵达同一个 Agent。

1. 从仓库录制的结果倒读

这里可以直接打开实际录制的 snapshots/session/todo-write/session.v4.jsonl。下列几行来自同一份文件。日志用于说明真实事件数据的形状,和上一章单元测试的两项输入分别来自不同用例。

仓库摘录 · snapshots/session/todo-write/session.v4.jsonl · L17–18 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"
}
仓库摘录 · snapshots/session/todo-write/session.v4.jsonl · L23–23 JSONL · 逐条展开
// turn/end 记录本轮结束原因。这里的 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,持久历史根据结算事件恢复。

一份日志,可派生两种不同读法

模型历史 · Session.deriveMessages沿 surface 的序号读取能生成消息的事件。尾部追加可以增量派生,出现替换等代次变化时重建缓存。
待办 UI 状态 · todos 投影在 todo/write 时采用最新完整列表,下一次 turn/start 清空,其他事件保留当前状态。
仓库摘录 · packages/core/session/src/index.ts · L999–1018
// 从 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

不是每条事件都属于模型消息历史。todo/write 用于领域投影,而带对应 surface 操作的 tool/result 可进入模型历史。改变模型所见内容需要保留可重建的日志事实。保存到磁盘、识别版本、迁移历史代次属于持久化 provider。做第一次扩展时先用已有事件与结构,碰到格式或迁移再读 docs/session-format-status.md。

2. Web 用不同入口,复用 Agent

浏览器 Clientremote.session.prompt(...)
Host Controller@Remote('prompt') → commands.prompt
Agentsteer 或 followup → 同一 Loop
图 04 · Web 的输入链。Headless 则从 runner 直接调用 followup。Web 的历史读取与 SSE 观察是另外的入口。
仓库摘录 · packages/api/session-controller/src/client/sessions/session.ts · L265–280
// 在等待远程调用之前,先在客户端标记首条提示已发出。
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,
仓库摘录 · packages/api/session-controller/src/index.ts · L467–481
// 把这个 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 },
    )
仓库摘录 · packages/api/session-controller/src/commands.ts · L362–375
// 先接收并验证附件内容。如果请求 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()

控制器校验边界数据后把请求交给命令层。命令层完成附件输入的准入并检查 Agent 仍然存在,按模式选择 steer 或 followup。这里的 using binding 把附件绑定资源的生命周期限制在块内。成功走 commit(),退出作用域时仍走资源的 dispose 契约。Web 请求的接受回执与最终助手答复是不同阶段。追回传时可继续查 SessionController 上的 page 和 stream Remote,或从 docs/api-gateway.md 跟路由和生成的 Remote 声明。

CHAPTER 09

自己开发 · 从一个插件到可验证的改动

先用 overlay 挂一段最小代码,再做模型工具。用仓库已有测试的方式验证行为。

1. 先让 Loader 调到你的代码

完成「在 Windows 上从源码启动」中的安装和构建后,在开发检出的根目录建 scratch-plugin/src,把下面官方教程的代码存为 scratch-plugin/src/my-plugin.ts。这个练习只验证插件是否成功加载。

仓库摘录 · docs/user/develop/basic/index.md · L36–43
// 只导入 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!')
}

创建 scratch-plugin/cordis.yml。把路径替换成你实际开发检出中的绝对路径。在 PowerShell 中可用 (Resolve-Path .\scratch-plugin\src\my-plugin.ts).Path.Replace('\', '/') 得到可填入的路径。这里用正斜杠和 YAML 单引号,路径里的空格仍留在引号内。

scratch-plugin/cordis.yml · 本地 overlay 示例
# 通过 overlay 给配置树插入一行。
- insert:
    # id 用来定位配置行。
    - id: scratch-greet
      # name 指向插件文件。请替换为你自己的绝对路径。
      name: 'C:/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
组合后的变化 插入 id 为 scratch-greet 的新行。name 指向本地 TS 模块。
PowerShell · 先看组合,再启动
# 第一条只打印组合结果,确认配置包含 scratch-greet。
pnpm dsh web --dump-config --patch ./scratch-plugin/cordis.yml
# 确认配置后,用相同 overlay 启动 Web。
pnpm dsh web --patch ./scratch-plugin/cordis.yml
预期观察 配置输出包含 scratch-greet 和你填入的路径。 启动时终端打印 [hello-plugin] plugin loaded!

--patch 只给这次启动增加覆盖层。想撤掉练习就用不带该 overlay 的启动命令。id 是 patch 对这行的地址,name 是 Loader 要加载的模块,模块里 export const name 则是插件自身的名字。

2. 做成模型可以调用的 greet

把同一个 my-plugin.ts 替换成下方官方 greet 示例,然后重新启动带同一 overlay 的 Web。逐行对照上一节的 todo_write:依赖声明 → 注册 → 输入 schema → execute → 返回 schema → 文本渲染。

仓库摘录 · docs/user/develop/basic/tool.md · L12–33
// 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}!`
    },
  }))
}
行为验收 · 示例输入到工具输出
工具名       greet
模型传入     {"name":"Ada"}
execute 返回  "Hello, Ada!"
render 返回   [{"type":"text","text":"Hello, Ada!"}]
预期观察 有可用模型凭据时,向 Web 发「请调用 greet 工具向 Ada 问好」。 若模型实际选用工具,可在工具结果中看到 Hello, Ada!。 把 name 换成数字时,输入校验应拒绝该调用。

配置能组合出插件,只说明它被选中。工具进入模型请求还取决于当前 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 格式和核心循环都有更广的兼容性要求,第一次扩展适合落在已有插件契约上。

PowerShell · 按改动选择检查
# 先只跑待办工具相关的单测。
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
各命令的验收点 Vitest 通过,关注真正运行的测试数量。 回放命中 todo-write,用录制输入验证最终 Session;无需真实 API 密钥。 typecheck 退出码为 0。需验证构建产物时,再走 build 或 artifacts + check。

上面的回放命令适用于已按开发文档完成准备的环境。若看到零个测试匹配,检查 -t 传参和场景名。回放配置在 vitest.snapshot.config.ts,测试驱动在 snapshots/session/headless.snapshot.ts,测试分层在 docs/testing.md。涉及打包后的入口,再选对应 built smoke;Provider 的真实 API e2e 单独用凭据执行。提交前按 AGENTS.md 的改动范围选择检查。

下一次读源码时的顺序

  1. 写下你要观察的输入和结果,例如 todo_write 的两个状态计数。
  2. 用配置找到行的 id 和模块 name,打开对应 package.json 与实现。
  3. 追实现入口的 inject,分别定位服务定义、provider、调用者。定位到一个方法后,再查它的调用和测试。
  4. 标记模型所见、UI 所见、Session 所存各属于哪个出口。改动后到对应的结果处验证。