DeepSeek Harness 拦截扩展点深度解析:基于类型化 Decision 的 Agent Hook 事件面设计
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
本文是 DeepSeek Harness 的 Agent Note 解读系列之一,源自 .agents/notes/implemented/feature/2026-06-30-interception-extension-points.md。DeepSeek Harness 以「Everything is a Plugin」为核心理念,通过 Cordis 事件体系把 Agent 生命周期全面插件化。本文聚焦其 hooks 子系统的地基——一组类型化、分权、可组合的拦截扩展点:
agent/*生命周期事件与tools/*五阶段工具执行管道。读完本文,你将理解「原生 hook 即普通插件」这一设计重构的本质,掌握PreStepDecision/PreToolDecision/PostToolDecision等类型化 Decision 的语义边界,以及循环(agent-loop)与工具注册表(dsh-tools)之间如何各司其职,并能在真实插件中直接复现这套拦截逻辑。
一、背景与核心重构:原生 Hook 不是「包」,而是事件 API
在 DeepSeek Harness 中,hooks 子系统的设计起点是一个关键的思想重构:「原生 hooks」并不是一个独立的软件包。一个原生 hook 本质上就是一个订阅了权威生命周期事件的普通 Cordis 插件;而 Claude Code / Codex 的桥接层(dsh-hooks-claude-code/dsh-hooks-codex)只是把外部 shell hook 协议翻译到同一套事件 API 上的翻译器(translator)。
这意味着一个重要的能力结论:任何桥接层能做的事,普通插件都能直接做,而且做得更强——没有序列化边界、拥有完整的ctx、返回类型化(typed)结果。这一结论决定了本文后面所有事件签名的设计取向:事件 API 必须「强大且类型完备」,而不是围绕外部协议的兼容性打转。
该设计还依托另一个前置约定:.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md 中提出的三域语义(session 是事实日志、agent 是实时事件通道、tools 是工具注册表与执行管道)以及类型化 Decision 惯用法。本文中的扩展点正是把这两条规则落实到 Agent 生命周期上的产物。
二、事件面总览:三类权威,泾渭分明
决策的核心是把拦截面拆成三种互相隔离的权威类型,避免「给所有扩展同样的能力」带来的越权问题:
| 类别 | 代表事件 | 拥有的能力 | 不能做的事 |
|---|---|---|---|
| 可变换的策略瀑布(waterfall) | agent/pre-step、tools/pre-execute、tools/post-execute | 返回扩展点专属的类型化 Decision 联合,允许 / 拒绝 / 改写 | 不得拥有与自身阶段无关的变更通道 |
| 环绕派发控制(around-dispatch) | tools/execute | 包装器用next()委托核心派发,返回规范化结果 | 不能移除exec.signal |
| 只读通知(observe-only) | agent/session-start、tools/result、agent/turn-stopping | 接收不可变快照,观察生命周期 | 不能改变最终结果 |
把各阶段混为一谈的坏处是双重的:插件会获得它们根本不需要的变更通道;同时「终局性」会依赖监听器的注册顺序。而上述划分让每个阶段只有一种权威,策略瀑布的终局性由 Decision 的类型联合天然决定,与监听器顺序解耦。
三、Agent 生命周期事件(dsh-agent)
3.1agent/session-start:纯通知,不能阻塞启动
- 签名:
agent/session-start({ agent, source }) - 触发时机:turn 1 之前,恰好发射一次。
SessionStartSource取值:startup(全新创建或 fork 创建)、resume(重载持久化会话);clear/compact为预留值。- 关键约束:这是纯通知,不能阻塞启动。这是一个刻意的设计缺口——桥接层在这里只负责「记日志 / 注入上下文」,不负责「把关启动」。需要种入上下文的监听器应调用
agent.inject()。
3.2agent/pre-step:每个提议步骤前的策略瀑布
- 签名:
agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision - 触发时机:在循环原子地取走其独占收件箱批次(inbox batch)之后、每个被提议的步骤执行之前。
- 载荷说明:载荷携带请求的
turn、step以及取消信号signal。被退役的PreStepContext字段直接并入载荷(见 .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md);当工具延续(tool continuation)没有新的介入输入时,messages为空数组。 - 两种返回:
enter:返回完整消息批次(含监听器贡献的当前请求上下文),步骤正式开启;reject:不开启任何步骤,已认领的消息保持被移除状态(不会回滚到收件箱)。
源码佐证:在 packages/core/agent-loop/tests/interception.spec.ts 中,agent/pre-step用例验证了坐标上报——初始提示与工具延续分别得到{ turn: 1, step: 1, messages: 1 }与{ turn: 1, step: 2, messages: 0 },正好对应「无介入输入的工具延续提交空批次」的约定;同一测试还断言传入监听器的message及其content均被Object.isFrozen冻结,证明输入身份不可变。
3.3agent/turn-stopping:自然停止边界的等待式通知
- 在自然停止边界处,该事件是**被等待(awaited)**的通知。
- 需要再走一步的监听器调用
agent.steer(),并携带显式标明来源的、面向模型的内容;循环随后重新读取 outbox,决定继续推进还是关闭本回合。
四、工具管道:每个阶段只拥有一种权威
每一次工具调用都严格遵循如下七段管线:
tools/pre-execute → guards → tools/execute → dispatch → tools/post-execute → ToolDefinition.finalizeContent → tools/result在策略开始之前,注册表会完成三件事:快照调用者输入、物化并冻结参数、分配不透明 token,同时快照可见定义的 final-content 回调。嵌套调用只携带父 token;执行身份(identity)不可变,只有signal允许在派发期间发生变化。因此日志、UI 与工具本体三者看到的「执行的是什么」完全一致。
4.1tools/pre-execute:可扩展的瀑布闸门
PreToolDecision三选一:allow(放行)、deny(拒绝)、ask(询问)。deny会跳过tools/execute与核心派发;ask通过可选的审批接缝(ctx.approval)解决,只有allowed-once继续穿过 guards 与派发;拒绝、取消、通道不可用、缺少审批服务、或无 agent 的调用,全部归一化为规范化拒绝。- 无论决议结果如何,都会进入后置策略(post-policy);抛异常的监听器最终归一化为失败结果。
4.2ctx.tools.guard():同步、作用域感知的终局守门员
- 安装在整个 pre-execute 瀑布之后。
- 守卫只能
deny或abstain(弃权),永远不能强制放行——这样监听器顺序无法复活一个被最终不变式(final invariant)禁止的操作。
4.3tools/execute:环绕派发瀑布
- 面向超时(timeout)、重试(retry)、指标(metrics)插件。
- 包装器用
next()委托核心派发;可以在委托前替换并恢复必需的exec.signal,但不能移除它;接收的是已归一化的权威成功 / 失败结果(对抛异常或未知工具同样适用)。 - 包装器自行构造的「成功」会短路派发,并经解析后的输出声明(output declaration)重新归一化。
4.4tools/post-execute:检查 / 变换瀑布
PostToolDecision的能力集:accept:接受;block:携带反馈阻塞;replace:替换展示内容(presentation content)或权威值(canonical value);- 附加
additionalContexts。
- 值替换会重新校验并重算展示内容;内容替换保留程序化值,且不是机密性边界。返回的 Decision 是官方支持的变换通道。
4.5ToolDefinition.finalizeContent:工具自有的末段内容不变式
- 可选的、同步的、纯内容边界的回调,在调用创建时与可见定义一同快照。
- 在注册表完成规范化、并对候选结果(含绕过后续瀑布的 pre-/around-/post- 监听器失败、以及快照其他结果字段时发现的错误)做无损快照之后,恰好运行一次。
- 可替换
content,或返回undefined保留原样;但不能改写isError、结构化错误身份、上下文或展示元数据。 - 价值在于:工具在这里强制自己的末段内容不变式,而不必把策略失败降级为更弱的 block 决策。
4.6tools/result:同步、受控的结果通知
- 在每一次变换、无损 JSON 物化以及外层错误边界之后触发。
- 收到同一个冻结的执行身份,以及权威结果的不可变快照;观察者失败按监听器隔离,不能改变或拒绝
ToolRuntime.execute()返回的结果。
4.7 归一化边界:错误永不逃逸出回合
核心派发与工具本体都位于归一化边界之内:工具抛错、监听器抛错、非法权威值、渲染器 / 投影器失败、非 JSON 展示、身份形状失败——全部归一化为 JSON 安全的isError结果,而不是逃逸出回合。因此:
- post-execute 监听器可以检查「抛了异常的工具」;
- 定义自有的 final content 不变式同时覆盖外层管道失败与候选物化失败;
- 最终观察者看到的,是执行局部的权威值 + 会话日志恰好能持久化的展示字段。
权威值与投影、持久化规则由 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md 承载。
五、三个承重(load-bearing)的循环决策
在每个被提议的步骤运行 pre-step 策略。循环在首次认领与决策之前就打开回合,因此
reject关闭的是一个「有记录但无步骤、无模型可见消息」的受阻回合;工具延续无新认领输入时仍提交空批次,允许按请求的上下文生产者把日志消息追加到该精确请求上。enter时循环打开步骤,并在请求推导前把返回批次作为user/message事件追加。依据 one-send-one-turn 简化,每个被认领的后续消息仍是其回合内唯一的直接提示。post-tool 的
additionalContexts与异步注入进入活动批次 FIFO,在该批次落定时追加。content/feedback塑造execute()返回的结果,但每个 context 都是独立的、带来源的user/message,单个步骤或复合工具可能产生多个。若立即追加,会得到result(c1) → context → result(c2)的穿插序列,或让嵌套 context 出现在其外层 result 之前,破坏「工具调用/结果相邻」的不变式。为此:ToolRunContext.deferContext()在失败路径上收集嵌套派发的 context;execute()在ToolExecutionResult上暴露有序数组;- 循环把它与执行期间产生的
agent.inject()调用放入同一个 FIFO;FIFO 在批次落定时、每一条已记录 result 之后追加——包括在被打断的回合关闭之前。 - 被接受的外层调用在 decision context 之前保留延迟 context;被阻塞的外层调用丢弃延迟 context,只暴露阻塞决策显式提供的 context。
停止中的监听器通过 steering 通道请求续跑,因此下一步循环顶部排空(drain)时会把这次续跑记录为同回合内下一步的 steering——是 next-step 而非 next-turn 的提示。
六、边界:什么不属于这套扩展点
hook/*会话事件(持久化的 hook 调用日志)不属于Service Definition 包,而归属于dsh-hook-protocol——因为原生插件使用类型化 Decision,根本不需要外部 hook 日志。原生插件集成测试 packages/core/agent-loop/tests/interception.spec.ts 通过真实循环组合这些扩展点,全程没有任何hook/*协议。- Compaction(
PreCompact/PostCompact)、Notification、以及 Codex 的PermissionRequest都排除在本决策之外。 ask决议通过 审批接缝 以ctx.approval解决;终态单调停止由工具结果数据表达,而agent/turn-stopping是引导下一步的最后机会。
七、被否决的备选方案
- 把 pre-tool 输入改写并入本扩展点集合:否决。理由是一个一致性问题——审计、历史与 UI 展示读的都是执行前记录的
tool/call.arguments;在身份创建前,一次合法的改写必须同时更新历史、审计、展示与执行四者。该契约由 pre-tool input-rewrite 提案 单独拥有,不应由扩展点隐式承担。 - 在扩展点旁同时声明持久化
hook/*会话事件:否决。原生插件直接使用类型化 Decision、完全不产生 hook 日志(集成测试已证明),因此持久化日志属于 hook-protocol 库,而非扩展面。
八、后果与落地验证
最终形态是一套统一类型化但权力分级的拦截面:hooks 返回 Decision、执行包装器做 wrap、终局守卫只能 deny、最终观察者只能 observe。职责划分清晰:
- 循环(agent-loop)拥有:session-start、pre-step 认领结算、post-tool 上下文缓冲、停止(stopping)。
dsh-tools拥有:身份密封(identity sealing)与五阶段执行管道。
契约文档化位置:docs/architecture.md、各包 README、docs/subsystems/core.md(interception-decisions 一节)以及 docs/subsystems/tools.md(tool structures 一节)。ACP 桥接层会把初始 pre-step 拒绝导致的「无步骤回合」结算为end_turn,而 hook 驱动的快照则端到端验证桥接层的可观察行为。
对插件作者而言,这套设计的实操含义可以浓缩为一张自检清单:要放行或拒绝,写agent/pre-step/tools/pre-execute返回 Decision;要包裹派发做超时与重试,写tools/execute包装器;要校验或变换结果,写tools/post-execute;要强制工具自身的内容不变式,用ToolDefinition.finalizeContent;要观察而不得干预,订阅agent/session-start/tools/result。记住每条原则对应的权威类型,就能在保持循环与管道不变式的前提下,安全地把任何外部 hook 协议「翻译」为原生插件能力。
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考