Dsh Cordis教程

Cordis 入门

English | 中文

Cordis 是 DeepSeek Harness 底层以 vendor 方式引入的插件框架。本文介绍 harness 插件作者在阅读子系统页面上生成的服务/事件参考之前需要了解的 Cordis 核心概念;Cordis 教程则通过实践逐一讲解这些概念。vendor 源码与同步流程见 vendor/README.md

五个核心概念

分发模式

每个事件具有以下分发模式之一,且只能通过对应方法分发。

模式 是否 await? 分发顺序 是否有返回值?
emit 监听器按注册顺序观察
waterfall 监听器按注册顺序观察
parallel 所有监听器并行观察事件
serial 监听器按注册顺序观察
bail 监听器按注册顺序观察,直到某个监听器返回 bail 值

分发模式是事件公开约定的一部分。新的 harness 事件通过 @mode 标签记录模式,以便生成的目录可以将声明与分发调用点做交叉校验。

Cordis Waterfall 语义

ctx.waterfall 是环绕中间件。监听器接收 (...args, next)。调用 next() 会执行下游监听器;下游返回值通过 next() 返回当前包装层,可由该层包装后继续向外返回。不调用 next() 直接返回则短路。

协作式监听器通常修改一个共享的请求或决策对象,然后委托。监听器也可以选择完全替换结果,下游监听器将只看到替换后的结果。仅当监听器必须在普通注册之前运行时才使用 prepend: true

对于单决策事件,短路是设计意图。策略监听器在拥有决策权时可以不调用 next() 直接返回,而仅做标注或观察的监听器则必须委托。

Loader 配置

@deepseek-ai/cordis-plugin-include!!js 解析为表达式节点。Loader 在声明的注入激活后,基于该插件上下文(ctx.serviceName)插值条目的 config,并在每次挂载决策时基于 loader 上下文插值其 disabled 字段;Include 会保留嵌套行表达式,直到目标行激活。其余条目元数据保持字面值。由环境选择插件时,请使用 overlay。

实践规则

将行为封装为插件:工具流水线事件属于 ctx.tools,模型流式输出属于 ctx.llm,实时 agent(智能体)协调属于 ctx.agents。拦截和策略优先使用事件;直接能力调用优先使用服务方法。

每个注册都应有对应的 disposer(资源释放函数):要么从 ctx.effect() 返回一个,要么使用 Cordis 提供的辅助方法自动处理。如果 teardown 顺序有要求,请将相关工作放在同一个 effect 中,以确保资源按预期顺序释放。

Cordis 教程

English | 中文

Cordis 是 DeepSeek Harness 底层的插件框架:它是一个小型运行时,其中的每项能力,包括工具、LLM(大语言模型)适配器、文件访问乃至 agent loop(智能体循环)本身,都是挂载到共享上下文中的插件。本教程通过动手实践讲解 Cordis:每一章都是一个可以运行的示例,你将在本仓库内的临时目录中逐步构建它,最后把一个插件接入真实的 harness 服务。

本教程面向 agent 开发者。你不需要深入掌握 TypeScript;下文的 TypeScript 说明会解释可能陌生的语法,并且每一章都会给出确切命令和预期输出。

如果你想阅读精简的概念参考,而不是逐步实践,请参阅 Cordis 入门。详尽的 API 参考见子系统页面上生成的 cordis-surface 区块,以及 Cordis 核心 API 页面。

如果你要为 harness 本身编写插件——由 cordis.yml 加载、在 Web UI 中驱动,而不是下面这个启动器——请从第一个 Harness 插件开始。

准备工作

你需要克隆本仓库并安装依赖;开发指南列出了前置条件。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install

创建各章使用的临时目录。tmp/ 已被 git 忽略,因此你在其中写入的任何内容都不会进入版本控制:

mkdir -p tmp/cordis-tutorial
cd tmp/cordis-tutorial

每一章都从该目录运行同一条命令:

node --import tsx ../../vendor/cordis/bin.js

这个单文件启动器(见 vendor/cordis/bin.js)会创建根 Context、挂载 Loader 插件,并让它从当前目录加载 ./cordis.yml。其余所有内容,包括有哪些插件以及如何配置它们,都来自你稍后将编写的 YAML 文件。--import tsx 标志让 Node 无需构建步骤即可运行配置所指向的 TypeScript 文件。

章节

  1. 你的第一个插件:插件是函数,由 loader 挂载。
  2. 生命周期与 effect:由 Cordis 管理的注册会在所属插件卸载时撤销。
  3. 服务:在 ctx 上公开一项能力,并通过 inject 依赖它。
  4. 事件:类型化事件、广播分发和 waterfall(瀑布式事件)的短路行为。
  5. 配置:读取 cordis.yml 中经过校验的配置,并在输入错误时明确报错。
  6. 组合与 HMR(热模块替换):把配置文件作为插件树,使用热重载,并诊断始终无法加载的插件。
  7. 进入 harness:基于真实的 harness 服务注册一个可由模型调用的工具。

TypeScript 说明

这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能:

第 5 章还会使用 interface 描述配置对象的字段,并使用 Schema<Config> 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。

1. 编写第一个插件

English | 中文

在本教程使用的 loader 配置中,Cordis 插件模块通过命名导出提供 apply 函数。Cordis 加载模块时,会用一个 上下文 调用 apply;该上下文就是 ctx 对象,插件通过它注册自己贡献的所有内容。

编写插件

tmp/cordis-tutorial 目录中(参见环境设置)创建 hello.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello'

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

name 导出项是可选的显示元数据;它用于在诊断信息中标识插件。

组合应用

本教程的启动器通过配置组装应用。创建 cordis.yml

- name: './hello.ts'

该文件是一组 Cordis 配置项的列表。name 是模块指定符,可以是相对路径或 NPM 包名;loader 会挂载每个配置项。各项会并发启动,因此它们在列表中的位置不保证插件的加载先后;顺序由服务依赖(inject,参见第 3 章)决定,而非文件中的位置。

运行

node --import tsx ../../vendor/cordis/bin.js

预期输出:

hello from my first plugin

当没有任何内容继续运行时,进程会自行退出。具体过程如下:

  1. 启动器创建根 Context,并挂载 Loader 插件。
  2. Loader 读取 cordis.yml,解析 ./hello.ts,然后将其作为子插件挂载。
  3. Cordis 调用你的 apply(ctx)

你的文件中没有框架启动代码:插件描述自己的贡献,cordis.yml 则组合应用。例如,dsh base 就是一份更长的插件组合,由部署 overlay 对它进行修补。

其他两种插件形态

函数是最常见的形式,但 Cordis 接受三种形式:

import { Service, type Context } from '@deepseek-ai/cordis'

// 1. Function plugin (what you just wrote).
export function apply(ctx: Context) {}

// 2. Object plugin: an object with an `apply` method.
export const objectPlugin = {
  name: 'object-plugin',
  apply(ctx: Context) {},
}

// 3. Class plugin: a Service subclass (covered in chapter 3).
export class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myTutorialService')
  }
}

在你需要公开服务之前,请一直使用函数形态;第 3 章介绍了何时应当使用类形态。

尝试制造错误

apply 抛出异常:

export function apply(ctx: Context) {
  throw new Error('apply exploded')
}

再次运行:进程会因该错误而终止。插件加载失败会明确报错,不会仅跳过该配置项。

还需要尽早了解一个例外:如果某个配置项的模块无法被 解析,例如路径或包名拼写错误,Cordis 会通过 logger 服务报告错误,而不会使进程崩溃。在启动阶段,这条报告可能在 console 导出器开始观察之前丢失。如果新增配置项似乎没有任何效果,请先检查拼写。

下一章:生命周期与 effect:插件卸载时会发生什么。

2. 生命周期与 effect

English | 中文

Cordis 插件可能因修改配置、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 ctx.effect() 中。

Effect

对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 ctx.effect() 中并返回 disposer(资源释放函数):

创建 lifecycle.ts,将它放在 tmp/cordis-tutorial 中:

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 () => {
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}

export function apply(ctx: Context) {
  // Mount a child plugin and keep its fiber to dispose it later.
  const fiber = ctx.plugin(heartbeat)
  // The demo timer is itself an effect: if THIS plugin is unloaded first,
  // the pending callback is cancelled instead of firing on a dead app.
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

cordis.yml 指向该文件:

- name: './lifecycle.ts'

运行(node --import tsx ../../vendor/cordis/bin.js)后会得到:

heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed

请留意三点:

Fiber 状态机

每个已加载插件实例都拥有一个 fiber,并在以下状态之间转换:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED

你会在第 6 章再次遇到 PENDING,它通常就是「为什么我的插件没有输出」的答案。

已经属于 effect 的操作

你很少需要亲自编写 ctx.effect(),因为内置注册 API 本身已经是 effect:

对于 Cordis 不管理的资源,应在 ctx.effect() 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。

有一项顺序注意事项:disposer 会按注册顺序的逆序启动,但多个异步 disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。

下一章:服务:插件如何共享功能。

3. 服务

English | 中文

服务是一个插件提供、其他插件通过 ctx 消费的具名能力。在 harness 中,ctx.toolsctx.llmctx.agents 都是服务。消费方只指定 'tools' 之类的能力,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。

提供服务

创建 greeter.ts,将它放在 tmp/cordis-tutorial 中:

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'

export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

两部分协同工作:

Service 子类本身就是插件(第 1 章介绍的类形态),因此 ctx.plugin(GreeterService) 会像挂载其他插件一样挂载它。

使用 inject 消费服务

创建 consumer.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'consumer'
export const inject = ['greeter']

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

inject 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 apply 内可以保证 ctx.greeter 已经就绪。cordis.yml 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。

组合并运行:

- name: './greeter.ts'
- name: './consumer.ts'
Hello, world!

交换 cordis.yml 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 ./greeter.ts:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出。第 6 章介绍如何诊断这种状态。

加载后仍会跟踪依赖关系

inject 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect(第 2 章),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会撤销。

这也是配置中可以替换服务的原因:卸载 Cordis 配置项 dsh-bash-local,挂载另一个 shell 提供方,所有注入 'shell' 的插件都会重新启动并使用新实现。

可选依赖

inject 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 inject,并在使用处探测:

export function apply(ctx: Context) {
  // undefined when no provider is loaded; the plugin still runs.
  const greeter = ctx.get('greeter')
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

命名

每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 toolsllm 等普通名称);子系统页面上生成的 cordis-surface 区块列出 harness 注册的每个名称。

下一章:事件:无需共享服务即可通信。

4. 事件

English | 中文

服务支持直接调用;事件让插件无需知道有哪些插件正在监听,就能发出通知。harness 使用事件处理工具结果、模型请求和审批决定等交互。

声明、发出与监听

创建 stats.ts,将它放在 tmp/cordis-tutorial 中。它是一项负责计数并在每次变化时发出通知的服务:

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)
  }
}

export const name = 'stats'

export function apply(ctx: Context) {
  ctx.plugin(StatsService)
}

interface Events 合并与第 3 章的 interface Context 合并在事件系统中相互对应:它声明事件名称及其监听器签名,因此 ctx.emitctx.on 都具有完整类型。namespace/action 命名约定让扁平的事件命名空间保持易读。

创建 reporter.ts

import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.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')
}

import type {} from './stats.ts' 行不会在运行时导入任何内容;它的作用是让 TypeScript 看到声明合并。组合并运行:

- name: './stats.ts'
- name: './reporter.ts'
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1

因为 ctx.on() 属于 effect,监听器会随插件一同消失,绝不需要手动维护 removeListener

分发模式

emit 是 5 种分发模式之一。事件采用哪种模式是其约定的一部分,决定了监听器能否返回值、能否并发运行,以及能否彼此短路:

模式 调用 语义
emit ctx.emit(name, ...args) 同步广播;不会等待或收集返回的 promise 与值。
parallel await ctx.parallel(name, ...args) 所有监听器并发运行,并一同等待。
serial await ctx.serial(name, ...args) 监听器按顺序运行并等待;第一个非 null/false/undefined 返回值胜出,并停止后续监听器。
bail ctx.bail(name, ...args) serial 的同步版本。
waterfall(瀑布式事件) ctx.waterfall(name, ...args, next) 环绕中间件,见下文。

每个 harness 事件都会在其所属子系统页面自动生成的参考文档中记录其模式。

waterfall:转换或短路

waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 next() continuation;它可以转换 next() 的返回值,也可以不调用 next() 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 waterfall-demo.ts

import type { Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
  }
}

export const name = 'waterfall-demo'

export function apply(ctx: Context) {
  // Listener 1: wrap the downstream result.
  ctx.on('demo/transform', async (input, next) => {
    const downstream = await next()
    return downstream.toUpperCase()
  })

  // Listener 2: short-circuit when it owns the decision.
  ctx.on('demo/transform', async (input, next) => {
    if (input.includes('blocked')) return '** blocked **'
    return next()
  })

  void (async () => {
    console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
    console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
  })()
}

cordis.yml 只指向该文件并运行:

HELLO
** BLOCKED **

按顺序看第二行如何产生:监听器 1 先运行并调用 next(),从而调用监听器 2;监听器 2 看到 blocked 后直接返回而不调用 next(),因此最内层默认逻辑(传给 ctx.waterfall 的函数)从未运行;返回途中,监听器 1 再把替换消息转换为大写。

由此得到一项纪律:只负责观察或标注的 waterfall 监听器必须调用 next();不调用就直接返回代表有意短路。如果日志监听器忘记调用 next(),会悄无声息地吞掉所有下游的默认行为。这是本仓库的常设规则(waterfall 语义)。

harness 使用 waterfall 处理协作插件可以包装或回答的决策:agent/request 允许插件替换模型调用配置,approval/request 允许策略代替用户作答。

下一章:配置:来自 cordis.yml 的插件选项。

5. 配置

English | 中文

cordis.yml 中的每个 Cordis 配置项都可以携带 config 块,插件则声明一个 schema,在运行 apply 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。

可配置插件

创建 config-demo.ts,并将其放在 tmp/cordis-tutorial 中:

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[]
}

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}!`)
  }
}

导出的 Config 既是 TypeScript 接口,也是同名的运行时 schema:消费方获得类型,Cordis 获得验证器。本仓库使用 Schemastery 定义 schema;Cordis 本身接受任意 Standard Schema 验证器,因此将普通对象导出为 Config 无法工作。

对其进行配置:

- name: './config-demo.ts'
  config:
    targets: ['alpha', 'beta']

运行:

Hello, alpha!
Hello, beta!

未提供 greeting,因此 schema 默认值会将其补齐:apply 始终会收到完整且经过验证的配置。

明确报错

现在向它传入无效内容:

- name: './config-demo.ts'
  config:
    targets: 'not-an-array'
ValidationError: invalid config:
  - $.targets expected array but got not-an-array (at targets)

插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。

计算得到的配置值

本仓库使用的 loader 支持 !!js 标签,用于必须在加载时计算的配置值:

- name: './config-demo.ts'
  config:
    greeting: !!js process.env.DEMO_GREETING ?? 'Hello'

!!js 仅在 config 与条目 disabled 字段内有效。disabled: !!js ... 在每次挂载决策时基于 loader 上下文求值(本仓库的扩展),可以按平台或环境门控一行;其余元数据(nameidinject 等)保持静态,其中的表达式是普通真值数据。详见 loader 配置

下一章:组合与 HMR(热模块替换):将 cordis.yml 视为应用。

6. 组合与 HMR(热模块替换)

English | 中文

到目前为止构建的每项能力都是插件,cordis.yml 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。

Cordis 配置项不只有名称

Cordis 配置项除了 nameconfig,还接受其他元数据:

- id: greeter          # stable identity for this entry
  name: './greeter.ts'
- id: consumer
  name: './consumer.ts'
  disabled: true       # keep the entry, skip mounting it

id 为 Cordis 配置项提供稳定标识,使 loader 能区分修改现有 Cordis 配置项与先删除再添加。disabled: true 会卸载插件而不删除其 Cordis 配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。

组可以嵌套一份 Cordis 配置项子列表,并将其作为一个单元加载和卸载;isolate 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 shell 提供方,互不影响。Cordis 入门服务隔离示例介绍了详细内容。

热模块替换

卸载会释放 effect(第 2 章),加载则遵循依赖关系(第 3 章),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。@deepseek-ai/cordis-plugin-hmr 插件会监视文件,并在保存时执行这一过程。

tmp/cordis-tutorial 中编写 cordis.yml

- id: logger
  name: '@deepseek-ai/cordis-plugin-logger-console'
- id: timer
  name: '@deepseek-ai/cordis-plugin-timer'
- id: hmr
  name: '@deepseek-ai/cordis-plugin-hmr'
  config:
    root: ['.']
- id: hello
  name: './hello.ts'

列表中增加了两个辅助插件:HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;它还会 inject timer 服务来实现去抖,如果没有 @deepseek-ai/cordis-plugin-timer,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。

HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis:

node --import tsx ../../vendor/cordis/bin.js

现在编辑 hello.ts,修改日志消息并保存:

hello from my first plugin
2026-07-22 15:44:36 [I] hmr watching [ '.' ]
2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
hello from my EDITED plugin

旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,apply 再次运行。按 Ctrl-C 停止进程。编辑 cordis.yml 本身也会触发更新:loader 按 id 比较 Cordis 配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述 Cordis 配置项显式携带 id 的原因:不带该字段的 Cordis 配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。

诊断始终无法加载的插件

依赖驱动加载也有另一面:如果插件的 inject 指定了无人提供的服务,它就会一直等待,不输出任何内容。这不是错误,因为 PENDING 是合法状态,提供方可能稍后才挂载。

你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 diagnose.ts

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.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'needs-timer'
export const inject = ['timer']

export function apply(ctx: Context) {
  console.log('needs-timer loaded')
}
- name: './needs-timer.ts'
- name: './diagnose.ts'

运行它(直接执行 node --import tsx ../../vendor/cordis/bin.js,按 Ctrl-C 停止):

needs-timer is PENDING — a required service is missing

inject: ['timer'] 没有提供方。向列表添加 - name: '@deepseek-ai/cordis-plugin-timer' 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。

下一章:进入 harness:把相同模式用于真实的 harness 服务。

7. 进入 harness

English | 中文

本章会向 harness 的 tools 服务注册一个可由模型调用的工具,通过 harness 工具流水线执行它,并观察结果事件。整个示例无需密钥,也不会调用模型。

工具插件

创建 greet-tool.ts,将它放在 tmp/cordis-tutorial 中:

import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallId } 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}!`
    },
  }))

  // Drive one call through the real execution pipeline, standing in for
  // the model. ToolCallId brands the correlation id a provider would issue.
  void (async () => {
    const result = await ctx.tools.execute({
      callId: brandString<ToolCallId>('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 章),因此卸载时会注销工具。defineToolparameters 规约转换为向模型展示的 JSON Schema,推导 args 的类型,并在 execute 运行前校验模型提供的参数。工具返回由 output.schema 声明的规范值;output.render 则作为 Native renderer(原生渲染器),另行生成可持久化的结果内容。

观察插件

创建 tool-logger.ts。这是一个独立插件,通过 harness 的 tools/result 事件观察应用中的每次工具调用:

import type { Context } from '@deepseek-ai/cordis'
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}`)
  })
}

import type {} from '@deepseek-ai/dsh-tools' 行会引入该包的声明合并,使 'tools/result' 及其 payload 具有类型。这与第 4 章导入 stats.ts 的做法相同,只是扩展到了包级别。

组合并运行

- 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。

node --import tsx ../../vendor/cordis/bin.js
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]

logger 会先触发:tools/result 在结果物化过程中发出,发生在 execute 向调用方返回的 promise 兑现之前。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。

从这里走向完整 agent(智能体)

真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和应用入口。对照 base profile 层headless 层,你现在已经可以读懂其中各项。通过一个小型 --patch overlay 加入 greet-tool.ts 即可。

后续可以阅读: