Skip to content

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共享 deadlineidleWatchdogclampTimeoutTimeoutReason
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 与已过期限 timeoutMsError 子类——只通过 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_MS2_147_483_647Node 无需钳制就排程的最大延迟

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 错误。

ts
export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'

包装器读取 ctx.tools.get(exec.name, exec.agent)?.timeoutMs——未声明预算的工具不设 deadline(原样委派):

ts
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 包装器先触发的定时器)被误读为本插件的超时——它读作普通的上游取消。
  • 替换结果是 isErrorToolExecutionResulterror.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: Narguments),并按 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 泄漏)。其插件配置选择贡献的

ts
export interface Config {
  enabled?: boolean            // default true
  package_allowlist?: string[] // 允许包名的正则来源;空则全部放行
  package_blocklist?: string[] // 在 allowlist 匹配后排除
}

违反不变量会抛出带包归属的 InvariantErrorcode: 'INVARIANT')并附属主包名。作为守卫,它是安全网而非循环定时器:它让运行时契约违例大声且可归因,而不是静默损坏状态。

参考:官方模式文档

仓库的 docs/defensive-patterns.md 刻画了这些守卫所属的更广防御姿态。其重点在于原因分类与区间属主:当多个 follow-up 共用一个区间时,绝不把一条状态事件或空闲信号当作单次操作的结果;也绝不等待一个无法发生的转移。这里的守卫具体落实了这一纪律——超时分类TOOL_TIMEOUT vs 上游取消)与重复检测(规范化身份)都关乎把循环归因于其真实原因。

延伸阅读

  • 权限与审批——守卫在 pre-execute/execute/post-execute 顺序中的位置。
  • 沙箱架构:总览——超时重试插件可借 TOOL_TIMEOUT 路由的升级词汇。
  • packages/util/timeout/src/index.ts——deadlineidleWatchdogclampTimeoutTimeoutReason
  • 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——包属主不变量注册表。