DeepSeek Harness 重复工具调用守卫(repeat-tool-reminder)深度解析:循环卫生插件的设计、配置与源码实现
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
模型在长时间自主工作中陷入循环——用字节级相同的参数反复发起同一条失败的 grep、反复读取一个未变化的文件、反复轮询一条已经给出答案的命令——是 Agent 系统最典型也最昂贵的失败模式之一:每一轮往返都消耗 token、挂钟时间与(对付费 API 而言)金钱,却不产生任何新信息。DeepSeek Harness(Everything is a Plugin)以@deepseek-ai/dsh-repeat-tool-reminder插件给出了原生解法:它统计同一工具以相同规范化参数发起的连续调用次数,在配置阈值处向模型注入建议性提醒。读完本文,你将掌握该守卫的设计定位、检测语义、完整配置方式,以及基于 源码 的逐行实现原理。
背景:harness 为什么需要循环守卫
模型陷入循环时,harness 自身没有任何机制能察觉这一点:循环没有步骤预算,也没有插件追踪调用重复。模型只有在碰巧改变自身行为时才能跳出。这种失败模式真实存在,且检测成本极低——社区中的 pi coding-agent 已经以扩展形式提供此功能:统计连续相同调用次数,超过阈值后追加一条<system-reminder>告诉模型停止重复并换个方向。
关键在于,harness 已经具备 pi 扩展所需的全部 seam,而且做得更完整:
- 拦截 seam 赋予了
tools/post-execute一种经过认可的方式,将面向模型的上下文附加到已完成的调用上; - 循环缓冲并注入该上下文,同时保持调用/结果的邻接关系;
- 注入的上下文是一条已记录的
context/message,因此原生守卫无需新增会话事件即可满足「模型可见 ⇔ 已记录」的规则。
缺的只是插件本身——这也是 Agent Note 记录这一决策的背景。该 Note 中的包名repeat-tool-guard后来按仓库命名契约改名为repeat-tool-reminder(当前实现见 包目录)。
设计定位:循环卫生插件,而非面向模型的工具
守卫是一个循环卫生插件,而不是模型可调用的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻止或改写调用——模型自行决定是换种方式重试,还是结束任务。
这一"仅建议、不否决"的姿态贯穿整个实现。从 源码 可以看到,post-execute 监听器始终通过next()委托下游决策,随后把提醒折叠到返回结果的additionalContexts上——无论下游是accept还是block,提醒都能送达,而阻止仍是后续监听器的事。
插件注册两个监听器,将状态保存在以存活Agent对象为键的WeakMap中(chains = new WeakMap<Agent, Chain>())。这一选择有两个深层原因:
- 按 agent 分键是正确性要求:工具注册表是上下文级别的单例,其 waterfall 事件会交错所有 agent 的调用(subagent 运行在同一个上下文上)。如果不按 agent 分键,一个 agent 的循环会误触发另一个 agent 的提醒。
- 弱对象键免除了 disposal 监听器:当 agent 对象被回收时,其链条目随之消失,纯清理用途的 dispose 监听器不再必要。
两个监听器分工如下:
| 监听器 | 角色 | 说明 |
|---|---|---|
tools/post-execute(waterfall) | 唯一的检测点 | 同时接收(exec, result),计数与提醒投递无需跨事件的 pending map;始终通过next()委托 |
agent/prompt-submit(waterfall) | 纯重置钩子 | 用户介入改变了上下文,跨越介入的重复不是循环;清除提交 agent 的链 |
检测语义:规范化、透明与隔离
链键与规范化
链的键是(tool name, canonical arguments)。与前一个被追踪调用相同的调用递增该 agent 的连续计数器,不同的被追踪调用将其重置为 1。
规范化方式为深度键排序加JSON.stringify(sortJsonValue与canonicalize)。这里有个值得注意的实现事实:ToolExecution.arguments按构造就是循环中JSON.parse的输出(或格式错误参数 JSON 的原始字符串回退,其本身也是可比较的值),因此 JSON 的值域就是全部输入域——pi 原版对 bigint、循环引用、undefined的防御性处理在此没有输入路径能产生,被有意去除。测试也验证了深度规范化:canonicalization ignores property order, deeply断言{a: 1, nested: {x: [1, 2], y: null}}与键序打乱的同值对象在链中视为相同。
两条刻意的规则
以下两条规则记录在 包 README 中,因为它们是读者否则只能猜测的行为:
- 未追踪的调用对链透明。被
include/exclude排除的调用既不递增也不重置计数器,因此grep X → todo_write → grep X在todo_write被排除时仍计为两次连续的grep X。这正是排除功能有用的原因——穿插在循环中的簿记工具不得为循环洗白。这是 pi 扩展的(未文档化的)语义,本实现有意保留并明确写下。 - 没有 agent 的调用被忽略。直接调用
ctx.tools.execute()的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的存活 agent 对象(observe中的if (!exec.agent) return undefined)。
计数位置:post-execute 而非 pre-execute
计数放在tools/post-execute而非tools/pre-execute,因为post-execute 也会为被拒绝的调用触发(ToolRegistry.execute将 deny 路由到同一条流水线)。模型反复敲击一个被拒绝的调用恰恰是值得打破的循环——单元测试 用pre-execute返回deny的方式验证了这一行为。一个监听器、无跨事件状态,即可覆盖严格更多的尝试场景。
提醒投递:additionalContexts 与升级阈值
提醒作为独立条目搭载在additionalContexts上,source 为:
{ kind: 'plugin', plugin: 'repeat-tool-reminder', form: 'notice', summary: '<tool> × <count>' }{kind: 'plugin'}标签是承载语义的——未打标签的上下文在派生历史中会渲染为普通用户提示词。提醒绝不替换content:tool/result事件仍是工具自身的审计输出,循环则在步骤结果之后把缓冲的上下文追加为context/message,会话将其渲染为带标签的合成 user 信封,并由派生历史回放。
阈值逐级升级(observe中的分派):
- 第一个配置阈值:获得简短温和提醒——"你正在用相同参数重复完全相同的工具调用,请先仔细分析上一次结果;若任务未完成,请换一种方法或换一组参数,而不是重复调用"。
- 后续各阈值:获得详细提醒,包含工具名、重复计数和规范化参数(在头部截断到
argumentsPreviewChars,默认 500 字符,并以… (+N more chars)标注省略量)。
参数预览截断只约束模型可见文本,链键始终比较完整规范化字符串(previewArguments)——循环中的write级大 payload 不得无界地进入下一次请求,但检测的完整性不受影响。对应的测试用 400 字符载荷验证了截断只发生在展示层。
一个移植细节值得记录:pi 原版把温和文本硬编码为字面计数 3;本守卫以thresholds[0]为键,修复了这一 bug。测试keys the gentle text to thresholds[0], not the literal 3(见测试)验证了自定义阈值[4, 2](故意无序,加载时归一化为升序)下温和提醒出现在第 2 次、详细提醒出现在第 4 次。
下游钩子桥贡献仍是独立的数组条目,因此两个插件都保留各自的 source、信封与元数据。
配置指南
插件随dshbase 组合默认启用(见 base 组合的 cordis.patch.yml),默认在 3、5、8 次重复时提醒。需要调优时,通过配置挂载:
- id: repeat-tool-reminder name: '@deepseek-ai/dsh-repeat-tool-reminder' config: thresholds: [3, 5, 8] # 触发提醒的连续重复次数 include: [] # 只跟踪这些工具;空 ⇒ 跟踪所有工具 exclude: [todo_write] # 对链透明的工具(既不计数也不重置) argumentsPreviewChars: 500 # 详细提醒中引用的参数长度上限| 字段 | 默认值 | 含义 |
|---|---|---|
thresholds | [3, 5, 8] | 触发提醒的重复次数;加载时校验,空列表、非整数、小于 2 的值或重复项都会抛出异常 |
include | [] | 只跟踪这些工具;空表示跟踪所有工具 |
exclude | [] | 绝不跟踪这些工具;对它们的调用既不计数也不重置 |
argumentsPreviewChars | 500 | 详细提醒中显示多少字符的重复参数 |
校验语义:配置错误快速失败
thresholds在加载时校验(validateThresholds):空列表、非整数、小于 2 的值或重复项都会抛出异常——配置错误快速失败,取代 pi 原版的静默回退到默认值。argumentsPreviewChars同样要求正整数,否则抛错(测试覆盖)。
通配符语义:模式是调用时的谓词
include/exclude条目支持*通配符(wildcardToRegExp将通配符编译为锚定正则,其余正则元字符按字面量转义)。模式是对调用时实际存在的工具的谓词,而非对注册表条目的引用——因此匹配不到当前已注册工具的条目不是错误:与toolOrder的引用检查不同,exclude: [mcp_*]在未加载 MCP 工具的部署中也必须保持有效。测试escapes regex metacharacters in patterns(见测试)验证了pr.be不会被当作正则的任意字符匹配。
源码实现细读
核心实现集中在 src/index.ts,约 230 行,可拆解为四层:
- 配置层:
Configschema 由@deepseek-ai/schemastery定义(L45-L50),apply中做 fail-loud 二次校验。 - 规范化层:
sortJsonValue深度键排序 +canonicalize生成链键。 - 链管理层:
WeakMap<Agent, Chain>持有每个 agent 的{key, count};observe单函数完成"推进链 + 命中阈值时生成提醒"(L189-L207)。 - 事件层:
tools/post-execute观察并丰富(先计数、后委托、再折叠),agent/prompt-submit(源码中为agent/pre-step检测 user 消息)纯重置。
其中"折叠到下游决策"的实现值得展开(L213-L224):监听器先observe(无论下游结果如何,状态都已推进),再await next()委托下游,最后按block与普通决策两种变体分别把提醒前置到additionalContexts。prependContext保证提醒排在下游上下文之前,同时完整保留下游条目的 source 与元数据。测试folds the reminder onto a downstream block and keeps its feedback(见测试)验证了被阻止的调用依然收到提醒、block 的 feedback 原样到达工具结果。
包还附带一个不变的伴生插件 src/invariant.ts:由于重复链私有于单个 post-execute 监听器、不暴露任何包级事件或快照,该伴生插件没有运行时不变式可观察,仅保留包所有权注册。
测试验证
- 单元测试(repeat-tool-reminder.spec.ts,403 行):使用脚本化 MockAdapter 驱动真实 agent 循环,逐文件 100% 覆盖率。覆盖场景包括:计数与重置规则、未追踪透明性、dispose 清理(复用同一 session id 的新 agent 从计数 1 重新开始)、按 agent 隔离(两个 agent 同屏,一个触发提醒另一个不触发)、规范化参数键序、阈值升级(含
thresholds[0]温和文本规则)、被拒绝的调用仍计数、无 agent 执行不崩溃、通配符转义、无效配置拒绝,以及下游 block/replacement 决策时的折叠行为。 - 快照测试:keyless 场景发起五次相同的
todo_write调用,在 ACP 输出和会话日志中固定第三次调用的温和提醒与第五次调用的详细提醒。该插件在实时示例中加载,但在其他场景中保持静默。 - E2e 测试:无——该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者(tools 子系统、agent-loop)覆盖。
曾考虑的替代方案与取舍
Agent Note 完整记录了六个被否决的替代方案,每个都揭示了设计意图:
- 将提醒追加到工具结果中(替换
content的accept——pi 扩展的机制):否决。这会让已记录的tool/result对工具实际返回的内容撒谎,而additionalContexts是 post-execute 评注的独立认可通道,循环级缓冲保持了调用/结果的邻接关系。 - 在
tools/pre-execute中计数并使用 pending-reminder map(pi 的两阶段形态):否决。post-execute 单独就能同时看到(exec, result)且也为被拒绝的调用触发,一个监听器、无跨事件状态即可覆盖严格更多的尝试。 - 在最高阈值升级为
block:在初始范围内否决。阻止会惩罚合法的相同重复(轮询长时间运行的终端、重新检查预期会变化的文件),建议性提醒让模型保持控制权。PostToolDecision已支持此选项,待有证据后重新审视。 - 通过 CC/Codex 桥接的逐部署外部钩子(一个
PostToolUse脚本):否决作为最终答案。它对单个部署有效,但一个已发布、有单元测试、可通过cordis.yml配置的插件才是 harness 原生的形式,且没有逐调用的子进程开销。 - 在
agent-loop中设置循环级步骤或重复预算:否决。「用插件,不改循环」;硬性步骤预算是一种更粗粒度的正交控制。 - 模糊/近似相同检测(路径归一化、相似但不完全相同的参数):否决。规范化后的精确匹配成本低、确定性强、且可向模型解释;相似度阈值引入误报风险,需要证据才能换取复杂度。
- 将包放在
core/:否决。core 是产品主干;行为守卫是可选的叶子插件,todo/分组是先例。守卫因此独立成guard/分组,与 timeout-policy 同属循环卫生家族(见 guard 组 README)。
后果、已知限制与延后事项
设计上的后果值得使用者留意:
- 提醒在设计上是建议性的:有意重复相同调用的幂等轮询模式仍会在超过阈值后收到提示。减压阀是配置(
thresholds、exclude)加上明确允许「在已收集足够证据时结束」的提醒文本。每次触发在下一次请求中增加提醒 token 的开销;阈值限制了触发频率。 - 链状态仅存于内存:从持久化恢复的会话以全新的链开始,因此跨越恢复的循环比实时循环更晚收到提醒——守卫是启发式提示而非已记录的不变式,持久化计数器状态带来的收益不值得其复杂度。
- 多个 post-execute 生产者共存:当多个监听器在同一次调用上附加上下文时,每项贡献保持为独立的
HookContext;顺序遵循 waterfall 嵌套关系,每个条目保留自己的溯源信息。 - 压缩(compaction)不重置链:压缩后的历史改变了模型所见的内容,但重复风险通常在压缩后仍然存在。
- subagent 的链按 agent 隔离:父 agent 与其 subagent 重复相同调用也绝不合并;在出现具体用例之前不提供共享机制。
- 精确匹配的边界:仅精确重复(同一工具、同一参数、与属性顺序无关)会被检测,近似变体会绕过链——这是当前的包约束,不是任务积压。
对维护者而言,Agent Note 还记录了一个测试基建的意外收获:实现快照层时暴露了 suite kit 的一项隐藏假设——fixture guard 把「撰写的模型场景」等同于「由 override 驱动」。Scenario表现在携带显式的overridden标志,且 sidecar 是否存在会以双向方式与其核对,使得 suite kit 比该插件出现前更严格。
结语
@deepseek-ai/dsh-repeat-tool-reminder是一个体量极小(单文件 ~230 行源码、403 行测试)却语义精确的循环卫生插件。它演示了 DeepSeek Harness 插件体系的一条核心路径:在既有 seam 上做观察与丰富,而不是侵入循环本体——用tools/post-execute的 waterfall 契约、additionalContexts的注入通道与{kind: 'plugin'}的来源标签,以近乎零机制成本解决了 Agent 系统中最昂贵的失败模式之一。对使用者而言,它默认启用、开箱即得,唯一需要动手的是按自己的工具集调优thresholds与include/exclude;对插件作者而言,它是学习 harness 事件驱动插件设计的一份高质量范本。
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考