CHAPTER 03 · STAGE 01 · 建立项目地图

读源码前需要懂的 TypeScript 与 Node.js

阅读这个仓库需要的不是完整 TypeScript 语言知识,而是一组反复出现的结构:ESM 导入导出、接口与类型、联合类型、泛型、声明合并、异步迭代、AbortSignal、类继承和 disposer。

不需要先成为 TypeScript 专家

阅读这个仓库需要的不是完整 TypeScript 语言知识,而是一组反复出现的结构:ESM 导入导出、接口与类型、联合类型、泛型、声明合并、异步迭代、AbortSignal、类继承和 disposer。

先能识别这些结构的作用,再逐步学习细节。遇到复杂类型时,优先问“它约束了谁能传什么”,而不是尝试在脑中完成 TypeScript 编译器的所有推导。

ESM:文件之间怎样连接

仓库使用 ESM。常见形式如下:

import { Session } from '@deepseek-ai/dsh-session'
import type { Message } from '@deepseek-ai/dsh-llm'
export class AgentLoop { ... }

普通 import 会在运行时加载模块。import type 只给 TypeScript 检查类型,编译后会消失。看到 import type 时,不要把它误认为运行时依赖。

仓库内的本地相对导入带 .ts 后缀,包之间使用包名。源码通过 TypeScript paths 解析,构建产物通过 package exports 解析。初学阶段只需要知道:测试通常直接运行源码,而发布路径还要验证构建后的 lib/。

interface 和 type

两者都能描述数据,但仓库常利用它们的不同能力。

interface 经常用于可扩展映射和 Service API。例如插件可以通过 declaration merging 给一个已有接口增加事件成员。type 经常用于联合、映射结果或别名,例如一个结果只能是成功、失败或取消中的一种。

阅读时把它们翻译成问题:

  • interface ToolDefinition:一个工具必须提供哪些字段?
  • type FinishReason = ...:模型结束原因允许哪些情况?
  • type SessionEvent<T> = ...:不同事件类型与各自 data 怎样绑定?

Discriminated Union:通过标签判断具体分支

仓库大量使用带标签的联合类型:

{ kind: 'allow' }
{ kind: 'deny', reason: string }
{ kind: 'ask', question: string }

代码通过 switch (decision.kind) 或 if (decision.kind === 'deny') 缩小类型。这比多个可选字段安全,因为每个分支能明确规定自己需要的数据。

看到 assertNever 时,它通常表示这是一个封闭联合:新增分支后,编译器会提醒所有 switch 更新。看到带 default 的事件 switch 时,往往表示联合允许其他插件通过声明合并扩展,不能假设已知分支就是全部。

泛型:把某个类型当参数

SessionEvent<T> 中的 T 可以理解成“具体事件名”。当 T 是 'tool/result' 时,TypeScript 能推导对应的 data 字段。

初学时不用展开复杂泛型,只做两步:找到泛型参数代表的概念;找到调用处传入的具体类型。如果调用处没有显式写出,观察函数参数和返回值让编译器推导了什么。

Declaration Merging:多个包共同扩展同一张类型表

Cordis Context 和事件表需要让插件扩展。一个插件可能声明:

declare module '@deepseek-ai/cordis' {
  interface Context {
    myService: MyService
  }
  interface Events {
    'my/event'(payload: Payload): void
  }
}

这段代码只扩展 TypeScript 对 Context 和 Events 的认识,不会自动创建 Service,也不会自动发出事件。插件仍要在运行时注册 Service 或监听器。

这是最容易误解的地方:类型声明告诉编译器“允许存在”,插件 effect 才让它“实际存在”。

类、继承与 Service

许多核心服务继承 Cordis 的 Service。类表达有状态的运行时能力,Cordis 负责把实例挂到 Context 并管理生命周期。

看到 class LlmRuntime extends Service 时,先看三件事:它使用哪个 ctx key;构造时需要哪些 Service;公开方法是注册 Provider、执行请求还是查询状态。不要从私有辅助函数开始读。

抽象 Service 常使用 abstract 方法规定 Provider 必须实现的 API。例如持久化能力定义“加载、追加、刷新”等操作,JSONL 和 SQLite 后端分别实现。

Promise、async/await 与 AsyncIterable

工具执行通常返回 Promise,因为文件、网络和进程操作需要等待。LLM 流式输出则常表现为 AsyncIterable:调用方可以用 for await 持续接收 chunk。

区分两个时间点:函数已经返回一个异步对象,不等于工作已经完成;Promise settle 或异步迭代结束,才表示调用的异步生命周期完成。

这也是 teardown 复杂的原因。插件卸载时不能只删除注册表条目,还要等待已开始的工作停止并收敛。

AbortSignal:协作式取消

AbortSignal 不是强制终止线程。调用方发出取消信号,被调用的工具、网络请求或子进程需要观察或转发这个信号,才能尽快停止。

因此 ToolDefinition 的执行函数不仅接收参数,还接收包含 signal 的执行上下文。一个忽略 signal 的同进程函数可能继续运行;框架只能等待它结束,不能安全地从外部杀死任意 JavaScript。

阅读取消代码时问:谁创建 signal;谁转发 signal;取消后谁负责等待资源完全停止;Session 最终记录什么结束原因。

disposer、ctx.effect() 与资源所有权

注册工具、事件或 Provider 通常会返回一个 disposer。调用 disposer 后,注册应从系统中消失。

ctx.effect() 把“建立资源”和“释放资源”绑定到插件生命周期。插件卸载或热重载时,Cordis 能按生命周期撤销注册。如果一个插件直接修改全局数组却没有 disposer,旧注册可能在重载后残留。

把 disposer 理解为资源所有权证明:创建者必须明确怎样撤销自己建立的状态。

Branded ID:看起来是字符串,但不能混用

SessionId、MessageId、CallId 等在运行时可能都是字符串,但类型系统给它们不同品牌。这样就不能意外把 MessageId 传给需要 SessionId 的函数。

边界处通常有构造函数,例如 SessionId(raw)。品牌不是加密或运行时安全机制,而是防止同进程 TypeScript 代码把不同领域的标识符混用。

Schema 校验与静态类型不是一回事

TypeScript 类型在运行时不存在。来自 YAML、JSON、模型 Tool Call、磁盘日志或网络的数据必须在运行时校验。仓库使用 schema 库描述配置和边界数据。

同进程、已经经过 TypeScript 检查的对象通常不重复做敌对输入校验;真正跨越配置、模型、文件、进程或 wire 的数据才需要运行时检查。

读一个陌生类型的步骤

  1. 找它是输入、输出、事件 data 还是持久化记录。
  2. 找创建它的生产者。
  3. 找至少一个消费者。
  4. 看标签字段怎样区分分支。
  5. 看对象是否会跨越 JSON、磁盘、进程或模型边界。
  6. 最后再研究泛型和映射类型细节。

本篇练习

  1. 在 packages/core/agent/src/runtime-types.ts 找到 AgentStatus、PreStepDecision 和 Agent,判断哪些是联合类型、哪些是接口。
  2. 在 packages/llm/llm/src/brand.ts 找到 MessageId 与 CallId,解释品牌解决的问题。
  3. 在 packages/core/agent/src/index.ts 找一个公开的异步创建方法,指出谁拥有返回的 handle。
  4. 用自己的话解释 declaration merging 为什么不能代替运行时插件注册。

验收标准:遇到一个复杂类型时,能先说出它约束的边界和生产者/消费者,而不是只说“这是 TypeScript 高级类型”。