DeepSeek Harness解析含源码解读

AI 时代程序员必备技能

Codex、Claude Code、Cursor、Hermes Agent、OpenClaw等工程化实战专栏 ,讲透 AI 如何接管脏活累活

个人观点不代表官方

1. 引言:DeepSeek Harness 是什么?

DeepSeek Harness(简称 dsh,读作 D-S-H)是一个由深度求索公司开源的 AI 智能体(Agent)开发框架。你可以把它想象成一个“AI 全能社团”的管理系统——它能把大语言模型、外部工具(如搜索引擎、代码解释器)、记忆模块等零件像乐高积木一样拼装起来,快速构建出一个能自主完成复杂任务的 AI 助手。

1.1 核心特点

  • 完全插件化:每一个功能(调用模型、搜索网页、执行代码)都是一个独立的插件,可以自由增删和替换。
  • 动态可热更新:无需重启,就能在运行时安装或卸载插件,非常适合实验和快速迭代。
  • 基于 Cordis 框架:底层依赖插件管理内核 Cordis,负责插件生命周期、依赖注入和副作用清理。
  • 开箱即用的配置:通过配置文件(Profile)可以组合出不同形态的应用,比如网页版(Web App)或命令行版(Headless)。

1.2 谁适合使用?

  • 对 AI Agent 开发感兴趣的研究者或开发者
  • 希望快速搭建自定义 AI 助手的爱好者
  • 想学习现代插件架构和依赖注入思想的学生

1.3快速安装dsh

安装环境

1.首先点我跳转后下载setup.exe,然后一路确定到最后
2.打开power shell,然后

nvm install 24.19.0
nvm use 24.19.0
npm install -g pnpm@latest

源码安装

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web

进入浏览器dsh web: http://127.0.0.1:3080,如果你想用deepseek那么直接写key然后点右边按钮,否则点左边按钮
在这里插入图片描述
然后进入设置可以看到语言选项
在这里插入图片描述
也可以自定义模型不一定非得deepseek
在这里插入图片描述
然后你可以自己选择插件
在这里插入图片描述

让你的手机也能访问dsh

1.进入ngrok界面点我跳转
在这里插入图片描述

选择第一个,然后copy key让codex、claudcode等给你配置,比如:

我的ngrok的认证是这个****,把本机的3080让我可以访问

1.4 插件市场可视化

在这里插入图片描述

DeepSeek Harness 可以通过插件市场管理和安装插件,例如安装 dshmarket

dsh plugin --profile web add dshmarket

如果出现:

'dsh' 不是内部或外部命令,也不是可运行的程序或批处理文件。

说明当前使用的是 DeepSeek Harness 源码运行方式dsh 并没有被安装为 Windows 全局命令。

这时进入 DeepSeek Harness 源码根目录,例如:

cd /d E:\path\to\deepseek-harness

然后改用:

pnpm dsh plugin --profile web add dshmarket

源码模式下可以简单记住:

启动 DeepSeek Harness
→ pnpm dsh web

安装插件
→ pnpm dsh plugin --profile web add <插件名>

例如:

pnpm dsh plugin --profile web add dshmarket

安装成功时通常可以看到类似:

Already up to date
Progress: resolved ..., added ..., done
Done in ...ms using pnpm v...

如果安装过程中出现:

[ERR_PNPM_IGNORED_BUILDS]
Ignored build scripts: ...

说明 Web Profile 中有依赖的构建脚本尚未经过 pnpm 授权。

先进入 Web Profile:

cd /d %USERPROFILE%\.dsh\profiles\web

执行:

pnpm approve-builds

根据实际需要选择允许执行构建脚本的依赖。

如果之前还出现:

[ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF]

可以先在同一目录执行:

pnpm install

让 pnpm 重新创建 Web Profile 的 node_modules

处理完成后,再回到 DeepSeek Harness 源码目录:

cd /d E:\path\to\deepseek-harness

重新执行:

pnpm dsh plugin --profile web add dshmarket

注意:Windows CMD 跨盘符切换目录时建议使用 cd /d,否则虽然输入了其他盘符的路径,当前命令行可能仍停留在原来的盘符,导致 pnpm dsh 找不到 DeepSeek Harness 项目中的 CLI。

安装完成后重新启动:

pnpm dsh web

然后进入 Web 页面即可使用插件市场。

1.5 绝大多数模型可调用的api(还有免费api路由自动帮你找!)

注册API

点我跳转
然后点击get apikey并完成一系列注册

复制key并进入dsh操作

在这里插入图片描述

免费模型路由

选如下的配置,可以看到还是挺快的,36min输入了3.3M tok,输出84.1K
在这里插入图片描述

2. Cordis

Cordis 是 DeepSeek Harness 底层使用的 插件框架(Plugin Framework)

官方文档中的原话是:

Cordis is the vendored plugin framework underneath DeepSeek Harness.

这里的 vendored(原意供应商但这里不是这个意思) 可以理解为:Cordis 的代码被直接纳入 DeepSeek Harness 项目中维护,而不是在运行时简单依赖一个外部包。

如果继续使用“大学社团”的比喻:

  • DeepSeek Harness = 整个大学社团;
  • LLM、Tools、Agent、Storage 等插件 = 社团中的不同部门;
  • Cordis = 社团的组织制度 + 行政管理系统。

Cordis 本身并不负责“思考问题”或者“调用搜索引擎”。它主要解决的是:

这么多插件怎样被加载、怎样获得其他能力、怎样相互通信,以及插件卸载以后怎样把自己留下的运行时影响清理干净。

所以,要真正读懂 DeepSeek Harness 的插件源码,首先需要理解 Cordis。

官方 Primer 将 Cordis 概括为 五个核心思想

1. Plugin / Service
        ↓
2. Context
        ↓
3. Dependency Injection
        ↓
4. Typed Events
        ↓
5. Reversible Effects

下面分别说明。


2.1 插件与 Service:社团里的各个部门

Plugin 发音:【普】拉金
Service 发音:【色】维斯

Cordis 中首先需要理解的是 Plugin(插件)

可以把插件想象成大学社团中的一个部门:

  • 搜索插件 = 外联部;
  • 代码执行插件 = 技术部;
  • LLM 插件 = 模型部门;
  • Agent 插件 = 任务协调部门;
  • Storage 插件 = 档案室。

每个插件负责相对独立的一类功能,并由 Cordis 管理它什么时候加载、什么时候卸载,以及它依赖哪些能力。

官方 Primer 对插件的描述是:

A plugin is a object that implements Service.

这里不要简单理解成:

所有 Plugin
都必须
extends Service

实际上 Cordis 支持多种 Plugin 形式,包括函数、带有 apply() 的对象,以及 Service 子类。

因此可以先把 Plugin 理解成:

被 Cordis 管理生命周期的功能单元。

而 Service 则是:

Plugin 向 Context 暴露的、具有固定名字并可以被其他 Plugin 或 Service 使用的一种能力。

例如:

某个 Plugin
    ↓
提供 tools Service
    ↓
Context
    ↓
ctx.tools
    ↓
其他 Plugin 使用

所以:

Plugin ≠ Service

更准确地说是:

Plugin
可以提供
Service

而其他 Plugin 可以依赖并使用这些 Service。

仍然用大学社团来类比,可以把:

Plugin

理解成:

一个部门

而:

Service

则更像这个部门对外开放的一个“服务窗口”。

例如:

技术部 Plugin
      ↓
提供
      ↓
代码执行 Service

其他部门真正关心的通常不是:

这个代码执行能力
到底由技术部里的谁实现

而是:

我现在有没有一个
可以使用的代码执行 Service

对应到 Cordis 中就是:

Plugin A
    ↓
提供 Service
    ↓
Context
    ↓
ctx.service
    ↑
    │
Plugin B 使用

这样做的好处是,Plugin B 不需要直接绑定 Plugin A。

它真正依赖的是:

某一种能力

而不是:

某一个具体实现

所以后面我们会看到类似:

inject = ['tools']

它表达的并不是:

我依赖某个叫 tools 的 Plugin

而是:

我启动之前
必须存在一个叫 tools 的 Service

这里还要继续区分一个很容易忽略的问题:

Service 本身并不一定需要依赖其他 Service。

有些 Service 可以直接建立在 Cordis 的 Context 和生命周期机制之上,然后再向其他 Plugin 提供能力。

例如:

Cordis
  ↓
Context
  ↓
Service A
  ↓
Plugin B

这里的 Service A 没有依赖其他业务 Service。

这种 Service 可以把它理解为整个依赖关系中比较靠底层的:

基础 Service

注意,“基础 Service”这里只是为了方便理解,并不是 Cordis 专门定义的一种新的 Service 类型。

它仍然是普通 Service,只是:

没有声明其他业务 Service 依赖

例如一个非常简单的 Service 可以是:

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

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

这里并没有:

static inject = [...]

因此它不需要等待其他业务 Service。

创建以后:

super(ctx, 'greeter')

会把它注册为:

greeter Service

于是:

GreeterService
      ↓
注册 greeter
      ↓
Context
      ↓
ctx.greeter

其他 Plugin 则可以进一步依赖它:

GreeterService
      ↓
greeter Service
      ↓
ctx.greeter
      ↑
      │
Consumer Plugin

不过这里说的“不依赖其他 Service”,指的是:

不依赖其他业务 Service

它仍然运行在:

Cordis Runtime
+
Context
+
生命周期机制

之上。

也就是说,我们可以区分两层依赖:

Cordis 框架依赖
│
├── Context
├── Fiber
└── 生命周期机制

业务 Service 依赖
│
├── tools
├── llm
├── storage
├── sessions
└── ...

一个 Service 可以没有第二层依赖,但仍然属于 Cordis 的运行体系。

更进一步,Service 自己也可以继续依赖其他 Service。

所以 Service 并不只是:

被 Plugin 使用

它自己也可能是另一个 Service 的消费者。

例如:

Service A
    ↓
Service B
    ↓
Plugin C

这里的 Service B

向下
依赖 Service A

同时

向上
提供 Service B

因此它同时具有两个身份:

对 Service A 来说
→ Consumer

对 Plugin C 来说
→ Provider

DSH 中就存在这种真实结构。

例如 ToolRuntime 本身是一个 Service

export class ToolRuntime extends Service {
  static inject = ['systemPrompt']

  constructor(ctx: Context, config = {}) {
    super(ctx, 'tools')
  }
}

这里两行代码的方向完全不同。

首先:

static inject = ['systemPrompt']

表示:

ToolRuntime
依赖
systemPrompt Service

而:

super(ctx, 'tools')

则表示:

ToolRuntime
向 Context 提供
tools Service

于是完整关系变成:

systemPrompt Service
        ↓
    ToolRuntime
        ↓
    tools Service
        ↓
     ctx.tools
        ↓
其他 Plugin / Service

所以 DSH 中的 Service 并不是一层平铺的。

更接近这样的结构:

Cordis
  ↓
Context
  ↓
基础 Service
  ↓
组合 Service
  ↓
更上层 Service
  ↓
业务 Plugin

当然,实际系统不会只有一条直线,而更像一张依赖网络:

                 Cordis
                    ↓
                 Context
                    ↓
              基础 Service A
               ↙          ↘
        Service B        Service C
            ↓                ↓
        Plugin D          Service E
                             ↓
                          Plugin F

于是整个系统可以不断进行:

使用已有 Service
        ↓
组合新的能力
        ↓
再提供新的 Service
        ↓
继续被其他 Plugin / Service 使用

因此,Plugin、Service 和 Context 三者之间更完整的关系可以理解为:

Plugin A
   ↓
提供 Service A
   ↓
Context
   ↓
Plugin / Service B 使用 Service A
   ↓
又可以提供 Service B
   ↓
Context
   ↓
继续被其他 Plugin 使用

所以最终可以先记住:

Plugin
=
功能实现和生命周期单元

Service
=
通过 Context 暴露的具名能力

Context
=
这些 Service 被注册、查找和连接的运行环境

以及一个非常重要的关系:

Plugin 可以不提供 Service

Service 可以不依赖其他业务 Service

Service 也可以依赖另一个 Service

一个 Plugin / Service
既可以是 Consumer
也可以是 Provider

因此 Cordis 真正组织的并不是:

Plugin A
直接调用
Plugin B
再直接调用
Plugin C

而更接近:

Plugin / Service
      ↓
提供能力
      ↓
Service
      ↓
Context
      ↓
其他 Plugin / Service
按能力进行依赖

这也是后面理解 inject 的关键:

Cordis 更关注“你需要什么 Service”,而不是“你必须依赖哪个具体 Plugin”。


2.1.1 函数式插件

最简单的函数式插件甚至可以只有:

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

export function apply(ctx: Context) {
  console.log('plugin loaded')
}

这里没有:

inject

也完全合法。

这说明:

并不是所有 Plugin 都必须依赖其他 Service。

如果一个插件不需要其他业务能力,Cordis 可以直接调用:

apply(ctx)

让它开始工作。

如果这个插件必须使用其他 Service,才需要声明 inject

例如:

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

export const name = 'my-plugin'

export const inject = ['tools', 'llm']

export f
unction apply(ctx: Context) {
  // 此时 ctx.tools 和 ctx.llm 已经可用
}

这里可以先注意两个东西:

inject
apply(ctx)

其中:

  • inject:声明这个插件启动所必需的 Service
  • apply(ctx):依赖满足以后,插件真正获得 Context 并开始注册功能的位置。

需要注意,应该写:

inject = ['tools', 'llm']

而不是:

inject = ['ctx.tools', 'ctx.llm']

因为:

tools
llm

是 Service 的名字;

而:

ctx.tools
ctx.llm

是获得 Context 以后访问这些 Service 的方式。

例如:

inject = ['tools']

实际表达的是:

“我的插件工作之前,必须先存在一个名为 tools 的 Service。”

如果 tools 还不存在:

Plugin
  ↓
PENDING
  ↓
等待 tools Service

等到其他模块把 tools Service 注册进 Context:

tools Service
     ↓
进入 Context
     ↓
ctx.tools 可用
     ↓
依赖满足
     ↓
执行 apply(ctx)

因此,inject 依赖的严格来说并不是“另一个具体 Plugin”。

例如:

Plugin A
   ↓
提供
   ↓
tools Service
   ↓
ctx.tools
   ↑
   │
inject: ['tools']
   │
Plugin B

Plugin B 真正依赖的是:

tools Service

而不是:

Plugin A

这意味着 Plugin B 不需要知道:

“到底是哪一个插件实现了 tools?”

它只需要知道:

“我启动时需要 tools 这个能力。”

如果以后换成另外一个 Plugin 来提供同名 tools Service,只要这个 Service 的接口保持一致,消费方通常不需要修改。

所以可以把 inject 记成:

插件告诉 Cordis:“我不关心是谁提供这些能力,但在我开始工作之前,请确保这些 Service 已经存在。”

如果某个 Service 只是可选能力,也可以不把它写进 inject,而是在使用时通过:

const service = ctx.get('serviceName')

判断它是否存在。

因此:

必须存在的 Service
→ inject

可有可无的 Service
→ ctx.get()

完全不需要其他 Service
→ 不写 inject

另外,Cordis 也支持把 nameinjectapply() 放在同一个对象中:

const myPlugin = {
  name: 'my-plugin',
  inject: ['tools'],

  apply(ctx) {
    // 插件初始化逻辑
  }
}

这种严格来说属于 Object Plugin,但和函数式插件表达的核心机制一样:

声明依赖
   ↓
等待 Service
   ↓
获得 Context
   ↓
执行 apply(ctx)

2.1.2 Service 子类

另一种方式是让 Plugin 本身成为一个 Service:

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

class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myService')
  }

  hello() {
    return 'hello'
  }
}

这里最关键的一行是:

super(ctx, 'myService')

其中:

myService

就是这个 Service 注册到 Context 中的名字。

因此可以理解成:

MyService
   ↓
super(ctx, 'myService')
   ↓
注册 Service
   ↓
Context
   ↓
ctx.myService

其他 Plugin 就可以声明:

export const inject = ['myService']

然后使用:

export function apply(ctx: Context) {
  ctx.myService.hello()
}

于是形成:

Service Plugin
     ↓
提供 myService
     ↓
Context
     ↓
ctx.myService
     ↑
     │
inject: ['myService']
     │
Consumer Plugin

这里还有一点很重要。

当前 Cordis 的 Service 在执行:

super(ctx, 'myService')

时,就会把当前实例注册进 Context,并纳入 Cordis 的生命周期管理。

因此它的实际关系更接近:

Service 创建
   ↓
注册到 Context
   ↓
运行
   ↓
所属 Plugin / Fiber 被卸载
   ↓
Service 自动从 Context 中移除

而不是必须自己手工写:

mount()
unmount()

再自行删除 ctx.myService

因此 Service 的价值不仅是“写成一个 class”,更重要的是:

它允许一个 Plugin 向整个 Context 提供稳定、具名、可被其他 Plugin 注入的能力。

可以用大学社团继续理解:

Plugin
=
一个部门

Service
=
这个部门开放给其他部门的服务窗口

例如:

技术部
=
Plugin

代码执行窗口
=
Service

其他部门不需要知道技术部内部具体是谁负责实现,只需要知道:

我要使用代码执行服务

这就是:

依赖能力
而不是
依赖具体实现

2.1.3 带你读源码:Todo 插件中的核心业务逻辑

理解了 Plugin、Service、inject 和 Context 以后,我们可以真正进入一个 DSH 插件源码,看看这些概念是怎样组合起来的。

这里选择 Todo 插件作为第一个例子。

Todo 可以理解成 DSH 给 Agent 提供的一张“任务清单”。

当 Agent 面对一个比较复杂的任务时,往往不是一步就能完成,而是需要先拆成几个步骤,例如:

分析项目结构
↓
找到需要修改的代码
↓
修改代码
↓
运行测试

这时,Agent 可以用 Todo 记录这些任务目前处于什么状态。

DSH 中 Todo 主要有三种状态:

pending
→ 还没有开始

in_progress
→ 正在进行

completed
→ 已经完成

例如一个任务执行到一半时,Todo 可能类似:

分析项目结构        completed
找到相关源码        completed
修改代码            in_progress
运行测试            pending

所以 Todo 并不负责“执行任务”。

它更像是:

帮助 Agent 记录和更新当前任务计划以及执行进度的工具。

在 DSH 中,模型真正使用的是一个名为:

todo_write

的 Tool。

模型每次调用 todo_write 时,会提交当前的完整 Todo 列表。新的列表会替换之前的列表,而不是只修改其中某一项。

因此可以先把整个过程简单理解成:

Agent
  ↓
把复杂任务拆成多个 Todo
  ↓
调用 todo_write
  ↓
提交完整 Todo 列表
  ↓
Todo Plugin 处理这份列表
  ↓
写入当前 Session
  ↓
得到当前 Todo 状态
  ↓
界面显示任务进度

这样一来,Todo 插件就很适合拿来理解前面讲过的 Plugin、Service、Context 和 inject

因为它自己是一个 Plugin,但工作时又需要使用其他 Service,然后再通过这些 Service 把 todo_write 等能力接入 DSH。

它的源码位于:

packages/todo/tool-todo/src/index.ts

Todo 本身并不是一个 Service 子类,而是一个函数式 Plugin。

它首先声明自己的名字和依赖:

export const name = 'tool-todo'

export const inject = [
  'tools',
  'sessionProjections'
]

这里:

tool-todo

是这个 Plugin 的名字。

而:

tools
sessionProjections

则是它运行时必须存在的两个 Service。

这表示 Todo Plugin 自己并不提供:

ctx.todo

这样的 Service。

相反,它需要别人已经把下面两个 Service 注册到 Context:

tools
sessionProjections

于是形成:

Context
│
├── ctx.tools
│
└── ctx.sessionProjections
        ↑
        │
     Todo Plugin

或者从依赖方向看:

Todo Plugin
    │
    ├── 依赖 tools
    │
    └── 依赖 sessionProjections

这里正好可以对应前面讲过的:

inject = [
  'tools',
  'sessionProjections'
]

它的意思不是:

Todo Plugin
依赖两个具体 Plugin

而是:

Todo Plugin 启动之前,需要 Context 中已经存在 toolssessionProjections 这两个 Service。

如果其中一个还不存在,Todo Plugin 就不能正常开始工作。

等这两个 Service 都准备好以后,Cordis 才会执行:

apply(ctx, config)

这时候 Todo Plugin 就可以通过 Context 使用它们:

ctx.tools

ctx.sessionProjections

然后分别注册自己的能力。

首先:

ctx.tools
   ↓
注册 todo_write Tool

这样 Agent 才能看到并调用:

todo_write

其次:

ctx.sessionProjections
   ↓
注册 todos 状态投影

这个 Projection 用来根据 Session 中发生的事件,计算:

“当前这一刻的 Todo 列表应该是什么?”

所以整个关系实际上是:

                  Todo Plugin
                      │
            inject 所需 Service
                      │
          ┌───────────┴───────────┐
          ↓                       ↓
       tools              sessionProjections
          │                       │
          ↓                       ↓
      ctx.tools        ctx.sessionProjections
          │                       │
          ↓                       ↓
注册 todo_write Tool       注册 todos Projection
          │                       │
          └───────────┬───────────┘
                      ↓
                  Todo 功能

如果再把 Agent 和 Session 加进来,整个过程会更直观:

Agent
  ↓
调用 todo_write
  ↓
Todo Plugin
  ↓
验证 Todo 数据
  ↓
写入 todo/write 事件
  ↓
Session
  ↓
todos Projection
  ↓
计算当前 Todo 状态
  ↓
界面显示

这也正好说明:

Plugin
Service
Tool
Projection

虽然经常一起出现,但它们并不是同一个概念。

在 Todo 中:

tool-todo
→ Plugin
→ 负责把整个 Todo 功能组织起来
tools / sessionProjections
→ Service
→ Todo Plugin 运行时需要使用的已有能力
todo_write
→ Tool
→ Agent 真正能够调用的工具
todos
→ Projection
→ 根据 Session 事件计算当前 Todo 状态

所以可以简单记成:

Todo Plugin
    ↓
使用 Service
    ↓
注册 Tool 和 Projection
    ↓
共同实现 Todo 功能

理解了这一层以后,再看 Todo 内部真正处理数据和状态的业务逻辑就会容易很多。

这个 Todo 插件中还包含 Schema 定义、配置、工具注册和 Cordis 接入等框架性代码。如果暂时忽略这些 boilerplate,只看真正处理 Todo 数据和状态的业务逻辑,可以重点关注三个部分:

Todo 插件
│
├── toTodoList()
│     └── 验证并转换 Todo
│
├── count()
│     └── 统计不同状态的任务数量
│
└── apply()
      └── 注册 Tool,并根据事件维护 Todo 状态投影

这三个部分可以先简单理解成:

Todo 数据是否合法 → 当前任务完成得怎么样 → Todo 状态怎样进入 DSH 的 Session 体系

接下来再逐个看源码。

1. 数据验证与转换:toTodoList()

首先看最核心的业务函数:

function toTodoList(
  raw: { content: string; status: string }[],
  allowParallel: boolean,
): TodoItem[] {
  const todos: TodoItem[] = []
  const seen = new Set<string>()
  let active = 0

  for (const item of raw) {
    const content = item.content.trim()

    if (content.length === 0) {
      throw new Error(
        'invalid todo: `content` must be a non-empty string',
      )
    }

    if (seen.has(content)) {
      throw new Error(
        `invalid todos: duplicate content ${JSON.stringify(content)}`,
      )
    }

    seen.add(content)

    if (item.status === 'in_progress') active++

    todos.push({
      content,
      status: item.status as TodoItem['status'],
    })
  }

  if (!allowParallel && active > 1) {
    throw new Error(
      `invalid todos: at most one task may be in_progress (got ${active})`,
    )
  }

  return todos
}

等效py代码(逻辑上)

import json
from typing import Literal, TypedDict, cast


TodoStatus = Literal[
    "pending",
    "in_progress",
    "completed",
]


class RawTodoItem(TypedDict):
    content: str
    status: str


class TodoItem(TypedDict):
    content: str
    status: TodoStatus


def to_todo_list(
    raw: list[RawTodoItem],
    allow_parallel: bool,
) -> list[TodoItem]:
    """
    验证并转换 Todo 列表。

    前提:
        raw 已经通过 JSON Schema 基础校验,
        因此 content/status 的基本类型以及 status 枚举值已合法。

    参数:
        raw:
            AI 返回的原始 Todo 列表。
        allow_parallel:
            是否允许多个任务同时处于 in_progress 状态。

    返回:
        合法且规范化后的 TodoItem 列表。

    异常:
        如果 Todo 违反业务规则,则抛出 ValueError。
    """
    todos: list[TodoItem] = []
    seen: set[str] = set()
    active = 0

    for item in raw:
        # 1. 去掉任务内容首尾空白
        content = item["content"].strip()

        # 2. 禁止空任务
        if not content:
            raise ValueError(
                "invalid todo: `content` must be a non-empty string"
            )

        # 3. 禁止重复任务
        if content in seen:
            duplicate = json.dumps(
                content,
                ensure_ascii=False,
            )
            raise ValueError(
                f"invalid todos: duplicate content {duplicate}"
            )

        seen.add(content)

        # 4. 统计进行中的任务
        if item["status"] == "in_progress":
            active += 1

        # JSON Schema 已经验证 status,
        # cast() 对应 TypeScript 中的 `as TodoItem['status']`
        status = cast(TodoStatus, item["status"])

        # 5. 构造合法 TodoItem
        todos.append(
            {
                "content": content,
                "status": status,
            }
        )

    # 6. 非并行模式下,最多只能有一个进行中的任务
    if not allow_parallel and active > 1:
        raise ValueError(
            "invalid todos: at most one task may be "
            f"in_progress (got {active})"
        )

    return todos

这个函数可以概括为:

把 AI 返回的原始 Todo 数据进一步进行业务规则校验,并转换成系统内部使用的 TodoItem[]

它接收两个输入:

raw
→ AI 返回的 Todo 列表

allowParallel
→ 当前是否允许多个任务同时处于 in_progress

最终输出:

TodoItem[]

整体处理流程为:

AI 返回 Todo
      ↓
遍历每个任务
      ↓
trim() 去除首尾空格
      ↓
检查内容是否为空
      ↓
检查任务是否重复
      ↓
统计 in_progress 数量
      ↓
构造 TodoItem
      ↓
检查 allowParallel
      ↓
返回 TodoItem[]

首先:

const content = item.content.trim()

负责清理任务内容前后的空白。

例如:

"   阅读 Cordis 文档   "

会转换成:

"阅读 Cordis 文档"

随后:

if (content.length === 0)

用于防止 AI 返回只有空白字符、实际上没有任何内容的 Todo。


接下来:

const seen = new Set<string>()

用于记录已经出现过的任务内容。

每处理一个任务,就通过:

seen.has(content)

判断相同任务是否已经存在。

例如:

阅读文档
编写代码
阅读文档

第二个“阅读文档”会被判定为重复内容,并直接抛出异常。

这里使用 Set 的原因也很直接:

它非常适合进行成员存在性检查,从而实现 Todo 内容去重。


随后:

if (item.status === 'in_progress') active++

统计当前有多少任务处于:

in_progress

遍历完成后,再根据:

allowParallel

决定这种状态组合是否合法。

如果:

allowParallel = false

那么:

任务 A    in_progress
任务 B    pending

是合法的。

但:

任务 A    in_progress
任务 B    in_progress

则违反业务规则。

对应代码就是:

if (!allowParallel && active > 1)

因此 toTodoList() 实际主要负责四件事:

① 内容规范化
② 空内容检查
③ 重复内容检查
④ in_progress 并行策略检查

这里还有一个值得注意的地方:

status: item.status as TodoItem['status']

其中:

as TodoItem['status']

是 TypeScript 的类型断言

它并不会在程序运行时真正检查:

status 到底是否合法

而只是告诉 TypeScript:

当前这个值可以按照 TodoItem['status'] 类型处理。

为什么这里可以这样写?

因为 toTodoList() 的输入有一个前提:

AI 返回的数据已经经过 JSON Schema 基础校验。

因此这里实际上形成了两层不同职责的验证:

AI Output
    ↓
JSON Schema Validation
    │
    ├── 数据结构
    ├── 字段类型
    └── status 枚举
    ↓
toTodoList()
    │
    ├── 内容规范化
    ├── 空内容检查
    ├── 重复检查
    └── 并行策略检查
    ↓
TodoItem[]

所以:

JSON Schema 负责结构是否合法,toTodoList() 负责业务语义是否合法。

这也是这里最值得注意的设计点之一。

AI 输出并不是直接被系统接受,而是继续经过确定性程序规则检查。


从复杂度来看,toTodoList() 只对任务列表进行一次主要遍历,因此时间复杂度为:

O(n)

其中 n 为 Todo 数量。

seen 最多存储 n 个任务内容,因此额外空间复杂度为:

O(n)

2. 状态统计:count()

Todo 验证完成以后,还需要统计不同状态的任务数量。

源码中使用了一个简单的辅助函数:

const count = (
  status: TodoItem['status'],
): number =>
  todos.filter(t => t.status === status).length

它的作用就是:

统计 Todo 列表中指定状态出现了多少次。

例如现在有:

任务 A    pending
任务 B    in_progress
任务 C    pending
任务 D    completed

执行:

count('pending')

得到:

2

实际使用时可以生成:

counts: {
  pending: count('pending'),
  inProgress: count('in_progress'),
  completed: count('completed'),
}

最终得到类似:

{
  "pending": 2,
  "inProgress": 1,
  "completed": 1
}

算法本身就是:

Todo 列表
   ↓
filter(status)
   ↓
length
   ↓
数量

一次 count() 调用需要扫描整个 Todo 列表,因此时间复杂度为:

O(n)

代码中虽然分别统计三个状态,相当于进行了三次扫描,但:

3n

在大 O 表示法中仍然属于:

O(n)

补充:allowParallelInProgress 是 Todo 插件中控制任务是否可以并行进行的配置参数,源码定义在 packages/todo/tool-todo/src/index.ts,但实际使用时应在项目的 cordis.yml 中找到 Todo 插件的 config 并修改它:设为 true 时允许多个任务同时处于 in_progress,设为 false 时最多只允许一个;该配置既会影响 AI 看到的 Todo 提示词,也会在 toTodoList() 中进行运行时校验。

3. 根据事件更新 Todo 状态:apply()

Todo 插件中还有一段重要逻辑,用来根据系统中发生的事件更新当前 Todo 状态。

这段代码位于:

packages/todo/tool-todo/src/index.ts

并注册在:

ctx.inject(...)
    ↓
sessionProjections.register(...)
    ↓
apply(state, event)

对应源码为:

ctx.inject(['sessionProjections'], (projectionCtx) => {
  projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({
    key: 'todos',
    schema: todosProjectionSchema,
    init: () => null,

    apply: (state, event) => {
      if (event.type === 'todo/write') {
        return event.data.todos
      }

      if (event.type === 'turn/start') {
        return null
      }

      return state
    },

    view: state => state,
    stateVersion: 2,
  })
})

等效py代码

def register_todo_projection(projection_ctx):

    def apply(state, event):
        if event["type"] == "todo/write":
            return event["data"]["todos"]

        if event["type"] == "turn/start":
            return None

        return state

    projection_ctx.session_projections.register(
        key="todos",
        schema=todos_projection_schema,
        init=lambda: None,
        apply=apply,
        view=lambda state: state,
        state_version=2,
    )


ctx.inject(
    ["sessionProjections"],
    register_todo_projection,
)

这里的核心其实就是内部的:

apply: (state, event) => {
  ...
}

它接收两个参数:

state → 当前 Todo 状态
event → 刚刚发生的事件

然后根据事件类型决定新的 Todo 状态。

如果收到:

todo/write

则:

return event.data.todos

也就是直接使用事件携带的新 Todo 列表。

如果收到:

turn/start

则:

return null

表示新一轮任务开始时清空上一轮 Todo。

如果是其他事件:

return state

则保持当前 Todo 不变。

因此整个逻辑可以简化为:

todo/write → 更新 Todo
turn/start → 清空 Todo
其他事件   → 保持不变

对于主要熟悉 Python 的读者,可以把它近似等效地理解成:

def update_todo_state(state, event):
    if event["type"] == "todo/write":
        return event["data"]["todos"]

    if event["type"] == "turn/start":
        return None

    return state

例如当前状态是:

state = [
    {"content": "查资料", "status": "in_progress"}
]

随后收到:

event = {
    "type": "todo/write",
    "data": {
        "todos": [
            {"content": "查资料", "status": "completed"},
            {"content": "写报告", "status": "pending"}
        ]
    }
}

执行:

state = update_todo_state(state, event)

新的状态就变成:

[
    {"content": "查资料", "status": "completed"},
    {"content": "写报告", "status": "pending"}
]

而这个 todo/write 事件来自工具执行过程中的:

exec.agent.session.append('todo/write', { todos })

因此可以把整个过程理解为:

AI 生成 Todo
     ↓
toTodoList() 校验
     ↓
合法 Todo
     ↓
session.append('todo/write', ...)
     ↓
产生 todo/write 事件
     ↓
apply(state, event)
     ↓
更新当前 Todo 状态

需要注意,源码中实际上有两个 apply

apply(ctx, config)
→ Todo 插件的加载入口

apply(state, event)
→ 根据事件计算 Todo 状态的投影回调

这里讨论的是第二个。

所以这段代码最简单的理解就是:

execute() 产生 Todo 事件,而投影中的 apply() 根据这些事件计算当前 Todo 应该是什么状态。

这种根据事件计算当前状态的方式,在代码中属于 状态投影(State Projection)

4. 三个核心逻辑是怎样配合的?

把前面的三个部分连接起来,可以得到:

                 AI 生成 Todo
                       │
                       ↓
             JSON Schema Validation
                       │
                       ↓
                 toTodoList()
                       │
                       ↓
                   TodoItem[]
                       │
                       ↓
                 todo/write Event
                       │
                       ↓
                    apply()
                       │
                       ↓
               Todo State Projection
                       │
                       ↓
                    count()
                       │
          ┌────────────┼────────────┐
          ↓            ↓            ↓
       pending     in_progress   completed

因此它们分别承担不同职责:

核心逻辑输入输出主要职责时间复杂度
toTodoList()raw[]allowParallelTodoItem[]规范化和业务规则验证O(n)
count()statusnumber统计指定状态 Todo 数量O(n)
apply()stateevent新投影状态根据 Event 更新 Todo ProjectionO(1)

补充说明:append() 负责告诉系统“发生了什么”,apply() 负责根据这些事件计算“现在的 Todo 状态是什么”。

这里可以看到 Todo 数据完整的处理主线:

Schema
  ↓
Business Validation
  ↓
Typed Data
  ↓
Event
  ↓
Projection

也就是:

结构校验
   ↓
业务校验
   ↓
标准化数据
   ↓
事件
   ↓
状态投影

5. 为什么不直接使用 AI 输出?

如果采用最简单的写法,可能直接:

const todos = aiOutput

但 AI 输出可能存在:

空任务
重复任务
非法 status
不符合并行策略的状态组合

因此 Todo 插件采用的思路更接近:

AI Output
    ↓
JSON Schema Validation
    ↓
toTodoList()
    ↓
TodoItem[]
    ↓
Event
    ↓
Projection

这里最值得注意的是:

LLM 负责产生候选结果,确定性的程序规则负责决定这些结果是否能够被系统接受。

所以不要把整个过程理解成:

LLM
 ↓
直接修改状态

而应该理解成:

LLM
 ↓
候选输出
 ↓
确定性验证
 ↓
标准化数据
 ↓
Event
 ↓
State Projection

这也是阅读 Agent 框架源码时非常重要的一个视角:

不要只关注模型生成了什么,还要继续寻找模型输出经过了哪些确定性约束,以及这些数据最终怎样进入系统状态。


6. 从这个插件应该学到什么?

Todo 插件本身的算法并不复杂。

真正值得学习的是它体现出来的数据处理边界:

AI Output
≠
可信的内部状态

而是:

AI Output
      ↓
Schema Validation
      ↓
Business Validation
      ↓
Typed Data
      ↓
Event
      ↓
Projection

因此,在后面阅读更复杂的 DeepSeek Harness 插件时,也可以沿用同样的方法:

先忽略部分框架样板代码
        ↓
找到真正处理数据的函数
        ↓
确定输入和输出
        ↓
寻找确定性校验规则
        ↓
寻找 Event
        ↓
寻找 Projection
        ↓
最后再回到 Cordis
理解这些逻辑是怎样被框架连接起来的

这样就不会一开始陷入大量插件注册和类型定义,而可以先抓住:

这个插件真正改变了什么数据,以及它怎样改变这些数据。

2.2 Context:插件的工作环境和服务仓库

Context 发音:英式偏 【康】泰克斯特,美式偏 【看】泰克斯特

如果 Plugin 是一个部门,那么 Context(ctx 就可以理解成这个部门进入系统后获得的:

工作环境 + 服务目录。

Cordis Primer 对 Context 有一句很重要的定义:

A context is a repository of services.

也就是说:

Context 是 Service 的仓库。

在代码中,Context 通常写作:

ctx

插件可以通过:

ctx.<key>

访问 Context 中提供的 Service,例如:

ctx.tools
ctx.llm
ctx.sessions

可以简单理解为:

ctx.tools    → 工具相关 Service
ctx.llm      → LLM 相关 Service
ctx.sessions → Session 相关 Service

整体关系可以表示为:

               Context(ctx)
                    │
        ┌───────────┼───────────┐
        ↓           ↓           ↓
     ctx.tools    ctx.llm    ctx.sessions
        │           │           │
        ↓           ↓           ↓
      工具能力     模型能力     会话能力

Context 在代码中怎么理解?

从实现思路上,可以把 Context 近似理解成一个保存 Service 的“字典”:

services.set('tools', toolsService)
services.set('sessions', sessionService)

对于熟悉 Python 的读者,可以近似理解为:

services = {
    "tools": tools_service,
    "sessions": session_service,
}

因此:

ctx.tools

可以近似理解成 Python 中:

services["tools"]

也就是:

根据 Service 的名字,找到对应的服务实例。

例如 Todo 插件中:

ctx.tools.register(...)

可以拆成两步:

ctx.tools
→ 获取 tools Service

.register(...)
→ 调用这个 Service 的 register() 方法

对应的 Python 思路可以写成:

tools = services["tools"]
tools.register(...)

所以 ctx.tools.register(...) 本质上就是:

从 Context 获取工具服务,再通过它注册新的工具。


不过还有一个问题:

如果代码需要的 Service 还没有准备好怎么办?

Cordis 使用 inject 来声明这种依赖。

例如:

ctx.inject(['sessionProjections'], (projectionCtx) => {
  projectionCtx.sessionProjections.register(...)
})

可以先简单理解成:

这段代码需要 sessionProjections Service,在它可用后再执行。

对于 Python 读者,可以近似理解为:

def register_projection(projection_ctx):
    projection_ctx.session_projections.register(...)

ctx.inject(
    ["sessionProjections"],
    register_projection,
)

如果只看它表达的逻辑,则更像:

if service_is_ready("sessionProjections"):
    register_projection(ctx)
else:
    run_when_service_ready(
        "sessionProjections",
        register_projection,
    )

这并不是 Cordis 的真实 Python 实现,而只是帮助理解:

inject 的重点是声明“这段代码依赖哪个 Service”。

因此可以把 Context 和 inject 放在一起理解:

Service
   ↓
进入 Context
   ↓
ctx.<key>
   ↓
插件获取并使用 Service

如果代码依赖某个 Service
   ↓
inject
   ↓
声明依赖
   ↓
Service 可用后执行相关逻辑

对于熟悉 Python 的读者,整体还可以近似理解成:

# Context:保存各种服务
services = {
    "tools": tools_service,
    "sessions": session_service,
}

# ctx.tools.register(...)
tools = services["tools"]
tools.register(...)

# ctx.inject(...)
run_when_service_ready(
    "sessionProjections",
    register_projection,
)

所以这一节最核心的一句话是:

Context(ctx)是插件获取系统 Service 的统一入口,而 inject 用来声明代码需要哪些 Service。

2.3 Dependency Injection:自动安排插件依赖

Dependency 发音:迪【喷】登西
Injection 发音:因【杰】克申

有了 Context 和 Service 以后,又会出现一个问题:

如果一个 Plugin 启动的时候需要 ctx.tools,但是 tools Service 还没有注册进 Context,怎么办?

前面 Todo Plugin 中其实已经出现过这个问题。

再看一次它的源码:

export const name = 'tool-todo'

export const inject = [
  'tools',
  'sessionProjections'
]

这几行看起来很简单,但其实已经告诉 Cordis:

Todo Plugin 要开始工作之前

必须先有:

tools Service
+
sessionProjections Service

也就是说:

Todo Plugin
    │
    ├── inject: tools
    │
    └── inject: sessionProjections

这里需要再次注意,inject 中写的是:

'tools'
'sessionProjections'

而不是:

'ctx.tools'
'ctx.sessionProjections'

因为前者是:

Service 的名字

后者:

ctx.tools
ctx.sessionProjections

则是 Service 注册进 Context 以后,Plugin 实际访问它们的方式。

那么问题来了:

Cordis 看到 inject = ['tools', 'sessionProjections'] 后,到底做了什么?

可以直接进入 Cordis 自己的源码。

在:

vendor/cordis/src/registry.ts

中,可以看到 Inject.resolve()

export namespace Inject {
  export function resolve(
    inject: Inject | null | undefined,
    result: Dict = Object.create(null),
  ) {
    if (!inject) return result

    if (Array.isArray(inject)) {
      for (const name of inject) {
        result[name] = null
      }
    }

    return result
  }
}

先不用看其他分支,只看 Todo 使用的数组形式:

[
  'tools',
  'sessionProjections'
]

Cordis 会逐个读取:

for (const name of inject) {
  result[name] = null
}

于是概念上相当于把:

inject = [
  'tools',
  'sessionProjections'
]

整理成:

需要的 Service:

tools
sessionProjections

也就是说,Cordis 从这里开始关心的已经不是:

哪个 Plugin 在前面
哪个 Plugin 在后面

而是:

当前这个 Plugin
需要哪些 Service?

接着继续往下看 RegistryService.plugin()

Cordis 加载一个 Plugin 时,会执行:

const fiber = new Fiber(
  this.ctx,
  config,
  Inject.resolve(plugin.inject),
  runtime,
  getOuterStack,
)

这里非常关键。

可以把它拆开看:

plugin.inject
       ↓
Inject.resolve()
       ↓
整理成 Service 依赖
       ↓
new Fiber(...)

也就是说:

Plugin 声明的 Service 依赖,最终会交给它自己的 Fiber 管理。

这里又出现了一个前面提到过的概念:

Fiber

暂时可以把 Fiber 理解成:

Cordis 用来管理一次 Plugin 运行实例的生命周期对象。

也就是说:

Plugin
   ↓
被 Cordis 加载
   ↓
创建 Fiber
   ↓
Fiber 管理:
依赖
状态
加载
卸载
清理

所以 Dependency Injection 并不是 Loader 简单看一眼:

“tools 有了吗?”

然后就结束了。

这个依赖关系会被交给 Fiber 持续管理。

继续看:

vendor/cordis/src/fiber.ts

Fiber 的状态定义中有:

export const enum FiberState {
  PENDING,
  LOADING,
  ACTIVE,
  FAILED,
  DISPOSED,
  UNLOADING,
}

这里第一个状态就是:

PENDING

源码注释对它的定义就是:

waiting for required services

也就是:

正在等待所需 Service。

所以一个 Plugin 被 Cordis 发现以后,并不意味着:

立刻执行 apply()

它首先可能处于:

PENDING

状态。

例如 Todo:

Todo Plugin
     │
     ├── tools                ?
     │
     └── sessionProjections   ?
     │
     ↓
   PENDING

Fiber 创建以后,源码会继续执行:

for (const name of Object.keys(this.inject)) {
  this._checkImpl(name)
}

this._refresh()

这里就已经非常直观了。

前面 Todo 的:

inject = [
  'tools',
  'sessionProjections'
]

经过 Inject.resolve() 以后进入 Fiber。

然后:

Object.keys(this.inject)

得到:

tools
sessionProjections

Cordis 接下来逐个执行:

this._checkImpl(name)

也就是概念上的:

检查 tools
↓
Context 中有没有?

检查 sessionProjections
↓
Context 中有没有?

因此可以把这段源码翻译成人话:

for 每一个声明的依赖 {

    去 Context 里看看
    这个 Service 现在有没有
}

再看 _checkImpl()

_checkImpl(name: string) {
  const impl = this.ctx.reflect._getImpl(name, true)

  if (!impl) {
    return delete this._store[name]
  }

  this._store[name] = impl
}

虽然实际源码还有一些可用性检查,但核心逻辑非常清楚。

首先:

this.ctx.reflect._getImpl(name, true)

可以理解成:

去当前 Context 中寻找这个名字对应的 Service 实现。

例如:

name = "tools"

那么 Cordis 就是在确认:

当前 Context
    ↓
有没有 tools Service?

如果不存在:

if (!impl) {
  return delete this._store[name]
}

也就是:

tools 没找到
↓
这个依赖目前不满足

如果找到:

this._store[name] = impl

就把当前找到的 Service 实现记录下来。

所以 Todo 的检查过程大概就是:

Todo Fiber
   │
   ├── 查找 tools
   │      ↓
   │    找到了?
   │
   └── 查找 sessionProjections
          ↓
        找到了?

检查完以后调用:

this._refresh()

继续看 _refresh() 中最重要的部分:

for (const name of Object.keys(this.inject)) {
  const impl = this._store[name]

  if (!impl) {
    epoch = INACTIVE
    break
  }

  epoch += ':' + impl.fiber.uid
}

this._setEpoch(epoch)

这一段就是 Dependency Injection 真正开始决定:

这个 Plugin 到底能不能运行

的地方。

它会再次遍历所有必需 Service:

for (const name of Object.keys(this.inject))

如果其中任何一个没有找到:

if (!impl) {
  epoch = INACTIVE
  break
}

那么整个 Plugin 就不能进入正常工作状态。

比如:

tools                ✓
sessionProjections   ✗

虽然 tools 已经有了,但:

sessionProjections

还不存在。

那么结果仍然是:

Todo Plugin
     ↓
PENDING

而不是:

“先运行一半再说”

所以 inject 表示的是:

这些 Service 都是 Plugin 正常运行的必要条件。

只有:

tools                ✓
sessionProjections   ✓

全部满足以后,才能继续:

Todo Plugin
     ↓
LOADING
     ↓
执行 apply(ctx, config)
     ↓
ACTIVE

这里就可以回到 Todo 自己的代码。

当依赖全部满足以后:

export function apply(ctx: Context, config: Config): void {
  ...
}

才真正开始执行。

而这时它已经可以直接使用:

ctx.tools
ctx.sessionProjections

例如:

ctx.sessionProjections.register(...)

以及:

ctx.tools.register(...)

所以:

export const inject = [
  'tools',
  'sessionProjections'
]

实际上给后面的:

ctx.tools
ctx.sessionProjections

建立了一个运行时保证:

如果 apply() 已经开始执行

那么 inject 声明的这些 Service
应该已经满足可用条件

因此 Todo 的完整过程其实是:

加载 tool-todo
      ↓
读取 inject
      ↓
tools
sessionProjections
      ↓
Inject.resolve()
      ↓
交给 Fiber
      ↓
逐个去 Context 查找 Service
      ↓
      ├── 有缺失
      │      ↓
      │   PENDING
      │
      └── 全部存在
             ↓
          LOADING
             ↓
       apply(ctx, config)
             ↓
          ACTIVE

那么 tools 又是从哪里来的?

继续看 DSH:

packages/core/tools/src/index.ts

其中有一个:

export class ToolRuntime extends Service {
  static inject = ['systemPrompt']

  constructor(ctx: Context, config: Config = {}) {
    super(ctx, 'tools')
  }
}

这里:

super(ctx, 'tools')

非常关键。

它表示这个 ToolRuntime 会向 Context 提供一个名字叫:

tools

的 Service。

而 Cordis Service 基类内部最终会执行类似:

ctx.reflect.provide(name, self, ...)

因此:

super(ctx, 'tools')

最终形成:

ToolRuntime
     ↓
提供 tools Service
     ↓
Context
     ↓
ctx.tools

于是现在 Todo 的整个依赖链终于能连起来了:

ToolRuntime
    ↓
提供
    ↓
tools Service
    ↓
Context
    ↓
ctx.tools
    ↑
    │
Todo inject: ['tools']
    │
Todo Plugin

所以 Todo 根本不需要知道:

ToolRuntime 这个 class
到底在哪个文件
是怎么实现的

Todo 只声明:

inject = ['tools']

意思就是:

我不关心谁实现 tools,我只要求我运行时 Context 中有 tools 这个 Service。

这正是 Dependency Injection 中“依赖能力而不是依赖具体实现”的意思。

更有意思的是,刚才的 ToolRuntime 自己又写了:

static inject = ['systemPrompt']

也就是说:

ToolRuntime

自己也是一个 Consumer。

它需要:

systemPrompt Service

同时它又提供:

tools Service

所以真实依赖链可以继续向下画:

systemPrompt Service
        ↓
    ToolRuntime
        ↓
    tools Service
        ↓
    Todo Plugin
        ↓
  todo_write Tool

这就说明 DSH 并不是简单地:

Plugin A
↓
Plugin B
↓
Plugin C

硬编码调用。

而是在逐层组合 Service:

底层 Service
      ↓
某个 Plugin / Service 使用
      ↓
组合出新的 Service
      ↓
再被上层 Plugin 使用

这也是为什么加载顺序不需要完全依靠:

先启动 A
再启动 B
再启动 C
最后启动 D

人工写死。

例如即使 Todo Plugin 先被 Loader 创建:

Todo Plugin
     ↓
发现 tools 不存在
     ↓
PENDING

之后 ToolRuntime 才出现:

ToolRuntime
     ↓
提供 tools

Cordis 仍然可以重新检查依赖。

_refresh() 后面的 _setEpoch() 正是在管理这种变化:

if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
  this.inertia = this._reload()
  return FiberState.LOADING
} else {
  this.inertia = this._unload()
  return FiberState.UNLOADING
}

先不用纠结 epoch 的具体实现。

这里只需要看两个方向。

当原来:

依赖不满足

后来变成:

依赖满足

Cordis 会:

_reload()
↓
LOADING
↓
执行 Plugin
↓
ACTIVE

反过来,如果 Plugin 正在运行时,一个必需 Service 消失:

ACTIVE
↓
某个 inject Service 消失
↓
依赖不再满足
↓
_unload()
↓
UNLOADING

因此 inject 并不是:

“启动的时候检查一次依赖。”

而更准确地说是:

Cordis 会让 Plugin 的生命周期跟随这些 Service 的可用状态变化。

例如:

tools 不存在
      ↓
Todo PENDING

tools 出现
      ↓
Todo LOADING
      ↓
Todo ACTIVE

tools 消失
      ↓
Todo UNLOADING

tools 再出现
      ↓
Todo 再次 LOADING
      ↓
Todo ACTIVE

所以 Dependency Injection 在 Cordis 中不仅解决:

谁先启动?

它实际上还在维护:

谁依赖哪些 Service?
这些 Service 现在存在吗?
依赖满足时谁应该启动?
依赖消失时谁应该卸载?
依赖恢复以后谁应该重新运行?

整个关系可以总结成:

Provider
   ↓
提供 Service
   ↓
Context
   ↑
Fiber 查找 Service
   ↑
inject 声明需要
   ↑
Consumer Plugin

2.3.1 Dependency Injection 真正解决什么?

看完源码以后,再回头理解 Dependency Injection 就比较容易了。

它首先解决的是:

Plugin 到底依赖“某个具体 Plugin”,还是依赖“某种能力”?

例如 Todo 并没有写:

我依赖 ToolRuntime

它写的是:

inject = ['tools']

所以真实关系不是:

Todo Plugin
    ↓
ToolRuntime

而是:

ToolRuntime
    ↓
提供
    ↓
tools Service
    ↓
Context
    ↑
inject: ['tools']
    ↑
Todo Plugin

也就是说:

Todo 依赖的是 tools 这种能力,而不是 ToolRuntime 这个具体实现。

第二,它解决了:

什么时候可以启动?

源码中的逻辑是:

读取 inject
↓
逐个检查 Service
↓
缺一个
→ PENDING

全部存在
→ LOADING
→ apply()
→ ACTIVE

所以加载顺序不需要简单写死成:

先 A
再 B
再 C

真正决定 Plugin 能否工作的,是:

它依赖的 Service
是否已经可用

第三,它解决了:

依赖运行过程中消失怎么办?

Cordis 的 Fiber 会继续跟踪依赖。

因此:

Service 消失
↓
Consumer 的依赖失效
↓
卸载 Consumer

如果以后 Service 恢复:

Service 恢复
↓
依赖重新满足
↓
Consumer 重新加载

所以 inject 建立的不是一次性的启动检查,而是:

Service 可用状态与 Plugin 生命周期之间的关系。

第四,它让依赖关系直接写在源码中。

例如我们第一次看到 Todo:

export const inject = [
  'tools',
  'sessionProjections'
]

甚至还没有继续读 apply(),就已经可以判断:

Todo 是 Consumer

它需要:
tools
sessionProjections

所以以后阅读 DSH 源码时,看到一个 Plugin,可以先看:

name
↓
它是谁?

inject
↓
它依赖谁?

apply(ctx)
↓
它拿这些 Service 做什么?

super(ctx, 'xxx')
↓
它是否又向 Context 提供新的 Service?

例如:

class ToolRuntime extends Service {
  static inject = ['systemPrompt']

  constructor(ctx) {
    super(ctx, 'tools')
  }
}

我们甚至不需要先读完整个 class,就能看出:

systemPrompt
      ↓
ToolRuntime
      ↓
tools

也就是说:

它消费 systemPrompt
同时提供 tools

这就是阅读 Cordis/DSH 源码时非常实用的一种方法。

因此,Dependency Injection 可以最终记成:

Plugin 或 Service 只声明“我需要哪些 Service”,Cordis 通过 Context 找到这些 Service,并由 Fiber 根据它们的可用状态决定什么时候加载、什么时候卸载以及什么时候重新运行。

换成一张图就是:

Provider Plugin / Service
          ↓
       provide
          ↓
       Service
          ↓
       Context
          ↑
      查找 Service
          ↑
        Fiber
          ↑
 inject 声明依赖
          ↑
   Consumer Plugin

所以 Cordis 中真正被组织起来的,不是一条简单的:

Plugin A
→ Plugin B
→ Plugin C

而是一张:

Service Dependency Graph

也就是:

Service 依赖图

Plugin 和 Service 在这张图中不断:

消费已有 Service
        ↓
实现自己的功能
        ↓
有些还会提供新的 Service
        ↓
继续被更上层使用

这才是 Cordis 中 Dependency Injection 真正做的事情。


2.4 Typed Events:社团的大群通知系统

Typed 发音:太普特
Events 发音:伊**【文】**茨

Service 解决的是:

“我现在需要某项能力。”

但插件之间还存在另一类通信:

“某件事情已经发生了。”

这种情况下就需要 Event(事件)

继续使用社团比喻。

假设技术部已经执行完代码。

它可以发送一个通知:

“代码执行完成!”

然后:

                 Code Finished
                      │
                      ↓
                  Event System
               ┌──────┼──────┐
               ↓      ↓      ↓
             Agent   UI     Logger

不同模块根据需要监听这个事件。

例如:

  • Agent 继续下一步;
  • UI 更新界面;
  • Logger 写入日志;
  • Telemetry 收集运行信息。

2.5 Service 和 Event 的区别

这两个概念很容易混。

最简单的区别是:

Service
=
“帮我做件事”

而:

Event
=
“有件事发生了”

例如:

场景更适合
调用 LLMService
执行工具Service
保存数据Service
工具执行完成Event
用户提交消息Event
模型流式输出发生变化Event
策略检查Event / Service,取决于具体设计

官方 Practical Rules 中给出了一个非常实用的原则:

直接能力调用优先使用 Service Method;拦截和 Policy 更适合使用 Event。

所以:

Direct capability call
→ Service

Interception / Policy
→ Event

这个区别后面读 Harness 源码时非常重要。


2.6 Typed Events:五种事件分发模式

前面已经知道,Plugin 之间除了可以通过 Service 共享能力,还经常需要通过 Event互相通知。

例如:

Session 结束了
工具执行完了
模型请求开始了
审批结果出来了

这些事情通常不适合写成:

Plugin A
   ↓
直接调用
   ↓
Plugin B

因为发送事件的一方往往根本不知道:

到底有几个 Plugin
正在关心这个事件?

所以 Cordis 提供了 Event 系统。

不过在真正看代码之前,我们先把 Cordis 中经常出现的五个英文单词认识一下:

emit
parallel
serial
bail
waterfall

这五个单词其实已经很形象地描述了五种 Event 的工作方式。

emit 的英文原意是:

发出
发射
散发

例如:

emit light
→ 发出光

emit a signal
→ 发出信号

所以在 Cordis 中:

emit

可以理解成:

“发出一个通知。”

最简单的记法就是:

emit
=
我说一声
大家听一下

例如:

ctx.emit('session/end')

可以理解成:

“Session 结束了,
关心这件事的人都知道一下。”

parallel 的英文原意是:

平行的
并行的

例如两条平行线:

────────────→

────────────→

它们同时向前。

所以在 Cordis 中:

parallel

表示:

“多个 Listener 同时开始工作。”

最简单记成:

parallel
=
大家一起做

例如:

           parallel
              │
      ┌───────┼───────┐
      ↓       ↓       ↓
Listener A Listener B Listener C

而调用方会等待:

A 完成
+
B 完成
+
C 完成

以后再继续。

所以完整一点就是:

大家同时开工,我等所有人都做完。


serial 的英文原意是:

连续的
按顺序的
串行的

在计算机里经常看到:

Serial
→ 串行

也就是:

一个接一个

所以 Cordis 中的 serial 可以先理解成:

Listener A
    ↓
Listener B
    ↓
Listener C

但是它还有一个重要特点:

只要某个 Listener 给出了有效结果,就可以停止继续询问后面的 Listener。

因此最好记成:

serial
=
一个一个问

谁先给出有效答案
就用谁的答案

例如:

Strategy A
   ↓
没答案
   ↓
Strategy B
   ↓
有答案
   ↓
return
   X
Strategy C 不再执行

bail 这个词第一次看到可能比较陌生。

它在英语里经常有:

退出
离开
脱身
中止

这样的意思。

例如口语中的:

bail out

就有:

退出
撤离

的感觉。

放在 Cordis 中就非常形象了:

Listener A
   ↓
没有结果
   ↓
Listener B
   ↓
有结果
   ↓
bail
   ↓
退出

所以可以记成:

“一旦有人给出了有效结果,就退出后面的 Listener。”

它和 serial 非常像。

区别主要在于:

serial
→ 可以 await
→ 异步串行

bail
→ 不 await
→ 同步串行

因此最简单可以记成:

bail = serial 的同步版本。

或者更口语一点:

bail
=
一个个问
有答案就撤

最后一个:

waterfall

英文原意就是:

瀑布

也就是水一层一层往下流:

上面
 ↓
 ↓
 ↓
中间
 ↓
 ↓
 ↓
下面

Cordis 中的 waterfall 也有这种:

一层一层往下传

的感觉。

但是它不是普通的:

A
↓
B
↓
C

而是每一层都有一个:

next()

例如:

Listener A
   │
   └── next()
          ↓
      Listener B
          │
          └── next()
                 ↓
             Listener C
                 │
                 └── next()
                        ↓
                    默认行为

每一个 Listener 都可以决定:

继续往下
→ next()

或者:

到我这里结束
→ 不调用 next()

所以可以把 waterfall 简单记成:

一层一层往下传,每一层都可以选择继续,也可以拦住。


因此,这五个单词可以先用一句非常简单的话记住:

emit
= 通知大家

parallel
= 大家同时做

serial
= 一个一个问,谁先有答案就停

bail
= serial 的同步版,有答案就撤

waterfall
= 通过 next() 一层一层往下传

或者用一句口诀:

emit 是“说一声”,parallel 是“一起做”,serial 是“排队问”,bail 是“有答案就撤”,waterfall 是“层层往下传”。

有了这个直觉以后,再来看 Cordis 中的 Typed Events 就容易很多了。

例如官方教程中的一个事件:

declare module '@deepseek-ai/cordis' {
  interface Events {
    'stats/report'(
      name: string,
      count: number
    ): void
  }
}

这里实际上是在告诉 TypeScript:

事件名称
=
stats/report

参数
=
name: string
count: number

返回值
=
void

于是发送时:

ctx.emit(
  'stats/report',
  name,
  count
)

监听时:

ctx.on(
  'stats/report',
  (name, count) => {
    console.log(name, count)
  }
)

TypeScript 都知道参数应该是什么类型。

所以这里所谓:

Typed Events

可以先简单理解成:

Event 的名称、参数和返回值都通过 TypeScript 类型明确声明。

但是 Cordis 的 Event 还有另外一个非常重要的东西:

Dispatch Mode

也就是:

这个事件到底应该怎样把消息分发给 Listener?

当前 Cordis 一共有五种主要分发模式:

emit
parallel
serial
bail
waterfall

源码中直接定义为:

export type DispatchMode =
  | 'emit'
  | 'parallel'
  | 'serial'
  | 'bail'
  | 'waterfall'

因此,一个 Event 的契约不只是:

事件叫什么
+
传什么参数

还包括:

它应该怎样被 dispatch

也就是说:

Event Contract
│
├── Event Name
├── Listener Signature
└── Dispatch Mode

不同 Dispatch Mode 的行为并不一样:

模式英文直觉调用形式核心语义
emit发出ctx.emit(...)同步广播,不等待 Listener 返回的 Promise
parallel并行await ctx.parallel(...)Listener 并发执行,等待全部结束
serial串行await ctx.serial(...)按顺序等待,出现有效结果立即停止
bail有结果就退出ctx.bail(...)serial 的同步版本
waterfall瀑布式向下传ctx.waterfall(...)Listener 通过 next() 层层包裹,可以继续或短路

下面再直接对着 Cordis 源码看这五种模式到底是怎么实现的。

2.7 Cordis 的整体运行逻辑:把前面的概念全部串起来

讲到这里,Plugin、Service、Context、Dependency Injection、Typed Events 和 Reversible Effects 已经分别介绍过了。

现在最重要的不是继续记更多 API,而是把它们真正连起来。

先从一个 Plugin 开始。

Plugin

它是 Cordis 管理的功能单元。

例如:

Todo Plugin
Search Plugin
LLM Plugin
Agent Plugin

一个 Plugin 被 Cordis 加载以后,会运行在自己的Context中。而 Context 又是 Plugin 获取各种能力的重要入口。

例如:

ctx.tools
ctx.llm
ctx.sessions
ctx.sessionProjections

这些:

ctx.xxx

背后对应的是一个个:

Service

所以最基本的关系是:

Plugin
   ↓
运行在
   ↓
Context
   ↓
访问
   ↓
Service

如果一个 Plugin 工作之前必须依赖某些 Service,它会通过:

inject

提前声明。

例如前面 Todo Plugin:

export const inject = [
  'tools',
  'sessionProjections'
]

意思是:

Todo Plugin
开始工作之前

必须存在:

tools Service
+
sessionProjections Service

然后 Cordis 会把这些依赖交给 Fiber 管理。

如果依赖还没有准备好:

Plugin
   ↓
PENDING

等依赖全部出现以后:

Service 准备完成
      ↓
Plugin LOADING
      ↓
执行 apply()
      ↓
ACTIVE

所以:

inject

解决的是:

Plugin 需要哪些能力,以及什么时候才具备运行条件。

而 Plugin 之间除了直接使用 Service,还经常需要:

Event

进行松耦合通信。

例如:

Session 结束
模型请求开始
Tool 即将执行
配置即将更新

发送者不需要知道:

到底有哪些 Plugin
正在监听?

它只负责发出 Event。

Cordis 再按照这个 Event 的 Dispatch Mode 处理 Listener。

目前我们已经看到五种:

emit
parallel
serial
bail
waterfall

可以简单记成:

emit
→ 通知大家

parallel
→ 大家同时做

serial
→ 一个一个问,有结果就停

bail
→ serial 的同步版本

waterfall
→ 通过 next() 层层包裹,可以继续,也可以短路

而 Plugin 在运行过程中还会不断做各种:

Registration

例如:

注册 Listener
注册 Tool
注册 Provider
注册 Prompt Section
注册 Watcher

这些 Registration 又应该尽量能够:

撤销

因此 Cordis 使用:

Effect
+
disposer

把:

创建

和:

清理

绑定起来。

最终就形成:

                         Cordis
                           │
                           ↓
                        Plugin
                           │
                     创建 / 管理 Fiber
                           │
                           ↓
                        Context
               ┌───────────┼───────────┐
               │           │           │
               ↓           ↓           ↓
            Service      Event       Effect
               ↑           │           │
               │           │           ↓
            inject       Listener    disposer
               │                       │
               └──────────┐            │
                          ↓            ↓
                     Plugin Lifecycle
                          │
              ┌───────────┴───────────┐
              ↓                       ↓
            Load                    Unload
              ↓                       ↓
         创建能力和注册          清理 Registration

所以前面这些概念其实分别回答了不同的问题:

Plugin
→ 谁在实现功能?

Context
→ Plugin 在什么运行环境中工作?

Service
→ 能力通过什么方式提供?

inject
→ Plugin 依赖什么能力?

Typed Events
→ Plugin 之间怎样松耦合通信?

Effect / disposer
→ Plugin 离开以后怎样把自己留下的东西清理掉?

Fiber
→ 谁把依赖、加载、卸载和 Effect 生命周期串起来?

这就是 Cordis 的主体结构。


2.7.1 Loader:这些 Plugin 又是从哪里来的?

到这里还有一个问题没有完全解决。

前面一直在讨论:

Plugin 已经进入 Cordis 以后
会发生什么?

但是实际启动 DeepSeek Harness 时,首先还需要决定:

这次到底加载哪些 Plugin?

这就是 Loader 要解决的问题。

例如一个 Profile 中可能有:

Profile
 │
 ├── Agent Plugin
 ├── LLM Plugin
 ├── Tools Plugin
 ├── Session Plugin
 ├── Storage Plugin
 └── Web Plugin

Loader 会读取这些配置,然后建立对应的 Plugin。

所以可以先把 Loader 理解成:

Cordis Plugin 的装配入口。

简单来说:

配置文件
   ↓
Loader
   ↓
决定加载哪些 Plugin
   ↓
ctx.plugin(...)
   ↓
Cordis
   ↓
Fiber
   ↓
依赖检查
   ↓
真正激活 Plugin

Loader 还需要处理诸如:

config
disabled
include
overlay

之类的配置机制。

例如:

disabled

可以决定:

当前这个 Plugin 到底要不要加载。

而:

config

则是 Plugin 真正运行时所使用的配置。

可以简单区分成:

disabled
→ 要不要这个 Plugin?

config
→ 如果要,它用什么配置?

另外,不同运行环境可能需要不同 Plugin 组合。

例如:

Development
Testing
Production

开发环境可能需要:

Debug Plugin
Mock Provider

而生产环境不需要。

这时比起在每个 Plugin 中写:

if development ...
if production ...

更适合让:

Base Configuration
      │
      ├── Development Overlay
      │
      └── Production Overlay

去描述不同环境下的装配差异。

所以 Loader 关注的是:

哪些 Plugin
+
用什么 Config
+
当前是否启用
+
不同环境如何组合

而 Cordis Runtime 关注的是:

Plugin 加载以后
依赖是否满足
什么时候运行
什么时候卸载
怎样清理

两者不要混在一起。


2.7.2 一个 DeepSeek Harness Plugin 到底是怎样运行起来的?

现在可以把前面的知识全部放进一次完整启动流程。

假设我们启动 DeepSeek Harness。

首先读取 Profile:

Profile
 │
 ├── Agent Plugin
 ├── LLM Plugin
 ├── Tools Plugin
 ├── Session Plugin
 └── Web Plugin

然后 Loader 读取配置:

Profile
   ↓
Loader
   ↓
解析 Plugin 配置
   ↓
建立 Plugin

Plugin 被交给 Cordis 以后:

Plugin
   ↓
ctx.plugin(...)
   ↓
Registry
   ↓
创建 Fiber

如果 Plugin 声明了:

inject = [
  'tools',
  'llm'
]

Cordis 会读取这些依赖。

前面已经看到,这一步会经过:

plugin.inject
     ↓
Inject.resolve()
     ↓
统一 Service 依赖表
     ↓
Fiber

Fiber 接下来检查:

tools Service
llm Service

是否已经存在。

如果:

tools ✓
llm   ✗

则:

Plugin
   ↓
PENDING

不会直接执行 apply()

之后提供 llm 的 Service 出现:

tools ✓
llm   ✓

于是:

Fiber
 ↓
LOADING
 ↓
apply(ctx, config)
 ↓
ACTIVE

Plugin 这时才真正开始工作。

然后 Plugin 可以通过 Context 使用已经声明的 Service:

ctx.tools
ctx.llm

也可以注册:

Event Listener
Tool
Provider
Prompt Section
其他 Effect

于是运行阶段变成:

Plugin ACTIVE
     │
     ├── 使用 Service
     │
     ├── 发出 Event
     │
     ├── 监听 Event
     │
     └── 创建 Effect

如果用户输入:

“帮我搜索资料并总结。”

一个非常粗略的 Harness 工作链可能类似:

用户
 ↓
Web / Session
 ↓
Agent
 ↓
LLM Service
 ↓
模型生成下一步动作
 ↓
Tools Service
 ↓
Tool Pipeline
 ↓
相关 Event / Waterfall
 ↓
Tool Provider
 ↓
Tool Result
 ↓
Agent
 ↓
LLM
 ↓
最终回答

这里需要特别注意:

Cordis 并不负责决定 Agent 应该搜索什么,也不负责模型本身的推理。

它更像是下面这一层:

LLM
Tools
Agent
Sessions
Providers
Plugins

之间的:

连接系统
+
依赖系统
+
生命周期系统

所以可以理解成:

DeepSeek Harness
负责构建 AI Agent 应用

Cordis
负责让组成这个应用的模块
能够可靠地装载、连接、通信和卸载

当某个 Plugin 被更新或卸载时,流程又会反过来。

例如:

Tool Plugin v1

要替换为:

Tool Plugin v2

理想过程是:

Tool Plugin v1
      ↓
Fiber unload
      ↓
执行 disposer
      ↓
清理:
Listener
Tool
Provider
Effect
...
      ↓
环境恢复干净
      ↓
Tool Plugin v2
      ↓
创建新的 Fiber
      ↓
检查 inject
      ↓
ACTIVE

因此 Reversible Effects 在这里就真正发挥作用了。

如果没有清理:

Tool Plugin v1 Listener
        +
Tool Plugin v2 Listener

那么旧版本留下的状态就可能继续影响新版本。

所以:

Dependency Injection
+
Fiber Lifecycle
+
Reversible Effects

实际上是连在一起工作的。


2.7.3 Cordis 的几个实际设计规则

理解运行流程以后,再看 Cordis 的一些设计规则会容易很多。

首先:

直接能力调用更适合放在 Service 中。

例如:

我要调用工具
→ ctx.tools

我要调用模型能力
→ 相应 LLM Service

我要访问 Session 能力
→ 相应 Session Service

也就是说:

我要某个能力
→ Service Method

而:

我要观察
我要拦截
我要修改
我要参与决策

则更适合:

Event

所以可以简单记成:

直接要能力
→ Service

多个 Plugin 要参与某个过程
→ Event

其中:

Waterfall

特别适合:

Interception
Policy
Transformation
Decision

例如:

Tool 执行之前
      ↓
Policy Listener
      ↓
允许?
   /      \
 是        否
 ↓         ↓
next()   short-circuit

第二条非常重要的规则是:

Registration 应该尽量拥有对应的 disposer。

也就是说写:

怎么注册?

的时候,同时应该想:

以后怎么撤销?

例如:

+ Listener

应该对应:

- Listener
+ Watcher

应该对应:

- Watcher
+ Provider

也应该拥有相应的生命周期清理。

因此:

Registration
       ↓
   disposer
       ↓
Fiber Lifecycle

是 Cordis 很重要的一条工程思路。

另外,如果多个资源之间存在明确的创建和清理顺序:

创建 A
 ↓
创建 B
 ↓
创建 C

那么卸载时通常应该:

销毁 C
 ↓
销毁 B
 ↓
销毁 A

也就是:

LIFO

后创建的资源先清理。

Cordis 的 Effect disposer 本身就按照相反注册顺序处理。

所以相关资源最好组织在清晰的 Effect 生命周期中,而不是:

这里创建一点
那里创建一点
然后希望以后记得全部删掉

2.7.4 DeepSeek Harness 和 Cordis 到底是什么关系?

现在可以把两者放进同一张图:

DeepSeek Harness
│
├── Agent
├── LLM
├── Tools
├── Sessions
├── Storage
├── Web / Headless
├── Providers
├── Adapters
│
└── Cordis
    │
    ├── Plugin / Service
    ├── Context
    ├── Fiber
    ├── Dependency Injection
    │    └── inject
    │
    ├── Typed Events
    │    ├── emit
    │    ├── parallel
    │    ├── serial
    │    ├── bail
    │    └── waterfall
    │
    ├── Reversible Effects
    │    └── disposer
    │
    └── Loader
         ├── config
         ├── disabled
         ├── include
         └── overlay

因此可以概括成:

DeepSeek Harness 是完整的 AI Agent 应用框架,而 Cordis 是支撑 Harness 模块化插件体系运行的底层插件框架。

换句话说:

Harness
=
“这个 AI Agent 系统要做什么”

Cordis
=
“组成这个系统的模块怎样组织起来”

比如:

Agent 怎么调用模型?
Tool 怎么接入?
Session 怎么提供?
Plugin 什么时候启动?
依赖消失怎么办?
Event 怎样分发?
Plugin 卸载以后怎么清理?

其中后一类:

模块组织
依赖管理
生命周期
事件通信
资源清理

正是 Cordis 重点解决的问题。


2.7.5 再用大学社团把整个 Cordis 串一次

如果前面的代码还是觉得抽象,可以继续使用大学社团这个比喻。

Cordis 概念大学社团比喻
Plugin社团里的一个部门
Service部门对外提供的公共能力
Context当前部门的工作环境和服务入口
ctx.<key>找到某个公共服务窗口
inject开工前声明必须具备哪些服务
Dependency Injection自动检查开工条件和安排依赖
Fiber记录这个部门本次任期和工作状态的管理档案
Event社团内部广播 / 协作通知
emit群里说一声
parallel多个部门同时行动
serial一个部门一个部门问,有答案就结束
bailserial 的同步版,有答案马上结束
waterfall一层层审批,通过 next() 继续
next()交给下一层
Short-circuit当前这一层直接做最终决定
prepend把某个处理人安排到队伍最前面
ctx.effect()建立一项带清理方式的工作
disposer任期结束时撤销这项工作
Overlay不同场景采用不同部门组合
Loader根据配置安排哪些部门进入系统
Cordis整套组织制度和行政管理系统

把完整过程放到学校中,就是:

学校决定成立哪些部门
        ↓
Loader

部门进入学校
        ↓
Plugin

给这个部门建立工作环境
        ↓
Context

查看部门开工需要哪些公共能力
        ↓
inject

条件不足
        ↓
PENDING

条件满足
        ↓
ACTIVE

部门通过公共服务窗口工作
        ↓
Service

不同部门互相通知和协调
        ↓
Event

部门任期内产生各种工作和权限
        ↓
Effect

部门离开
        ↓
disposer
        ↓
清理所有遗留

这就是整个 Cordis。


2.7.6 最后只记住六句话

如果前面的源码和概念很多,一时记不住,也没有关系。

先记住下面六句话就够了。

第一:

Plugin 是 Cordis 管理的功能单元。

第二:

Context 是 Plugin 的运行环境,也是访问 Service 的入口。

第三:

Service 是通过稳定名称公开的能力,例如 ctx.tools

第四:

Plugin 使用 inject 声明自己必须依赖哪些 Service,Fiber 根据这些依赖决定什么时候加载和卸载。

第五:

Typed Events 用于插件之间的松耦合通信,目前主要有 emitparallelserialbailwaterfall 五种分发方式。

第六:

Plugin 创建的 Registration 应该尽量可逆,让对应 disposer 在 Fiber 卸载时把资源清理干净。

把这六句话再压缩成一张图:

Plugin
  ↓
Context
  ↓
Service
  ↑
inject
  │
Fiber 管生命周期
  │
  ├── Event
  │
  └── Effect
        ↓
     disposer

如果这张图已经能够看懂,那么 Cordis 的主体思想基本就已经串起来了。


2.8 推荐的学习顺序与本章总结

到这里,Cordis 最核心的概念已经基本讲完了。

不过需要注意,官方的 Cordis Primer 本身并不是完整的 API 手册。

它更重要的作用是:

在真正阅读 Cordis API、DSH Plugin 和底层源码之前,先建立正确的心智模型。

因此学习 Cordis 时,不建议一开始就钻进:

vendor/cordis/src/

然后从几千行框架源码开始硬啃。

更适合的顺序是:

Cordis Primer
      ↓
Cordis Tutorial
      ↓
Subsystem / Service / Event Reference
      ↓
简单 Plugin 源码
      ↓
自己写 Plugin
      ↓
最后再深入 Cordis 内核源码

首先可以看:

docs/cordis-primer.md

Primer 主要帮助你建立这些基本概念:

Plugin
Service
Context
inject
Event
Effect
Lifecycle

也就是先回答:

Cordis 整体到底是怎样组织 Plugin 的?

然后再看:

docs/cordis-tutorial/index.md

Tutorial 会把前面的抽象概念真正变成代码。

例如:

定义 Plugin
↓
提供 Service
↓
通过 Context 使用 Service
↓
声明 inject
↓
注册 Event
↓
创建 Effect

等 Tutorial 跑通以后,再去看:

Subsystem Reference
Service Reference
Event Reference

这时候你看到:

ctx.tools
ctx.sessions
ctx.llm

或者:

emit
parallel
serial
bail
waterfall

就不会只把它们当成孤立的 API,而是知道它们在整个 Cordis 架构中处于什么位置。

接下来就可以挑一个比较简单的 DSH Plugin 阅读。

例如我们前面已经看过的 Todo Plugin:

packages/todo/tool-todo/src/

阅读时可以继续沿用前面总结出来的方法:

先找 name
↓
这个 Plugin 是谁?

再找 inject
↓
它依赖哪些 Service?

再看 apply(ctx)
↓
它使用这些 Service 做什么?

再找 ctx.xxx
↓
它访问了哪些能力?

再找 register / on / effect
↓
它向系统注册了什么?

最后看 disposer / Fiber
↓
这些东西卸载时怎样清理?

这样读源码会比从第一行开始逐字翻译有效得多。

最后,再尝试自己写一个最小 Plugin。

例如:

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

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  console.log('hello cordis')
}

然后慢慢加入:

inject
↓
Service
↓
Event
↓
Effect

这样 Cordis 的各个概念就会真正连起来。

所以推荐的学习路线可以压缩成:

先建立概念
      ↓
Primer

再跑真实代码
      ↓
Tutorial

再查具体接口
      ↓
Reference

再看真实实现
      ↓
DSH Plugin

再自己动手
      ↓
自己写 Plugin

最后深入框架
      ↓
Cordis Source

也就是说:

学习 Cordis 最好的方式不是死记 API,而是先理解架构,再通过真实 Plugin 把这些概念串起来。


到这里,也可以对这一章做最后一次总结。

Cordis 最核心的问题并不是:

“AI 怎么推理?”

也不是:

“模型应该调用哪个 Tool?”

这些属于 Agent、LLM 和具体业务逻辑需要解决的问题。

Cordis 真正关心的是:

“组成 AI Agent 的各种功能模块,怎样可靠地生活在同一个系统里?”

例如:

Plugin 怎么进入系统?

Plugin 怎样获得其他能力?

Plugin 之间怎样通信?

依赖还没准备好怎么办?

依赖运行过程中消失怎么办?

Plugin 注册的 Listener 和 Tool 谁来清理?

Plugin 更新以后怎样避免旧资源残留?

Cordis 通过前面讲过的几个核心机制来回答这些问题:

Plugin / Service
        +
Context
        +
Dependency Injection
        +
Typed Events
        +
Reversible Effects
        +
Fiber Lifecycle

把它们全部连起来,可以画成:

                           Cordis
                             │
                             ↓
                          Plugin
                             │
                        创建 Fiber
                             │
                             ↓
                          Context
                             │
             ┌───────────────┼───────────────┐
             ↓               ↓               ↓
          Service          Events          Effects
             │               │               │
             │       ┌───────┼───────┐       ↓
             │       ↓       ↓       ↓    disposer
             │     emit   parallel  serial
             │               │
             │              bail
             │               │
             │           waterfall
             │               │
             │             next()
             │
             ↑
           inject
             │
             ↓
     Dependency Injection
             │
             ↓
      Fiber 管理依赖状态
             │
      ┌──────┴──────┐
      ↓             ↓
    Load          Unload
      ↓             ↓
    ACTIVE      清理 Effects

如果再从 Plugin 的角度看一次:

Plugin 被 Loader 创建
        ↓
Cordis 创建 Fiber
        ↓
读取 inject
        ↓
检查所需 Service
        ↓
     是否满足?
      /      \
    否        是
    ↓         ↓
 PENDING   LOADING
              ↓
          apply(ctx)
              ↓
           ACTIVE
              ↓
    ┌─────────┼─────────┐
    ↓         ↓         ↓
 使用 Service  Event   Effect
                         ↓
                      disposer
                         ↓
                  Plugin Unload
                         ↓
                     自动清理

所以这些概念并不是互相独立的。

例如:

Context

不是单纯一个“装变量的对象”。

它连接着:

Service
Event
Effect
Fiber

而:

inject

也不是单纯一个配置数组。

它最终会影响:

Fiber
↓
PENDING
↓
LOADING
↓
ACTIVE
↓
UNLOADING

整个 Plugin Lifecycle。

Event 同样不是简单的“消息通知”。

根据不同 Dispatch Mode,它可以承担:

emit
→ 广播通知

parallel
→ 并发协作

serial
→ 异步顺序决策

bail
→ 同步顺序决策

waterfall
→ 环绕、修改、拦截和短路

ctx.on() 创建的 Listener 又不是永久留在系统里的。

它最终还会进入:

Fiber
↓
Effect
↓
disposer

从而在 Plugin 离开时自动清理。

所以 Cordis 最值得理解的,并不是某一个 API,而是这一整套:

装载
↓
依赖
↓
运行
↓
通信
↓
注册
↓
清理

的生命周期模型。

最终可以用一句话概括:

Cordis 是 DeepSeek Harness 底层负责 Plugin、Service、Context、Dependency Injection、Typed Events、Effect 和 Plugin Lifecycle 管理的插件框架。

再口语一点:

DeepSeek Harness 负责构建“AI Agent 能做什么”,Cordis 负责管理“这些功能模块怎样装进来、怎样找到彼此、怎样协作,以及不用以后怎样安全拆掉”。

如果只想记住整章最核心的一张图,可以记这一张:

                 Cordis
                   │
                 Plugin
                   │
                 Fiber
                   │
                 Context
          ┌────────┼────────┐
          ↓        ↓        ↓
       Service    Event    Effect
          ↑                  ↓
        inject            disposer
          │                  │
          └────────┬─────────┘
                   ↓
            Plugin Lifecycle

如果这张图能够看懂,那么 Cordis 的主体思想基本就已经理解了。


附:本章常见英文词发音

单词中文近似含义
Cordis【考】迪斯插件框架名称
Plugin【普】拉金插件
Context【康】泰克斯特上下文 / 运行环境
Service【色】维斯服务 / 能力
Dependency迪【喷】登西依赖
Injection因【杰】克申注入
Inject因【杰】克特声明 / 注入依赖
Resolve瑞【造】夫解析 / 确定
Event伊【文】特事件
Emit伊【米】特发出
Parallel【派】若莱尔并行
Serial【西】瑞欧串行
Bail【贝】尔退出 / 中止
Waterfall【沃】特佛瀑布
Effect伊【费】克特Effect / 副作用
Reversible瑞【沃】瑟伯可逆的
Dispose迪【斯波兹】清理 / 释放
Disposer迪【斯波】泽清理函数
Loader【漏】德加载器
Overlay【欧】弗雷覆盖层
Lifecycle【莱】夫赛口生命周期
Fiber【发】伊伯Fiber / 插件运行实例
Harness【哈】尼斯Harness
Agent【埃】真特智能体
Profile【普】肉发奥配置组合

AI 时代程序员必备技能

Codex、Claude Code、Cursor、Hermes Agent、OpenClaw等工程化实战专栏 ,讲透 AI 如何接管脏活累活

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值