dsh-plugin-hooks-ordering
Verified@tengxiaohtx/dsh-plugin-hooks-ordering · v0.2.0 · MIT
Deterministic before/after ordering for Cordis waterfall hooks, contributed across independent plugins
Install
dsh plugin add @tengxiaohtx/dsh-plugin-hooks-ordering Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-plugin-hooks-ordering
English | 简体中文
为 Cordis 钩子提供确定性的 before/after 排序——即使参与者来自彼此独立、互不感知的插件。同时支持 waterfall 与 serial 两种派发模式,并附带一个可选的 DeepSeek-Harness 层,开箱即用地控制真实的 dsh 钩子。
- 在线演练场: https://xinyuehtx.github.io/dsh-plugin-hooks-ordering/
- 可运行的示例:
pnpm run demo
问题
Cordis 按注册顺序派发 waterfall 监听器——也就是它们在内部监听器数组中的位置,唯一的调节手段是 prepend。而注册顺序又由 inject 依赖的激活时机决定,这在互不相关的插件之间是非确定性的。
由此带来的后果是:一个插件无法可靠地声明相对顺序,比如「让我在那个插件之后运行」。具体来说:
ctx.on上没有before/after/stage声明——只有一个布尔值prepend,而且prepend是「后 prepend 者胜」,所以它也不是稳定的「永远第一」。- Cordis 提供的唯一稳健的排序原语是
inject:如果插件 B 注入了插件 A 提供的服务,B 就会在 A 之后激活。但这会全局且单向地固定顺序,并强制 B 依赖 A——对于必须互不依赖的不同厂商而言这是不可能的。 - 两个插件可能需要在不同的钩子上要求相反的相对顺序。单一的激活顺序无法表达这一点。
于是那些真正重要的顺序——鉴权先于日志、清洗先于序列化、指标采集最后——都悄悄依赖于无人掌控的加载顺序。改动一个不相干的 inject,顺序就翻转了。
原生 ctx.on,加载顺序 [auth, logging, metrics] -> auth, logging, metrics
原生 ctx.on,加载顺序 [metrics, logging, auth] -> metrics, logging, auth ← 相同插件,顺序翻转
这并非假想。在 deepseek-harness 中,waterfall 钩子 agent/pre-step 被十余个独立包订阅,tools/post-execute 被六个、llm/stream 被五个订阅——而且多处明确记载:相对顺序是关键载荷,却仅由 prepend 和注册时机决定(见下文现有 dsh 排序手段及其局限)。
解决方案
你无需修改或 fork Cordis。本包是一个普通的 Cordis 插件,它为选定的钩子加上**括号(bracket)**并自行决定参与者顺序,与插件加载时机无关。
Waterfall(HookOrdering)用一个 prepend 的监听器括起钩子,并利用洋葱模型:
- 监听器
next()之前的代码会在整条原生链之前运行——即front阶段。 next()之后的代码会在所有内容(包括钩子内置的默认行为)之后运行——即back阶段。
Serial(SerialHookOrdering)没有可包裹的 next(),因此用两个协调器括起钩子:一个 prepend 的 front 协调器,跑在原生链之前(在此处 bail 会短路整条派发);以及一个 append 的 back 协调器,尽力排在最后。
两者中,参与者都向协调器(而非原生钩子)注册,并带上 before/after 名称,由一个稳定的拓扑排序决定它们的顺序。
HookOrdering,加载顺序 [auth, logging, metrics] -> auth, logging, <host default>, metrics
HookOrdering,加载顺序 [metrics, logging, auth] -> auth, logging, <host default>, metrics ← 稳定
为什么这该是插件,而不是 Cordis 本身
Cordis 刻意保持极简:它提供排序的原语(数组位置、prepend、next() 链)。而排序的策略——数值序、before/after、拓扑排序——因钩子而异,不是内核该关心的事。把它做成插件意味着零框架改动,也无需维护 fork。
分层
本包做了分层设计,你可以在所需的高度上使用它:
| 层 | 入口 | 提供什么 |
|---|---|---|
| 1. 算法 | @tengxiaohtx/dsh-plugin-hooks-ordering/topo-sort、/dag |
纯的稳定拓扑排序,以及约束图(JSON)渲染器。零依赖、不依赖 Cordis。 |
| 2. Cordis 服务 | @tengxiaohtx/dsh-plugin-hooks-ordering(根)、/waterfall、/serial |
HookOrdering 与 SerialHookOrdering——在任何 Cordis 应用中控制任意钩子。 |
| 3. DeepSeek-Harness | @tengxiaohtx/dsh-plugin-hooks-ordering/dsh |
一个 dsh 插件 + cordis.patch.yml,替你控制真实的 dsh 钩子。 |
安装
pnpm add @tengxiaohtx/dsh-plugin-hooks-ordering
# 对等依赖:
pnpm add @deepseek-ai/cordis
用法
Waterfall 钩子
import HookOrdering from '@tengxiaohtx/dsh-plugin-hooks-ordering'
ctx.plugin(HookOrdering)
// 钩子的拥有者(或应用编排层)只接管一次。这会安装那个唯一的
// bracket 监听器;重复接管会抛错,因此 prepend 竞态不会卷土重来。
ctx.hooksOrdering.control('request/assemble')
// 厂商 A —— 只声明自己的约束,不从厂商 B 导入任何东西。
ctx.hooksOrdering.register('request/assemble', 'front', {
name: 'auth',
before: ['logging'],
run: (req) => authenticate(req),
})
// 厂商 B —— 另一个包,对 A 一无所知。
ctx.hooksOrdering.register('request/assemble', 'front', {
name: 'logging',
run: (req) => log(req),
})
// 厂商 C —— 必须在所有东西之后运行,连 host 默认行为也在其前。
ctx.hooksOrdering.register('request/assemble', 'back', {
name: 'metrics',
run: (req) => emitMetrics(req),
})
无论这三个插件以何种顺序加载或注册,auth 总是先于 logging 运行,而 metrics 总是最后运行。
输出约束 DAG
传入一个 log 文件,即可在每次注册变更时把约束图(JSON)写入该文件,使其始终反映当前状态——在排查异常顺序或环时非常有用:
ctx.plugin(HookOrdering, { log: './hooks-ordering-dag.json' })
{
"sections": [
{
"hook": "request/assemble",
"phase": "front",
"nodes": ["auth", "logging"],
"edges": [{ "from": "auth", "to": "logging" }] // auth 先于 logging 运行
},
{
"hook": "request/assemble",
"phase": "back",
"nodes": ["metrics"],
"edges": []
}
]
}
该图渲染时不做拓扑排序,因此出现环也会如实呈现,而不是抛错。你也可以随时通过 ctx.hooksOrdering.dumpDag() 以编程方式读取(返回 JSON 字符串)。写入失败会经 console.warn 报告,绝不抛回 fiber。
Serial 钩子
import { SerialHookOrdering } from '@tengxiaohtx/dsh-plugin-hooks-ordering'
ctx.plugin(SerialHookOrdering)
ctx.serialHooksOrdering.control('turn/stopping')
// front:跑在原生链之前。返回一个 bail 值(除 null/false/undefined 外的任何值)
// 会短路整条 serial 派发。
ctx.serialHooksOrdering.register('turn/stopping', 'front', {
name: 'guard',
run: (turn) => (isAllowed(turn) ? undefined : 'DENIED'),
})
// back:尽力排在最后(见「语义与限制」)。
ctx.serialHooksOrdering.register('turn/stopping', 'back', {
name: 'audit',
run: (turn) => recordAudit(turn),
})
在 DeepSeek-Harness 中
/dsh 入口是一个 dsh 插件,它会挂载上述两个服务,并接管那些被多个包贡献的 dsh 钩子(agent/pre-step、tools/pre-execute、tools/post-execute、system-prompt/assemble、llm/stream……以及 serial 的 agent/turn-stopping)。只需在 profile 补丁中加一行:
- insert:
- id: hooks-ordering
name: '@tengxiaohtx/dsh-plugin-hooks-ordering/dsh'
config:
# hooks: ['agent/pre-step', 'tools/post-execute'] # 默认:全部已知 dsh waterfall 钩子
# serialHooks: ['agent/turn-stopping'] # 默认:[agent/turn-stopping]
# log: './hooks-ordering-dag.json' # 可选的 DAG 日志
控制一个没有任何参与者的钩子是透明的直通,因此这一行在某插件用 before/after 注册之前不会改变任何行为。包根目录附带了一份 cordis.patch.yml(通过 dsh.bundle.patch 清单字段声明),内容同样是这一行。
推荐装配顺序
顺序由协调器强制,而非由加载位置决定——但协调器必须先于其它插件的 prepend 就位。因此:
- 先挂载本插件并对目标钩子调
control()——在贡献插件加载之前。那个唯一的 bracket 必须先于任何竞争者被 prepend;如果另一个插件在control()之后 prepend,它会落在 bracket 之外而逃脱排序(这正是文档中所述的prepend竞态,按设计被排除在受协调集合之外)。 - 然后加载贡献者。 它们用
before/after名称向协调器register();它们之间的加载顺序便不再重要。
在 dsh profile 中,这意味着把 hooks-ordering 行放得靠前(它会在其服务可用后激活);cordis.patch.yml 中的行序不带加载语义,但它所安装的协调器,正是让之后每个贡献者声明的顺序得以成立的关键。
现有 dsh 排序手段(及其局限)
以下是 deepseek-harness 中影响钩子顺序的现有手段——也就是本插件要取代的「bypass」。它们都真实存在,但都不足以表达声明式的相对顺序:
ctx.on(...)上的{ prepend: true }—— 引擎唯一的放置手段(unshift对push,vendor/cordis/src/events.ts:143)。它是二值的,且「后 prepend 者胜」:两个都 prepend 的插件会相互竞态,谁也无法声明「front 中的第一个」。例如packages/spill/spill-policy/src/index.ts:209与packages/llm/llm/src/invariant.ts:88在用。- 注册 /
ctx.plugin(...)调用顺序 —— 默认 append 使调用顺序成为执行顺序。只有当单一编排点掌控所有调用时才有效;一旦由互不隶属的厂商以无人掌控的顺序加载,就会失效。packages/skill/tool-skill/src/index.ts:164与packages/examples/agent-spine-demo/src/index.ts:257刻意(且脆弱地)依赖它。 inject依赖 —— 把插件的激活门控在服务可用性上(如packages/core/agent-loop/src/index.ts:297的static inject = [...])。它排序的是插件相对服务的位置,全局且单向,并强制引入依赖边。它无法表达同一钩子上两个监听器之间的相对顺序,也无法表达不同钩子上的相反顺序。- 顺序不变式断言 —— 事后检测错误顺序(
packages/context/time-context/src/invariant.ts:66),但不强制顺序。 - profile
cordis.patch.yml行序 —— 明确不带加载语义(「激活由服务可用性驱动」,packages/bundle/base/cordis.patch.yml:13),因此根本无法为监听器排序。
HookOrdering/SerialHookOrdering 用单一的声明式原语取代了以上五种:声明你的 before/after,向协调器注册,顺序便与加载时机无关地稳定——而且当顺序出错时,约束图还能以 JSON DAG 的形式供你查看。
API
ctx.hooksOrdering —— HookOrdering 服务(waterfall)
| 方法 | 说明 |
|---|---|
control(hook) |
在 waterfall 钩子 hook 上安装 bracket。每个钩子只调用一次。返回一个 disposer。若已被接管则抛出 HookControlError。 |
register(hook, phase, entry) |
向 'front' 或 'back' 添加一个参与者。返回一个 disposer。若该钩子未被接管则抛出 HookControlError。 |
plan(hook, phase) |
返回参与者将要运行的顺序(名称列表)——用于测试和诊断。 |
dumpDag() |
以 JSON 字符串返回所有受控钩子的约束 DAG。 |
配置:ctx.plugin(HookOrdering, { log?: string })。
ctx.serialHooksOrdering —— SerialHookOrdering 服务(serial)
与 HookOrdering 接口相同(control / register / plan / dumpDag,配置相同)。其条目的 run 可以返回一个值:bail 值(除 null/false/undefined 外的任何值)会短路 serial 派发并成为其结果。
HookEntry / SerialHookEntry
| 字段 | 含义 |
|---|---|
name |
在同一个 (hook, phase) 内唯一。被其他条目的 before/after 引用。 |
before? |
本条目必须排在这些名称之前。 |
after? |
本条目必须排在这些名称之后。 |
run(...payload) |
以钩子的 payload 调用(waterfall:派发的参数,去掉 Cordis 末尾的 next)。会被 await。serial 的 run 可返回 bail 值。 |
topoSort(entries)(.../topo-sort)与 buildDag(sections)(.../dag)
零依赖的稳定拓扑排序,独立导出。并列项保持输入顺序;未知的 before/after 目标是空操作;出现环则抛出 OrderingCycleError。buildDag 把约束图渲染为一个普通对象({ sections: [{ hook, phase, nodes, edges }] })供 JSON.stringify 使用——它从不排序,也从不因环抛错。
语义与限制
- 仅限 waterfall 与 serial。 waterfall 的 bracket 需要
next();serial 用两个带 bail 语义的协调器。emit/parallel/bail钩子没有可协调的有序链。 - 每个钩子一个协调器。 第二个
prepend会重新引入竞态,因此control会拒绝重复接管。 - 它只排序自己拥有的部分。 协调器控制它的
front/back注册表及其内部顺序,并把它们相对原生链放置。它不会重排外部监听器彼此之间的顺序。 - waterfall 的
back是精确的;serial 的back是尽力的。 waterfall 的back通过next()跑在整条原生链之后。serial 没有next(),因此它的 back 协调器在control()时被 append,跑在那一刻已存在的监听器之后——在control()之后添加的原生监听器会排在它后面,而且任何 bail(原生或 front)都会整体跳过它。 - 未知引用 = 空操作。 跨厂商的
after: ['maybe-absent']在该 peer 未加载时不施加任何约束——跨厂商插件不能假设彼此存在。 - 出现环在派发时明确报错。 相互冲突的约束会抛出
OrderingCycleError,指出被阻塞的条目(而dumpDag()仍会渲染出这个环供检查)。
开发
pnpm install
pnpm test # vitest,单元测试 + 真实 cordis 集成测试
pnpm test:coverage # 100% 单文件覆盖率门槛
pnpm typecheck
pnpm lint
pnpm build # tsdown -> lib/(ESM + d.ts)
pnpm demo # 问题与修复,waterfall + serial,并排对比
pnpm playground:build # -> playground/dist(部署到 GitHub Pages)
CI 使用 pnpm 并面向公共 npm 源安装(见 .npmrc)。