deepseek-harness · Cordis 插件框架

把官方 7 篇教程,揉成一份能直接上手的手册

本教程浓缩自官方 cordis-tutorial/01~07 七篇文档,并逐章对照 deepseek-harness 本仓库的真实代码(文件路径与片段均可在 packages/、vendor/cordis/、examples/ 中查证)。目标:少术语、多图示、每个概念都有「本项目里谁在这么写」作锚点。

来源:7 篇官方教程 对照:deepseek-harness 源码 形式:单文件 HTML · 离线可读 主题:浅色

心智 先记住三件事

Cordis 的本质:一切皆插件(everything is a plugin)。 应用 = 一份插件清单(cordis.yml)+ 一堆插件(apply(ctx) 函数)。框架负责把插件按依赖关系组装、启动、卸载、热替换。
比喻 1 · 插线板 ctx 是一块插线板。插件是插头,插上去就通过 apply(ctx) 通电。服务(Service)是插线板上固定编号的「专用插座」(如 ctx.tools、ctx.llm),别的插头想用电就认插座号,不关心谁在供电。
比喻 2 · 接线清单 cordis.yml 是接线清单:每行写一个要挂的插件。谁先通电不由清单顺序决定,而由「谁依赖谁」(inject)决定——像电路里的上下游。

三个必须刻进肌肉记忆的 API

apply(ctx) ctx.effect(disposer) ctx.plugin(...) ctx.on(event, fn) ctx.emit(event, ...) super(ctx, 'name') inject: ['svc'] declare module
最常被坑的一点 插件不是按 cordis.yml 的书写顺序启动的。如果你的插件 inject: ['tools'] 但清单里没有人提供 tools,它不会报错,而是安静地停在 PENDING 状态——进程可能悄无声息地以 0 退出。第 6 章教你怎么诊断这种「明明写了却没输出」的情况。

▶ 怎么跑起来

教程的 7 个最小示例都放在一个 tmp/cordis-tutorial/ 目录里,用裸 Cordis loader 启动:

sh从教程目录运行
node --import tsx ../../vendor/cordis/bin.js

而在 本仓库里,你不会直接敲这条命令——harness 把它包进了 CLI。等价入口是:

sh仓库内运行一个任务(需要 DEEPSEEK_API_KEY)
pnpm dsh --profile headless "帮我看看这个仓库的 README"

两条命令的底层完全一致:都是「创建根 Context → 挂 Loader 插件 → 读 cordis.yml → 逐个挂载子插件 → 调用每个插件的 apply(ctx)」。理解前者,就理解了后者。

01 第一个插件

一句话:插件就是一个导出 apply(ctx) 的函数;cordis.yml 决定把它挂不挂、挂哪个。

最小插件(教程示例)

tshello.ts教程 01
import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello'

export function apply(ctx: Context) {
  console.log('hello from my first plugin')
}

用一份清单把它挂上去

yamlcordis.yml教程 01
- name: './hello.ts'

运行后输出 hello from my first plugin。注意:你的文件里没有任何框架启动代码——插件只描述自己的贡献,组合交给 cordis.yml。本仓库的 packages/bundle/base/cordis.patch.yml 就是一份长得多的「插件接线清单」(见第 6 章)。

插件有三种长相

ts三种形态教程 01
// 1. 函数插件(你刚写的,最常用)
export function apply(ctx: Context) {}

// 2. 对象插件:带 apply 方法的对象
export const objectPlugin = {
  name: 'object-plugin',
  apply(ctx: Context) {},
}

// 3. 类插件:Service 的子类(第 3 章才用)
export class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myTutorialService')
  }
}
经验法则 在你需要对外公开一项服务之前,一律用函数形态。类形态是给「要注册成 ctx.xxx 服务」的场景准备的(第 3 章)。

本项目里到处都是这种函数插件

tspackages/schedule/schedule/src/index.ts真实代码
/** Cordis 函数插件名 */
export const name = 'schedule'
/** 依赖的服务:这些就绪后本插件才会启动 */
export const inject = ['agents', 'sessions', 'tools', 'sessionPersistence']

export function apply(ctx: Context): void {
  // ...挂载定时提醒能力...
}

这正是教程里 hello.ts 的「生产版」:同样的 name / inject / apply(ctx) 三件套,只是 inject 列出了它需要的服务。

坑 · apply 抛错 vs 模块解析失败 如果 apply 内部抛异常,进程会直接挂掉。但如果只是 cordis.yml 里的模块路径 / 包名拼错(解析失败),Cordis 只会打一条日志、不会崩溃——而且这条日志可能在 console 导出器就绪前就丢了。所以「加了配置项却毫无反应」时,先查拼写。

02 生命周期与 effect

一句话:插件会被卸载(改配置、热替换、缺服务、显式释放)。通过 Cordis API 建立的注册会自动撤销;框架管不到的资源(定时器、连接、watcher)必须装进 ctx.effect() 并返回一个「清理函数」。

把资源关进 effect

tslifecycle.ts教程 02
import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {
  console.log('heartbeat plugin loading')
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    return () => {                 // ← disposer:卸载时运行
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}

export function apply(ctx: Context) {
  const fiber = ctx.plugin(heartbeat)   // 把函数当插件挂上去,返回 fiber
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()             // 等 heartbeat 全部清理完才结束
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

运行输出:heartbeat plugin loading → tick ×3 → heartbeat cleaned up → disposed。三个要点:

  • ctx.plugin(heartbeat) 把「来自代码的函数」挂成插件——和 YAML loader 对每个配置项做的事一模一样。它返回一个 fiber(已加载插件实例的运行时句柄)。
  • effect 的主体在加载期运行,返回的 disposer 在卸载期运行。生命周期和插件一致的资源,你永远不用手动调 disposer。
  • fiber.dispose() 会等该插件的所有清理(含异步)完成,并递归卸载它挂的子插件。

Fiber 状态机

每个已加载的插件实例都有一个 fiber,在这些状态间流转。当你排查「为什么没输出」时,这张图就是答案。

PENDING 等依赖就绪 LOADING apply 运行中 ACTIVE 运行中 UNLOADING disposer 运行 DISPOSED 已拆除 FAILED
PENDING = 已声明,但所需服务还不可用(第 3 章)。LOADING 时 apply 在跑;FAILED 表示 apply 或配置校验抛错。你会在第 6 章反复遇到 PENDING。

哪些操作「本来就是 effect」,不用你手写

  • ctx.on(event, listener):监听器随插件卸载自动移除(第 4 章)。
  • ctx.plugin(child):子插件随父插件一起 dispose。
  • 服务注册、以及 harness 的注册表(如 ctx.tools.register(...))都会把返回的 disposer 挂到调用插件上——卸载时自动注销(第 7 章)。
顺位注意 disposer 按「注册顺序的逆序」启动;但多个异步 disposer 是并发跑的。如果拆除必须按顺序,把它们放进同一个 disposer 里依次 await。

真实代码:嵌套 effect(schedule 插件)

tspackages/schedule/schedule/src/index.ts真实代码
export function apply(ctx: Context): void {
  ctx.effect(() => {                          // 外层:插件级生命周期
    const stopCreated = ctx.on('agent/created', ({ agent }) => {
      if (/* 不是根 agent 等条件 */) return
      const cleanup = agent.ctx.effect(() => { // 内层:单个 agent 的生命周期
        const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, ...)
        const stopStatus = agent.ctx.on('agent/status', ...)
        runtime.start()
        return async () => {                    // 该 agent 消失时清理
          stopStatus()
          disposeTools()
          await runtime.dispose()
        }
      }, 'schedule.runtime()')
      runtimes.set(agent, cleanup)
    })
    return async () => {                        // 插件卸载:取消所有监听并清理
      stopCreated()
      await Promise.allSettled([...runtimes.values()].map(c => c()))
    }
  }, 'schedule.lifecycle()')
}

这就是教程 lifecycle.ts 的「工程版」:外层 effect 负责「新 agent 出现就给它装能力」,内层 effect 负责「单个 agent 消失就拆掉它的定时器/工具」。父子两层 effect 自动级联清理。

03 服务 Service

一句话:服务 = 一个插件提供、其他插件通过 ctx 消费的具名能力(如 ctx.tools、ctx.llm)。消费方只认能力名、不认提供方,所以换实现不用改消费方。

提供服务:写一个 Service 子类

tsgreeter.ts教程 03
import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService      // 编译时:让 ctx.greeter 有类型
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')         // 运行时:以 'greeter' 注册到 ctx
  }
  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'
export function apply(ctx: Context) {
  ctx.plugin(GreeterService)      // Service 子类本身就是插件
}

两段协同:运行时靠 super(ctx, 'greeter') 注册,之后任何插件都能 ctx.greeter 访问它,且注册是 effect,提供方卸载时服务自动消失;编译时靠 declare module 的声明合并把 greeter 加进 Context 接口,拿到类型安全(不写也能跑,只是没类型)。

消费服务:用 inject

tsconsumer.ts教程 03
export const name = 'consumer'
export const inject = ['greeter']     // 声明硬依赖

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))  // 这里保证 greeter 已就绪
}

inject 列出本插件需要的服务,Cordis 会让它保持 PENDING 直到每一项都存在——所以在 apply 里可以放心用 ctx.greeter。清单里两行的先后无关紧要:决定启动时机的是依赖,不是文件顺序。把 ./greeter.ts 整行删掉,消费方就永远 PENDING、不输出、不报错。

真实代码:harness 里的服务

tspackages/e2b/e2b/src/index.ts真实代码 · E2B 沙箱服务
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'

declare module '@deepseek-ai/cordis' {
  interface Context { e2b: E2BRuntime }
}

export class E2BRuntime extends Service {
  static Config: z<Config> = z.object({
    apiKey: z.string(),
    cwd: z.string().default('/home/user/workspace'),
    timeoutMs: z.number().default(300_000),
  })
  constructor(ctx: Context, config: Config) {
    super(ctx, 'e2b')            // 注册为 ctx.e2b
  }
}

再看 harness 最核心的服务之一——工具注册表 ctx.tools:

tspackages/core/tools/src/index.ts真实代码 · 工具服务
export class ToolRuntime extends Service {
  static inject = ['systemPrompt']   // 工具要向系统提示词贡献 schema
  static Config: z<Config> = z.object({
    mode: z.union(['native', 'code', 'both'] as const).default('native'),
    maxParallelSubCalls: z.natural().min(1).default(10),
  })
  constructor(ctx: Context, config: Config = {}) {
    super(ctx, 'tools')               // 注册为 ctx.tools
    ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
  }
}
依赖是活的 inject 不是一次性启动检查。运行中若服务消失(提供方被卸载 / 热替换),每个依赖它的插件会跟着卸载;服务恢复后又会自动重新加载。结合 effect,这能防止消费方持有已失效服务的引用。
可选依赖 硬依赖才用 inject。某项功能「没有也能跑」时,跳过 inject,在使用处探测:const greeter = ctx.get('greeter'),为 undefined 就走降级路径。
命名空间 全应用的服名共用一个扁平命名空间。给自己的服务加辨识度前缀(harness 已占用 tools、llm 等普通名)。生成的 cordis-surface 区块列出了 harness 注册的所有名称。

04 事件 Events

一句话:服务适合「直接调用」;事件让插件在「不知道谁在听」的情况下发通知。harness 用事件传递工具结果、模型请求、审批决定等。

声明、发出、监听

tsstats.ts教程 04
import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context { stats: StatsService }
  interface Events {                  // 声明事件名 + 监听器签名
    'stats/report'(name: string, count: number): void
  }
}

export class StatsService extends Service {
  private counts = new Map<string, number>()
  constructor(ctx: Context) { super(ctx, 'stats') }
  bump(name: string) {
    const next = (this.counts.get(name) ?? 0) + 1
    this.counts.set(name, next)
    this.ctx.emit('stats/report', name, next)   // 发出
  }
}
tsreporter.ts教程 04
import type {} from './stats.ts'     // 仅为让 TS 看到声明合并

export const name = 'reporter'
export const inject = ['stats']

export function apply(ctx: Context) {
  ctx.on('stats/report', (name, count) => {     // 监听
    console.log(`[stats] ${name} -> ${count}`)
  })
  ctx.stats.bump('tool_call')
  ctx.stats.bump('tool_call')
  ctx.stats.bump('prompt')
}

输出 [stats] tool_call -> 1 / 2 和 [stats] prompt -> 1。因为 ctx.on() 是 effect,监听器随插件一起消失,永远不用手动 removeListener。interface Events 的声明合并和上一章 interface Context 对应:它声明事件名与签名,使 emit / on 都带完整类型。

五种分发模式

事件用哪种模式是「约定的一部分」,决定监听器能否返回值、能否并发、能否互相短路:

模式调用语义
emitctx.emit(name, ...args)同步广播;不等待、不收集返回值 / promise。
parallelawait ctx.parallel(name, ...)所有监听器并发跑,一起等待(任一个 reject 则 AggregateError)。
serialawait ctx.serial(name, ...)按顺序、逐个 await;第一个非 null/false/undefined 返回值「胜出」并停止后续。
bailctx.bail(name, ...)serial 的同步版。
waterfallctx.waterfall(name, ...next)环绕式中间件(见下),用于「转换或短路」。

下面这段就是 Cordis 源码里 waterfall 的真实实现——它把监听器排成「从外到内」的链,每层都能拿到 next():

tsvendor/cordis/src/events.ts真实代码 · 事件分发
waterfall(...args: any[]) {
  const cbs = this.dispatch('waterfall', args)
  const inner = args.pop()            // 最内层的默认行为
  const next = () => {
    const cb = cbs.shift() ?? inner   // 没有更多监听器就跑默认行为
    return cb(...args)
  }
  args.push(next)
  return next()                       // 从最外层监听器开始
}

waterfall:转换 or 否决(veto)

每个监听器拿到参数和一个 next() 续体:可以「转换 next() 的返回值」,也可以「不调 next() 直接返回」来短路整条链(Cordis 称之为否决)。

tswaterfall-demo.ts教程 04
declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
  }
}
export function apply(ctx: Context) {
  ctx.on('demo/transform', async (input, next) => {
    const downstream = await next()        // 先让下游跑
    return downstream.toUpperCase()         // 再包装结果
  })
  ctx.on('demo/transform', async (input, next) => {
    if (input.includes('blocked')) return '** blocked **'  // 否决:不调 next()
    return next()
  })
  // 输出:HELLO / ** BLOCKED **
}
铁律 只负责观察 / 标注的 waterfall 监听器必须调用 next();不调用就返回 = 有意短路。如果某个日志监听器忘了调 next(),会悄无声息吞掉所有下游默认行为。这是本仓库的常设规则。

真实代码:harness 的 tools/result 事件

tspackages/context/agent-instructions/src/index.ts真实代码 · 监听每次工具结果
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
  // 每次有工具跑完,这里都能拿到执行信息与结果
})

harness 还用 waterfall 做「协作插件可以包装或回答的决策」:agent/request 允许插件替换模型调用配置,approval/request 允许策略代替用户作答。这正是 waterfall 否决模式的用武之地。

05 配置 Config

一句话:cordis.yml 里每个插件项可带 config 块;插件导出一个 Schema,在 apply 之前校验它。配置不合法就加载失败并给精确错误——插件绝不会在配置残缺时启动。

可配置插件

tsconfig-demo.ts教程 05
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export const name = 'config-demo'

export interface Config {
  greeting: string
  targets: string[]
}

// 同名 Config:既是 TS 类型,又是运行时校验器
export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  targets: Schema.array(String).default(['world']),
})

export function apply(ctx: Context, config: Config) {
  for (const target of config.targets) {
    console.log(`${config.greeting}, ${target}!`)
  }
}
yamlcordis.yml教程 05
- name: './config-demo.ts'
  config:
    targets: ['alpha', 'beta']

没给 greeting,schema 默认值补上 → 输出 Hello, alpha! / Hello, beta!。导出的 Config 既是 TypeScript 接口,也是同名运行时 schema:消费方拿类型,Cordis 拿校验器。本仓库用 Schemastery;Cordis 本身接受任意 Standard Schema 校验器——所以「把普通对象导出成 Config」是行不通的。

明确报错

yamlcordis.yml(错误配置)教程 05
- name: './config-demo.ts'
  config:
    targets: 'not-an-array'     # 类型错了

报错:ValidationError: invalid config: - $.targets expected array but got not-an-array。fiber 进入 FAILED,启动器打印错误后以状态码 1 退出。

真实代码:本仓库的 Schema 长这样

tspackages/core/tools/src/index.ts(节选)真实代码 · ToolRuntime 配置
static Config: z<Config> = z.object({
  mode: z.union(['native', 'code', 'both'] as const).default('native'),
  maxParallelSubCalls: z.natural().min(1).default(10),
})

和教程的 Schema.object({...}) 是同一套思想,只是本仓库统一用 @deepseek-ai/schemastery 的 z.object / z.string / z.union / z.natural 写法。

计算得到的配置值:!!js 标签

yamlcordis.yml(加载时求值)教程 05
- name: './config-demo.ts'
  config:
    greeting: !!js process.env.DEMO_GREETING ?? 'Hello'

!!js 仅在 config 与条目 disabled 字段内有效。本仓库大量用它做「按环境 / 平台门控」,例如 examples/headless-agent/cordis.yml 里:

yamlexamples/headless-agent/cordis.yml真实代码
- id: agent-spine
  name: '@deepseek-ai/dsh-agent-spine-demo'
  config:
    agents:
      - id: main
        cwd: !!js process.cwd()        # 加载时求值
- id: persistence
  name: '@deepseek-ai/dsh-session-persistence-jsonl'
  config:
    compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"

06 组合 · HMR · 诊断

一句话:到目前为止每项能力都是插件,cordis.yml 选择插件树。本章改组合、热替换一个插件、并诊断「永远加载不出来」的插件。

Cordis 配置项不止有名字

yamlcordis.yml教程 06
- id: greeter          # 配置项的稳定身份
  name: './greeter.ts'
- id: consumer
  name: './consumer.ts'
  disabled: true       # 保留条目但不挂载
  • id:给配置项稳定标识,loader 由此区分「改了现有项」还是「删了再添」。
  • disabled: true:卸载插件但不删配置项;改回原值后,它和所有因依赖它而 PENDING 的插件会重新加载。
  • 组(group)可嵌套子列表整体加载 / 卸载;isolate 为组提供某服务的独立实例——两个组各自看到配置不同的 shell 提供方,互不干扰。

热模块替换(HMR)

卸载会释放 effect(第 2 章),加载遵循依赖(第 3 章),所以 HMR 可以先卸载、再加载来替换运行中的插件。@deepseek-ai/cordis-plugin-hmr 会监视文件,保存时自动执行这套流程。它依赖 logger 服务记录日志、inject timer 服务做去抖——所以清单里要带上这两个辅助插件,否则 HMR 会永远停在 PENDING 且不发声。

为什么配置项要带 id 不带 id 的配置项每次读取都会生成一个新 id,于是只要配置文件有任何编辑(哪怕自身文本没变),它也会被当成「先删再添」而重新挂载。显式写 id 才能做到「只挂载 / 卸载真正变化的部分」。

诊断:我的插件为什么一直 PENDING?

依赖驱动加载的另一面:inject 指了没人提供的服务,它就一直等、不输出。这不是错误(PENDING 是合法状态)。你可以直接枚举插件注册表看状态:

tsdiagnose.ts教程 06
import { FiberState, type Context } from '@deepseek-ai/cordis'

export const name = 'diagnose'
export function apply(ctx: Context) {
  setTimeout(() => {
    for (const runtime of ctx.registry.values()) {
      for (const fiber of runtime.fibers) {
        if (fiber.state === FiberState.PENDING) {
          console.log(`${fiber.name} is PENDING — a required service is missing`)
        }
      }
    }
  }, 500)
}

配合一个「要不到服务」的插件,运行就会打印 needs-timer is PENDING — a required service is missing。往清单加 - name: '@deepseek-ai/cordis-plugin-timer' 它就活了。不加 PENDING 过滤地遍历,你还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE。

真实代码:base bundle 的长清单

教程那个一行清单,放大到本仓库就是 packages/bundle/base/cordis.patch.yml——它一次性插入几十个带 id 的核心插件,后续各 mode 的 bundle 按 id 覆盖某一行的 config:

yamlpackages/bundle/base/cordis.patch.yml真实代码 · 共享核心
- insert:
    - id: timer
      name: '@deepseek-ai/cordis-plugin-timer'
    - id: hmr
      name: '@deepseek-ai/cordis-plugin-hmr'
      config:
        root: ['.']
    - id: llm
      name: '@deepseek-ai/dsh-llm'
    - id: session
      name: '@deepseek-ai/dsh-session'
    - id: agent
      name: '@deepseek-ai/dsh-agent'
    # ...(共数十行,按 id 可被后续 bundle 覆盖)

07 进入 harness

一句话:把前几章的模式直接用在真实 harness 服务上——向 ctx.tools 注册一个模型可调用的工具,通过真实工具流水线执行它,并监听 tools/result 事件。本例无需密钥、不调模型。

工具插件:注册 + 执行

tsgreet-tool.ts教程 07
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet the named person.',
    parameters: {
      name: { type: 'string', required: true, description: 'Who to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))

  // 代替模型,直接驱动一次调用走真实执行流水线
  void (async () => {
    const result = await ctx.tools.execute({
      callId: CallId('demo-1'),
      name: 'greet',
      arguments: { name: 'Cordis' },
      signal: new AbortController().signal,
    })
    console.log('tool replied:', JSON.stringify(result.content))
  })()
}

每个模式都来自前几章:inject: ['tools'](第 3 章)让插件等服务就绪;ctx.tools.register(...) 把注册 disposer 挂到插件(第 2 章),卸载即注销。defineTool 把 parameters 规约为给模型看的 JSON Schema、推导 args 类型、并在 execute 前校验模型给的参数;output.render 是 Native renderer,把规范值转成可持久化的内容块。

观察插件:监听工具结果

tstool-logger.ts教程 07
import type {} from '@deepseek-ai/dsh-tools'   // 引入声明合并,使事件有类型

export const name = 'tool-logger'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.on('tools/result', (exec, result) => {
    const text = result.content
      .map(block => (block.type === 'text' ? block.text : ''))
      .join('')
    console.log(`[tool-logger] ${exec.name} -> ${text}`)
  })
}

组合并运行

yamlcordis.yml教程 07
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'

注意 @deepseek-ai/dsh-tools 会注入 systemPrompt 服务(工具要向系统提示词贡献 schema),所以清单里也要列它的提供方;缺了提供方,工具插件就按第 6 章那样 PENDING。运行输出:

txtstdout
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]

logger 先触发:tools/result 在结果「物化过程中」发出,早于 execute 返回的 promise 兑现。两个插件互不知道对方存在——它们由「注册表服务」和「事件」连接。

真实代码:defineTool 与 register 在做什么

tspackages/core/tools/src/schema.ts(节选)真实代码 · 工具定义
export function defineTool(options: DefineToolOptions): ToolDefinition {
  const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
  const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema)
  const validate = (args) => validateJsonSchemaValue(parameters, args, '')
  return {
    name: options.name,
    description: options.description,
    parameters,
    output: {
      schema: outputSchema,
      render: (args, value) => options.output.render(args, value),
    },
    async execute(args, exec) {
      const violations = validate(args)
      if (violations.length) throw new ToolArgsError(violations)  // 校验模型参数
      return options.execute(args, exec)
    },
  }
}
tspackages/core/tools/src/index.ts(节选)真实代码 · 注册即 effect
register(definition: ToolDefinition): () => void {
  const name = definition.name
  if (name === RUN_CODE_NAME) throw new Error(`tool name "${RUN_CODE_NAME}" is reserved ...`)
  // 注册本身是 effect:返回的 disposer 会随插件卸载自动注销工具
  return this.layers.effect(
    this.ctx,
    layer => layer.tools.insert(name, definition),
    { label: 'tools.register()' },
  )
}

这就把第 2 章「注册会自动撤销」和第 3 章「ctx.tools 是个服务」连起来了:defineTool 负责把你的函数变成规范定义,ToolRuntime.register 用 effect 把它插进注册表——插件一卸载,工具自动消失。

★ 从零到一个 Agent

一句话:真实的 agent = 上面这套组合,再加上 LLM 适配器、agent loop、持久化、运行入口。读懂下面这份清单,你就能读懂 harness 是怎么「拼」出来的。

examples/headless-agent/cordis.yml 就是一个完整的一次性编码 agent。你已经能逐行读懂它了:

yamlexamples/headless-agent/cordis.yml(节选)真实代码
- id: settings        name: '@deepseek-ai/dsh-settings-file'   # 用户设置
- id: credentials     name: '@deepseek-ai/dsh-credentials-local' # 密钥
- id: llm-deepseek    name: '@deepseek-ai/dsh-llm-deepseek'     # LLM 适配器
    config: { thinking: enabled, reasoningEffort: max, models: [...] }
- id: subprocess      name: '@deepseek-ai/dsh-subprocess-local'
- id: bash            name: '@deepseek-ai/dsh-bash-local'        # bash 能力
- id: agent-spine     name: '@deepseek-ai/dsh-agent-spine-demo'  # agent 主脊
- id: persistence     name: '@deepseek-ai/dsh-session-persistence-jsonl'
- id: compaction-basic name: '@deepseek-ai/dsh-compaction-basic' # 上下文压缩
- id: subagent        name: '@deepseek-ai/dsh-subagent'          # 子智能体
- id: todo / fs / workflow / ralph ...                            # 各类工具
- id: fs-local        name: '@deepseek-ai/dsh-fs-local'          # 文件系统

把教程里的 greet-tool.ts 加进这份清单的副本,你就拥有了一个「会打招呼」的真实 agent。后续可深入:构建工具(defineTool 的呈现与更丰富 schema)、三层能力设计(harness 如何组织可替换能力)、各 子系统页面 上生成的 cordis-surface 区块(可注入 / 可监听的一切)。

Loader 读 cordis.yml dsh-tools 提供 ctx.tools dsh-system-prompt 提供 systemPrompt greet-tool inject:['tools'] tool-logger on('tools/result')
Loader 按清单挂载插件;箭头表示 inject 依赖(如 greet-tool 等 ctx.tools 就绪才启动)。tool-logger 与 greet-tool 互不认识,靠 tools/result 事件连接。

≡ API 速查表

你要做什么怎么做章节
写一个插件export function apply(ctx) {} + cordis.yml 里一行 name01
管一个定时器 / 连接 / watcherctx.effect(() => { const h = ...; return () => cleanup(h) })02
挂一个子插件并拿到句柄const fiber = ctx.plugin(Foo); fiber.dispose()02
对外提供一项能力class X extends Service { constructor(ctx){ super(ctx,'x') } } + declare module03
声明我依赖某服务export const inject = ['tools'](缺了会 PENDING)03
发通知 / 广播ctx.emit('ns/action', ...args)04
监听通知ctx.on('ns/action', (...args) => {})(自动随插件移除)04
包裹 / 短路决策ctx.waterfall('ns/action', ...next),监听器可 next() 或否决04
给插件加配置 + 校验导出 Config schema(Schemastery / Standard Schema)05
加载时计算配置config: !!js process.env.X ?? 'default'05
给配置项稳定身份写 id;临时关掉写 disabled: true06
诊断 PENDING遍历 ctx.registry,找 fiber.state === FiberState.PENDING06
注册一个模型工具ctx.tools.register(defineTool({ name, parameters, output, execute }))07

本教程引用到的真实文件索引

  • vendor/cordis/bin.js — 裸 Cordis loader 入口
  • vendor/cordis/src/events.ts — emit/parallel/serial/bail/waterfall 真实实现
  • vendor/cordis/src/service.ts — Service 基类与注册机制
  • packages/core/tools/src/index.ts — ToolRuntime(ctx.tools 服务)、register
  • packages/core/tools/src/schema.ts — defineTool
  • packages/e2b/e2b/src/index.ts — E2BRuntime(真实 Service 子类 + Schema 配置)
  • packages/schedule/schedule/src/index.ts — 嵌套 ctx.effect 的真实范例
  • packages/bundle/base/cordis.patch.yml — 生产级插件组合清单
  • examples/headless-agent/cordis.yml — 一个完整 agent 的组合