dsh 中的守卫是这样一种策略:可以在不重写循环核心的情况下终止或注入 agent 循环。两个守卫位于 packages/guard/*——一个协作式超时强制器与一个咨询性重复调用提醒——构建在 packages/util/timeout 的共享超时算术之上,并以下方 packages/runtime-diagnostics/invariants 的包属主运行时不变量作为安全网。
| 包 | 角色 |
|---|---|
packages/guard/timeout-policy | 协作式工具调用超时强制(TOOL_TIMEOUT) |
packages/guard/repeat-tool-reminder | 咨询性重复调用检测器 |
packages/util/timeout | 共享 deadline、idleWatchdog、clampTimeout、TimeoutReason |
packages/runtime-diagnostics/invariants | 可配置的包属主运行时不变量注册表 |
守卫在循环中做什么
守卫接入 tools waterfall(顺序见权限与审批),而不会把自身逻辑并进某个单一策略服务:
tools/pre-execute——allow/deny/ask门(权限、沙箱)。- 单调守卫——拒绝或弃权;身份受保护。
tools/execute——围绕派发的包装器:超时、重试、度量。timeout-policy就在这里武装它的 deadline。tools/post-execute——接受、阻止、替换或追加上下文。repeat-tool-reminder就在这里注入它的提示。
因此两个守卫涵盖了守卫可能执行的两种动作:终止(超时调用被替换为结构化的 TOOL_TIMEOUT 结果)与注入(重复调用被前置面向模型的上下文,绝非否决)。
协作式超时强制
该模式底部在 packages/util/timeout/src/index.ts。其核心对象是 TimeoutReason——一个携带能力属主 code 与已过期限 timeoutMs 的 Error 子类——只通过 abort 信号投递;每个能力仍拥有停止其工作并把超时原因翻译为公开结果的机制。
| 工具函数 | 签名 | 用途 |
|---|---|---|
deadline(upstream, timeoutMs, code) | Deadline | 融合上游取消与一个可识别的超时;timeoutMs <= 0 表示"无超时"。返回 { signal, [Symbol.dispose]() }(dispose-once 定时器清理) |
idleWatchdog(upstream, timeoutMs, code) | IdleWatchdog | 针对单个未决 async-iterator 需求的可重新武装超时;pulse() 重新武装 |
clampTimeout(requested, def, max, name?) | number | 校验调用方提示,套用后端默认,封顶于 max |
timeoutOf(x, code?) | TimeoutReason | undefined | 从 signal/reason 载体取回 TimeoutReason;以 code 界定作用域 |
MAX_TIMER_DELAY_MS | 2_147_483_647 | Node 无需钳制就排程的最大延迟 |
deadline 使用 AbortSignal.any([upstream, timer.signal]),因此竞速解析为单一原因:只有超时获胜时 timeoutOf 才读到 TimeoutReason,而上游取消留下普通 abort 原因。
timeout-policy 插件
packages/guard/timeout-policy/src/index.ts 注册一个 tools/execute 包装器。其前提是协作:工具在其定义上声明 timeoutMs 并承诺响应 exec.signal;包装器武装该 deadline,并把自己的到期映射为 TOOL_TIMEOUT 错误。
export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'包装器读取 ctx.tools.get(exec.name, exec.agent)?.timeoutMs——未声明预算的工具不设 deadline(原样委派):
using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
const upstream = exec.signal
exec.signal = d.signal
try {
const result = await next()
if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
return toolTimeoutResult(timeoutMs) // replace with structured TOOL_TIMEOUT
}
return result
} finally {
exec.signal = upstream // post-execute never sees the (possibly aborted) timeout signal
}关键性质:
- 把
timeoutOf界定到TOOL_TIMEOUT可防止嵌套的外部 deadline(另一个tools/execute包装器先触发的定时器)被误读为本插件的超时——它读作普通的上游取消。 - 替换结果是
isError的ToolExecutionResult,error.code === 'TOOL_TIMEOUT',因此重试/沙箱插件(以及重放)可以据此路由。 - deadline 是围绕派发的:它绝不放弃工具 promise 或与之破坏性竞速——工具看到被中止的信号、达到安静,包装器再替换为结构化结果。
repeat-tool-reminder 守卫
packages/guard/repeat-tool-reminder/src/index.ts 是咨询性的:它在 tools/post-execute 决定上追加记录在案的模型上下文,而不否决或改写调用。目标是打破模型在完全相同的调用上循环。
检测
按 agent(WeakMap<Agent, Chain>),它保留上次被跟踪调用的规范化身份及其运行长度:
canonicalize(exec.arguments)——对解析后的 JSON 参数做递归按键排序(对畸形 JSON 回退到原始字符串),使两个仅在属性顺序上有差异的调用得到相同规范化。key = JSON.stringify([exec.name, canonical]);当 key 变化时链的count重置为1。- 计数发生在
tools/post-execute——因为被拒的调用也经过这个 waterfall,而模型反复撞一个被拒调用正是值得打破的循环。
注入什么
在每个配置的阈值(thresholds,默认 [3, 5, 8])上它返回一条 UserMessage:
- 首个阈值发出温和提醒("You are repeating the exact same tool call with identical arguments…");
- 后续阈值发出详细提醒,点名工具、运行长度与规范化参数(
consecutive_calls: N、arguments),并按argumentsPreviewChars(默认 500)截断预览,使巨大载荷不会不受约束地乘上下一次请求。
该提醒搭载在 post-execute 决定的 additionalContexts 上——因此连被阻止的调用也会收到提示——并且盖戳 source: { kind: 'plugin', plugin: 'repeat-tool-reminder' }(该标签是承重的:未标注的上下文在派生历史里会渲染成用户提示)。它是观察并丰富,绝不否决:状态总是推进(observe 先于 next() 运行)。
用户插话会重置链:agent/pre-step 监听器在入站消息包含用户源消息时删除该 agent 的链(跨插话的重复不是循环)。
配置
thresholds(默认 [3, 5, 8])、include/exclude(在调用时对工具名做 * 通配谓词——匹配不到任何已注册工具的模式也是合法的,例如没有 MCP 工具时 exclude: [mcp_*] 依然合法)、argumentsPreviewChars(默认 500)。配置错误会在加载时大声失败(空列表、非整数、< 2、重复值或非整数预览数都抛错)。
不变量作为安全网
packages/runtime-diagnostics/invariants/src/index.ts(@deepseek-ai/dsh-invariants)是一个可配置的、包属主运行时不变量贡献注册表。每个 workspace 包都会从 ./invariant companion 注册检查(例如 credentials 用它强制 credentials/updated 只在 credentials 服务仍挂载时触发,从而暴露 provider 泄漏)。其插件配置选择贡献的包:
export interface Config {
enabled?: boolean // default true
package_allowlist?: string[] // 允许包名的正则来源;空则全部放行
package_blocklist?: string[] // 在 allowlist 匹配后排除
}违反不变量会抛出带包归属的 InvariantError(code: 'INVARIANT')并附属主包名。作为守卫,它是安全网而非循环定时器:它让运行时契约违例大声且可归因,而不是静默损坏状态。
参考:官方模式文档
仓库的 docs/defensive-patterns.md 刻画了这些守卫所属的更广防御姿态。其重点在于原因分类与区间属主:当多个 follow-up 共用一个区间时,绝不把一条状态事件或空闲信号当作单次操作的结果;也绝不等待一个无法发生的转移。这里的守卫具体落实了这一纪律——超时分类(TOOL_TIMEOUT vs 上游取消)与重复检测(规范化身份)都关乎把循环归因于其真实原因。
延伸阅读
- 权限与审批——守卫在 pre-execute/execute/post-execute 顺序中的位置。
- 沙箱架构:总览——超时重试插件可借
TOOL_TIMEOUT路由的升级词汇。 packages/util/timeout/src/index.ts——deadline、idleWatchdog、clampTimeout与TimeoutReason。packages/guard/timeout-policy/src/index.ts——tools/execute包装器与TOOL_TIMEOUT。packages/guard/repeat-tool-reminder/src/index.ts——链检测器与其两级提醒。packages/runtime-diagnostics/invariants/src/index.ts——包属主不变量注册表。