Agent 跑着换掉 shell,为什么不留残余:DSH 底下的 Cordis
目录
一句“跑一下测试”背后的能力#
DeepSeek Harness(dsh)是 DeepSeek 开源的 agent harness,一切皆插件,由 Cordis 驱动。它的基础配置里有 97 个插件条目。
Cordis 的回答:三句话#
Cordis 的设计论文把动态组合分成两个维度:插件之间的依赖能声明、能跟着变,是空间上的;插件移除时副作用能全部撤销,是时间上的。
第一层:DSH 是一棵插件树#
运行中的 dsh 是一棵插件树,启动时把几层配置依次叠在一个空的条目列表上;条目的先后不决定加载,插件按服务是否可用来激活。
第二层:插件之间只隔着 ctx#
DSH 把可替换的能力做成 ctx 上的接缝,比如 ctx.shell 有本机、沙箱、PowerShell 几个提供方,使用方 tool-bash 只认 ctx 键。插件用 inject 声明要用的服务,服务没到齐之前停在 PENDING。
第三层:拆开 bash 工具这个插件#
tool-bash 注入 tools、shell、systemPrompt 等服务,ctx.tools.register 本身就是一个 effect;通过 Cordis 接口做的登记,插件卸载时都会撤销。下面“重新加载三次”的对比是实测的,代码附在文末。
跟着这条请求走:先问模型#
下面三页按 DSH 文档里一轮对话的顺序走:先 组装提示词和工具 schema,再 经过 agent/request 发请求、llm/stream 流式返回。
工具调用穿过的流水线#
pre-execute 默认放行,返回 ask 才交给 ctx.approval,之后过守卫检查,再进入 tools/execute。沙箱 shell 把 bash -c 交给 ctx.sandbox 包一层隔离,再通过 ctx.subprocess 启动进程;基础配置默认 workspace-write,只允许写工作区。要越出沙箱权限时才申请审批,批准只管这一次。
结果交回模型,这一轮结束#
工具结果需要再请求模型就进入下一步,否则经过 agent/turn-stopping 后 turn/end。图里测试输出和回答的内容是示意。
跑着的时候换掉 shell,流程不用改#
服务消失,依赖它的插件跟着卸载;服务回来,再加载。下面的毫秒数是实测的。DSH 的基础配置按 process.platform 挑沙箱版 shell,bash 工具不用改。改代码后的热更新也是先卸载撤销 effect,再按依赖加载新代码。
代价#
缺依赖的插件会一直停在 PENDING,不报错;多个异步的撤销会并发运行,有先后要求的要写进同一个撤销里。
回到那三句话#
附:实验代码#
两个实验都在 DSH 仓库(提交 ce7073d)里用 tsx 运行,Cordis 从仓库的 vendor/cordis/lib/index.js 引入。下面把引入路径写成包名,实际运行时换成你本地 DSH 仓库里那个文件的路径。
实验一:手写登记和 Cordis 各重新加载三次
import { Context } from '@deepseek-ai/cordis'
import { EventEmitter } from 'node:events'
// 手写:插件加载时往工具表和事件总线里登记
const tools = new Map<string, number>()
const bus = new EventEmitter()
let version = 0
function loadNaive() {
const v = ++version
tools.set('read_file#' + v, v)
bus.on('tool/result', () => console.log(` [naive] 监听器 v${v} 收到结果`))
}
loadNaive(); loadNaive(); loadNaive()
console.log('手写的工具表:', [...tools.keys()])
bus.emit('tool/result')
// Cordis:同样的插件,用 effect 和 ctx.on 登记
const ctx = new Context()
const ctools = new Map<string, number>()
let cv = 0
function toolPlugin(ctx: Context) {
const v = ++cv
ctx.effect(() => {
ctools.set('read_file#' + v, v)
return () => ctools.delete('read_file#' + v)
})
ctx.on('tool/result' as any, () => console.log(` [cordis] 监听器 v${v} 收到结果`))
}
let fiber = ctx.plugin(toolPlugin)
await fiber
for (let i = 0; i < 2; i++) {
await fiber.dispose()
fiber = ctx.plugin(toolPlugin)
await fiber
}
console.log('Cordis 的工具表:', [...ctools.keys()])
ctx.emit('tool/result' as any)输出:
手写的工具表: [ 'read_file#1', 'read_file#2', 'read_file#3' ]
[naive] 监听器 v1 收到结果
[naive] 监听器 v2 收到结果
[naive] 监听器 v3 收到结果
Cordis 的工具表: [ 'read_file#3' ]
[cordis] 监听器 v3 收到结果实验二:运行中把 shell 从本机换成沙箱
import { Context, Service } from '@deepseek-ai/cordis'
const STATE = ['PENDING', 'LOADING', 'ACTIVE', 'FAILED', 'DISPOSED', 'UNLOADING']
const t0 = Date.now()
const log = (s: string) => console.log(String(Date.now() - t0).padStart(4) + 'ms ' + s)
const tick = () => new Promise((r) => setTimeout(r, 10))
class LocalShell extends Service {
constructor(ctx: Context) { super(ctx, 'shell') }
run(c: string) { return `本机执行 ${c}` }
}
class SandboxShell extends Service {
constructor(ctx: Context) { super(ctx, 'shell') }
run(c: string) { return `沙箱执行 ${c}` }
}
const toolList = new Set<string>()
const bashTool = {
name: 'bash-tool',
inject: ['shell'],
apply(ctx: any) {
log('bash-tool 启动,拿到的 shell:' + ctx.shell.run('ls'))
ctx.effect(() => {
toolList.add('bash'); log(' 工具表加入 bash')
return () => { toolList.delete('bash'); log(' 工具表移除 bash') }
})
},
}
const root = new Context()
const tool = root.plugin(bashTool)
await tick()
log('还没有 shell:bash-tool 状态 = ' + STATE[tool.state] + ',工具表 = ' + JSON.stringify([...toolList]))
const local = root.plugin(LocalShell)
await tick()
log('—— 卸载本机 shell')
await local.dispose()
await tick()
log('bash-tool 状态 = ' + STATE[tool.state] + ',工具表 = ' + JSON.stringify([...toolList]))
log('—— 挂上沙箱 shell')
root.plugin(SandboxShell)
await tick()
log('bash-tool 状态 = ' + STATE[tool.state] + ',工具表 = ' + JSON.stringify([...toolList]))输出(毫秒数每次略有不同):
13ms 还没有 shell:bash-tool 状态 = PENDING,工具表 = []
14ms bash-tool 启动,拿到的 shell:本机执行 ls
14ms 工具表加入 bash
26ms —— 卸载本机 shell
26ms 工具表移除 bash
38ms bash-tool 状态 = PENDING,工具表 = []
38ms —— 挂上沙箱 shell
39ms bash-tool 启动,拿到的 shell:沙箱执行 ls
39ms 工具表加入 bash
50ms bash-tool 状态 = ACTIVE,工具表 = ["bash"]