dsh-tool-call-timeout-policy 是一个零配置的 tools/execute 包装器,从每个工具自己声明的 timeoutMs 起一个 deadline,超时则返回结构化的 TOOL_TIMEOUT 结果。dsh-repeat-tool-reminder 监视连续的相同工具调用,在配置的阈值处注入递进式提醒——它从不拦截调用。两者都属于 guard:核心服务的自包含消费者,不是可替换的能力。
agent 卡在循环里,是真正烧钱的那种失效模式,而 DeepSeek Harness 为此自带了两个插件。它们被归为 guard——核心服务与扩展点的自包含消费者,并且明确不是可替换的能力。
这个区分有意义:seam 是有多个 provider 供你挑的;guard 只是一个你要么组装、要么不组装的插件。
工具调用超时
@deepseek-ai/dsh-tool-call-timeout-policy 是一个 tools/execute 的环绕分发监听器,而且零配置:
- id: timeout-policy
name: '@deepseek-ai/dsh-tool-call-timeout-policy'整行就这么多。预算不在这里配——它读的是工具自己的声明 ToolDefinition.timeoutMs,由拥有该工具的
插件设置。自带的 web_fetch 和 web_search 就是通过 dsh-tool-web 的
fetchTimeoutMs / searchTimeoutMs 配置声明的。
由此带来一个很漂亮的设计后果:「工具名拼错」根本不可能发生,因为这个 guard 从不点名任何工具, 它读的是被分发过来的那个。
每次调用它做什么
对一个声明了 timeoutMs 的工具,监听器会:
- 从注册表读预算(
ctx.tools.get(exec.name)?.timeoutMs),并起一个deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')——把调用方的 abort 和本插件的定时器融合成一个信号。 - 把这个派生信号换到
exec上供下游分发,之后再把调用方自己的信号换回来,这样tools/post-execute看到的仍然是调用方的信号。 - 分发结束后,如果是自己的定时器先触发,就把结果替换成一个结构化错误:
{
"isError": true,
"error": { "message": "…", "info": { "name": "ToolTimeoutError", "code": "TOOL_TIMEOUT" } },
"content": "Error: tool call timed out after <ms>ms"
}没声明预算的工具原样透传。
替换是按信号(timeoutOf)判定的,不是按结果的形状——因为分发会先把上游 abort 错误规范化成一个普通的
错误结果,而包装器需要知道是不是自己的定时器造成的。
协作式,不是强杀
所以只有会转发信号的工具才应该声明预算。如果你自己写工具、又希望超时真的有意义,就必须把
exec.signal 一路转发给真正干活的那一层。
从成本角度还有一点值得记:非超时调用零 token 开销;一次超时只增加一条很小的、会被保留的错误结果 ——同时可能挡住一个更大的、姗姗来迟的 provider 结果进入上下文。
组装顺序就是语义
多个 tools/execute 监听器按 cordis 的注册顺序组合。将来配上重试或指标包装器时,
注册顺序决定含义:超时注册在外层,意思是「超时覆盖整个重试操作」;注册在内层,意思是
「超时覆盖每一次尝试」。
这是一个真实的决策,不是实现细节,而它只由顺序表达。
重复调用提醒
@deepseek-ai/dsh-repeat-tool-reminder 是一个建议性的破循环器,不是面向模型的工具。它不出现在
工具列表里,不否决也不改写任何调用。它只加一个行为:统计连续的相同调用,在配置的次数处注入提醒。
- id: repeat-tool-reminder
name: '@deepseek-ai/dsh-repeat-tool-reminder'
config:
thresholds: [3, 5, 8] # 触发提醒的连续次数
include: [] # 要跟踪的工具名模式;空 ⇒ 全部
exclude: [todo_write] # 对链透明的模式
argumentsPreviewChars: 500 # 详细提醒里引用参数的字符上限thresholds 在加载时大声失败:空列表、非整数、小于 2 的值、重复值都会抛错——绝不静默回退到默认值。
第一个阈值给一条简短的通用提示;之后每个阈值给详细版,点名工具、连续次数和规范化后的参数。
模型在第一个阈值收到的原文:
You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call.
决定权留在模型手上。 一次合理的重复调用不会被延迟一毫秒,也不会被拦。
链语义
链的键是 (工具名, 规范化参数),规范化方式是深度按键排序再 JSON.stringify——所以仅仅属性顺序不同的
参数对象会被算作相同。
四条值得记住的规则:
未跟踪的调用是透明的。 被 include/exclude 排除的调用既不增加计数也不重置它。所以当
todo_write 被排除时,grep X → todo_write → grep X 仍然算两次连续的 grep X。这正是排除项有用的
原因:夹在循环里的记账类工具,不能把这个循环洗白。
被拒绝的调用照样计数。 检测挂在 tools/post-execute 上,而它对被 pre-execute 监听器拒绝的调用
同样会运行——一个反复捶一个被拒调用的模型,正是最该被打断的那种循环。
按 agent 记键。 用 WeakMap<Agent, Chain> 以活的 agent 对象为键,所以即使子 agent 通过同一条
waterfall 交错执行,一个 agent 的重复也永远不会触发另一个的提醒。用户发来的提示会重置提交方 agent 的链。
只存在内存里。 从持久化恢复的会话会从一条全新的链开始。这个 guard 是启发式的提醒,不是被记录的不变式。
提醒怎么送达
提醒搭乘 post-execute 决策的 additionalContexts,来源是
{kind: 'plugin', plugin: 'repeat-tool-reminder'}——绝不替换 content,所以 tool/result
事件里留下的仍然是工具自己的输出,可供审计。
循环会缓冲这条上下文,并在该步骤的工具结果之后作为注入的 user/message 追加进去。于是这条提醒
对模型可见、有来源标注、且能从 session log 重建——而且不需要新增任何 session 事件类型。
这就是这套代码库里随处可见的同一条纪律:模型看到过的,日志必须能重建。
如果你在意 agent 成本
这两个是成本最低的护栏,而且各管问题的一半:
- 超时约束的是单次调用卡住的情况——并且顺带把慢 provider 的迟到结果挡在上下文之外。
- 重复提醒约束的是另一种:每次调用都很快成功,但 agent 就是不前进。没有任何超时能抓住这一种。
两者都不是预算上限。要硬边界,你要的是 goal 的轮次上限、workflow 引擎的 maxTotalAgents、
以及 Ralph 的 maxRounds——见 workflow 引擎。
guard 减少浪费,cap 才能止损。
常见问题
超时插件会强杀跑飞的工具吗?
不会。派生出的信号只负责通知;终止权仍在工具、以及它把 exec.signal 转发给的那个能力手上。声明 timeoutMs 的含义是「我与 exec.signal 协作」——一个无视信号的工具,超时了也不会停。
超时是在这个 guard 里按工具配置的吗?
不是,这个 guard 零配置。预算读的是工具自己的 ToolDefinition.timeoutMs,由拥有该工具的插件设置——所以「工具名拼错」这种事根本不可能发生。
重复提醒会拦截重复调用吗?
从不。它只加一个行为:在配置的连续次数处给一条建议性提醒。重试、补证据还是收尾,决定权完全在模型手上;一次合理的重复调用不会被延迟、也不会被拦。
被排除的工具会打断重复链吗?
不会——未被跟踪的调用对链是透明的。当 todo_write 被排除时,grep X → todo_write → grep X 仍然算作两次连续的 grep X,所以记账类工具没法把一个循环洗白。