DeepSeek Harness(dsh)是由 DeepSeek AI开发的开源Agent Harness。它采用一切皆插件的架构,并由Cordis驱动,其设计参见论文 A Programming Paradigm for Spatiotemporal Composability。Cordis本质上是插件的的执行内核,Harness则是基于Cordis的完整代理运行框架。理解Cordis就是理解Harness的底层机制:插件化、上下文共享、配置驱动、事件通信。这让开发者能灵活组合模型与工具,快速构建可扩展的AI Agent系统。在这个系列中,我将从源码为出发点,从各个角度Cordis的设计和实现。
1. 从Context说起
Cordis下的插件究竟是一个怎样的对象呢?要回答这个问题,就必须先了解Cordis整个架构体系一个最核心的对象,那就是作为上下文的Context对象。在Cordis框架下,Context是它的核心抽象之一,可以理解为插件运行的统一环境与依赖容器。它的作用和地位主要体现在以下几个方面:
- 统一环境:所有插件都挂载在同一个Context上,形成共享的运行空间;
- 服务解析:插件通过Context提供的服务解析器来访问其他插件暴露的功能(如日志、事件、注册表);
- 事件总线:Context内置事件系统,支持
ctx.on和ctx.emit等方法,插件间通信通过它完成; - 依赖注入:插件可以通过
ctx.inject获取其他服务,避免硬编码依赖; - 生命周期管理:Context负责插件的加载、卸载、资源清理,保证运行时稳定。
Context在Cordis中被定义一个类,基于代理的设计让这个类型拥有了极强的扩展性。我们可以将Context视为一个依赖注入容器,或者说可以将整个Cordis视为一个建立在依赖注入容器上的插件系统。我们通过相应的服务注册API在容器上注册相应的服务,服务的消费者则从这个容器中提取依赖服务对象。为了解决多个同名服务并存的问题(针对同一个名称注册了不同的服务实例供不同的插件使用),Context采用树形层次化设计:
- 顶层是根Context,包含日志、注册表、事件总线和服务接口;
- 根Context向下派生出多个子Context,每个子Context可以挂载不同的插件(模型适配器、工具插件、事件钩子等);
- Context的继承与隔离特性保证服务可继承、可覆盖,同时保持隔离性。
这种设计的优势在于:
- 层次化:父子Context形成树状结构,支持继承与隔离;
- 灵活性:不同子Context可以挂载不同插件,互不干扰;
- 统一性:共享Context空间保证服务调用一致,避免碎片化。
针对Context的介绍会贯穿整个系列,这里我们简单看看定义了核心成员的Context接口的定义:
interfaceContext{[symbols.isolate]:Dict<symbol>[symbols.intercept]:Dict root:thisbaseUrl?:stringevents:EventsService logger:LoggerService reflect:ReflectService registry:RegistryService}定义在Context接口中的核心成员包括:
- [symbols.isolate]:维护服务隔离映射表。当某个服务需要在不同作用域下拥有独立实例,要求同名服务可以在不同Context下可独立存在,互不干扰;
- [symbols.intercept]:存储服务的拦截配置。通过
intercept方法,插件可以在加载时注入额外配置或修改服务行为。利用这个字典实现插件级的配置合并与行为定制,而不影响父级Context; - root: 指向应用的根Context。无论当前Context层级多深,都能通过
ctx.root访问全局服务与配置,它保证所有子Context都能追溯到统一的根环境; - baseUrl: 定义插件或模块的基础路径。用于解析相对路径的模块加载或资源定位,它让插件能在不同运行环境下正确定位依赖;
- events:事件总线服务。提供
ctx.on、ctx.emit等方法,实现插件间通信,促成了Cordis松耦合的事件驱动架构; - logger:日志服务。通过
ctx.logger(name)获取命名日志器,支持分级与上下文追踪,提供统一日志输出,方便调试与监控; - reflect:反射层服务。支撑上面提到过的基于代理机制的动态属性解析,它让Context能像对象一样直接访问服务;
- registry:插件注册表。负责插件的加载、卸载与依赖管理。
2. 插件的表现形式
Context相当于Cordis这个插件内核的执行上下文,它提供了注册、执行和释放插件所需的运行时信息和服务对象。插件本质上是注册到某个Context中并在此上下文中执行的操作,插件一般会有对应的配置和元数据。表示插件的Plugin是由如下三个泛型接口组成的联合,类型参数T表示的是插件对应配置的类型。
exporttypePlugin<T=any>=|Plugin.Function<T>|Plugin.Constructor<T>|Plugin.Object<T>表示三种插件形态的接口都是针对如下这个Base接口的扩展,该接口定义了插件的基本结构,它的所有成员都是可缺省的。
exportnamespacePlugin{exportinterfaceBase<T=any>{name?:stringConfig?:StandardSchemaV1<any,T>inject?:Inject provide?:string|string[]intercept?:Dict<boolean>}}exporttypeInject<M=Dict>=(keyofM)[]|{[KinkeyofM]?:M[K]}Base接口定义的成员说明如下:
- name:插件的名称标识;
- Config:插件的配置Schema;
- inject:注入的依赖服务声明。可以是注入的服务名称列表,有可以是同时携带配置的字典;
- provide:插件对外提供的服务名称列表;
- intercept:可拦截服务声明,指明可以消费哪些服务的拦截配置。
2.1 函数形式
函数形式作为简单明了,只需要提供一个将Context和配置对象作为输入的函数即可,对应与如下这个Plugin.Function<T>接口。
exportnamespacePlugin{exportinterfaceFunction<T=any>extendsBase<T>{(ctx:Context,config:T):any}}在如下的演示程序中,我们注册的插件体现为定义的greet函数,配置类型利用timeOfDay字段提供表示输出问候语的时间部分(morning、afternoon和evening)。整个插件的目的就是在控制台输出一条根据指定时间生成的的问候语。
import{Context}from'@deepseek-ai/cordis'import*asreadlinefrom"readline"functiongreet(context:Context,config:{timeOfDay:string}){console.log(`Good${config.timeOfDay}`)}asyncfunctionmain(){constctx=newContext();constrl=readline.createInterface({input:process.stdin,output:process.stdout});constloop=function(){rl.question("Enter time of day or exit: ",(answer)=>{if(answer=="exit"){rl.close()}else{ctx.plugin(greet,{timeOfDay:answer});setTimeout(()=>loop(),1000);}});}loop();}main();我们在main函数中我们创建了一个Context对象,并构建了一个循环利用输入来指定greet插件的配置。我们在注册插件的时候利用Context的registry属性得到表示注册服务的RegistryService对象,并调用其plugin方法完成插件的注册。执行这个程序,以插件配置的形式输入不同的时间,注册的插件会输出相应的问候语。
Entertimeof day or exit: morning Good morning Entertimeof day or exit: afternoon Good afternoon Entertimeof day or exit: evening Good evening由于ctx.plugin方法会以混入(Mixin)的形式映射到ctx的plugin方法,所以我们一般会直接调用ctx.plugin方法来注册插件。
2.2 类形式
插件的第二种形式体现为如下这个Plugin.Constructor<T>接口,它包含一个具有与上述函数相同签名的构造函数。
exportnamespacePlugin{exportinterfaceConstructor<T=any>extendsBase<T>{new(ctx:Context,config:T):any}}这意味着我们可以将插件定义成一个具有指定构造函数的类。所谓上面演示的通过函数greet定义的插件,也可以通过如下这个greeter类来定义。
import{Context}from'@deepseek-ai/cordis'import*asreadlinefrom"readline"classgeeter{constructor(context:Context,config:{timeOfDay:string}){console.log(`Good${config.timeOfDay}`);}}asyncfunctionmain(){constctx=newContext();constrl=readline.createInterface({input:process.stdin,output:process.stdout});constloop=function(){rl.question("Enter time of day or exit: ",(answer)=>{if(answer=="exit"){rl.close()}else{ctx.plugin(geeter,{timeOfDay:answer});setTimeout(()=>loop(),1000);}});}loop();}main();2.3 对象形式
插件的第三种形式体现为如下所示的Plugin.Object<T>接口,该接口必须包含一个名为apply的方法,该函数同样具有与上面一致的签名。
exportnamespacePlugin{exportinterfaceObject<T=any>extendsBase<T>{apply(ctx:Context,config:T):any}}如果采用对象形式,上面演示程序针对插件的注册可以改成如下的形式。
import{Context}from'@deepseek-ai/cordis'import*asreadlinefrom"readline"functiongreet(context:Context,config:{timeOfDay:string}){console.log(`Good${config.timeOfDay}`)}asyncfunctionmain(){constctx=newContext();constrl=readline.createInterface({input:process.stdin,output:process.stdout});constloop=function(){rl.question("Enter time of day or exit: ",(answer)=>{if(answer=="exit"){rl.close()}else{ctx.plugin({apply:greet},{timeOfDay:answer});setTimeout(()=>loop(),1000);}});}loop();}main();2.4 插件的最终表现形式
虽然我们可以采用上述三种方式来定义和注册插件,但是对于Cordis来说,插件就是一个单纯的函数。两者之间的转换体现在如下这个resolve函数中,具体的转换逻辑如下:
- 如果原始形态就是函数,直接使用该函数;
- 对于其它两种形式,取其
apply成员(构造函数的apply成员或者插件对象的apply方法)。
resolve(plugin:Plugin):Function|undefined{try{if(typeofplugin==='function')returnpluginif(isApplicable(plugin))returnplugin.apply}catch{}}functionisApplicable(object:Plugin){returnobject&&typeofobject==='object'&&typeofobject.apply==='function'}3. 插件函数的返回值
我们再来看看函数和对象形式的插件接口Plugin.Function<T>和Plugin.Object<T>的定义,可以看出两者的apply方法对返回类型都没有限制(any)。由于插件是可拔插的组件,我们希望当插件被卸载的时候,它引用的资源能够全部释放掉。在这种情况下,我们可以让函数返回一个执行清理工作的函数。
exportnamespacePlugin{exportinterfaceFunction<T=any>extendsBase<T>{(ctx:Context,config:T):any}exportinterfaceObject<T=any>extendsBase<T>{apply(ctx:Context,config:T):any}}插件的生命周期管理由其关联的Fiber对象来负责,RegistryService的plugin方法就返回这么一个对象,具体可以参考我的文章DeepSeek Harness插件内核-03:利用Fiber管理Context和插件的生命周期。在如下演示程序中,我们分别以函数和对象形式注册了两个插件,并得到对应的Fiber对象。插件函数都返回了一个函数输出一段相应的指示性文本。在完成插件注册后,我们等待一秒让插件正常执行,再先后调用两个Fiber对象的dispose方法。从输出的结果可以看出,插件函数返回的函数会因Fiber对象的释放被调用。
import{Context}from'@deepseek-ai/cordis'constcontext=newContext();constfiber1=context.registry.plugin(ctx=>{console.log("Plugin1 executes...");return()=>console.log("Plugin1 is disposed...");})constfiber2=context.registry.plugin({apply(ctx:Context){console.log("Plugin2 executes...");return()=>console.log("Plugin2 is disposed...");}})setTimeout(()=>{console.log("Fiber1 is disposed...")fiber1.dispose();console.log("Fiber2 is disposed...")fiber2.dispose();},1000);输出:
Plugin1 executes... Plugin2 executes... Fiber1 is disposed... Fiber2 is disposed... Plugin1 is disposed... Plugin2 is disposed...4. 针对类形式插件的特殊处理
再来看看表示类形式插件的Plugin.Constructor<T>接口,它仅仅要求提供一个指定签名的构造函数。构造函数最终的使命是创建对象,有没有想过生成的这个对象对插件本身有何作用?
exportnamespacePlugin{exportinterfaceConstructor<T=any>extendsBase<T>{new(ctx:Context,config:T):any}}其实我们可以使用两个预定义的Symbol为插件对象定义两个特殊成员:
- symbols.initHooks:初始化自动执行的钩子函数;
- symbols.init:用于提供插件卸载时执行清理工作的回调函数。
具体实现体现在如下的演示程序中:
classPlugin{[symbols.initHooks]=[()=>console.log("Initial hook 1 execute..."),()=>console.log("Initial hook 2 execute...")];[symbols.init]=()=>console.log("Plugin is disposed...");constructor(ctx:Context){console.log("Plugin initialize...");}}constcontext=newContext();constfiber=context.registry.plugin(Plugin);setTimeout(()=>{console.log("Fiber is disposed...")fiber.dispose();},1000);process.stdin.resume();输出:
Plugin initialize... Initial hook 1 execute... Initial hook 2 execute... Plugin is disposed... Fiber is disposed...