Repeat-tool-call guard plugin
重复工具调用守卫插件
A model stuck in a loop re-issues the same tool call with byte-identical arguments — re-running a failing grep, re-reading an unchanged file, polling a command that already gave its answer — and each round trip burns tokens, wall-clock, and (for paid APIs) money without adding information. The harness has nothing that notices: the loop has no step budget, no plugin tracks call repetition, and the model only escapes w
English
Problem
A model stuck in a loop re-issues the same tool call with byte-identical arguments — re-running a failing grep, re-reading an unchanged file, polling a command that already gave its answer — and each round trip burns tokens, wall-clock, and (for paid APIs) money without adding information. The harness has nothing that notices: the loop has no step budget, no plugin tracks call repetition, and the model only escapes when it happens to vary its own behavior. The failure mode is real and cheap to detect — pi-repeat-tool-guard ships exactly this as a pi coding-agent extension: count consecutive identical calls and, past a threshold, append a <system-reminder> telling the model to stop repeating itself and change course.
The harness already has every seam the pi extension uses, and better ones: the interception-seams Agent Note gives tools/post-execute a sanctioned way to attach model-facing context to a finished call, the loop buffers and injects that context with call/result adjacency preserved, and injected context is a logged context/message — so a native guard satisfies the model-visible ⟺ logged rule with no new session event. What was missing was only the plugin itself.
Decision
The guard is a loop-hygiene plugin, not a model-facing tool. It counts consecutive calls to the same tool with identical canonical arguments and injects advisory reminders at configured thresholds. It never delays, blocks, or rewrites a call; the model decides whether to retry differently or finish.
The plugin is @deepseek-ai/dsh-repeat-tool-guard at packages/guard/repeat-tool-guard/, opening the guard/ group for loop-hygiene plugins (single-package groups have precedent: the todo-write Agent Note shipped todo/tool-todo). It registers two listeners and holds state in a WeakMap keyed by the live Agent object — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish; weak object keys also make a disposal-only cleanup listener unnecessary.
tools/post-execute(waterfall) — the one detection point. The listener receives(exec, result)together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because itstool_call/tool_resulthooks are separate events). It always delegates vianext()and, when a threshold is hit, prepends a reminder to the downstream decision'sadditionalContexts— the observe-and-enrich posture the hooks bridges already use, honoring the waterfall contract. Counting happens here rather than intools/pre-executebecause post-execute also runs for denied calls (ToolRegistry.executeroutes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking.agent/prompt-submit(waterfall) — pure reset hook: delegate vianext(), clear the submitting agent's chain. A user interjection changes the context; repetition across it is not a loop.
Detection semantics
The chain key is (tool name, canonical arguments); a call identical to the previous tracked call increments the agent's consecutive counter, a different tracked call resets it to 1. Canonicalization is a deep key-sort plus JSON.stringify: ToolExecution.arguments is by construction the loop's JSON.parse output (or the raw string fallback for malformed argument JSON, which is itself a comparable value), so the pi original's bigint/circular/undefined handling has no inputs here and is deliberately dropped.
Two deliberate rules, both documented in the package README because they are behavior a reader would otherwise guess at:
- Untracked calls are transparent to the chain. A call excluded by
include/excludeneither increments nor resets the counter, sogrep X → todo_write → grep Xstill counts as two consecutivegrep Xwhentodo_writeis excluded. This is what makes exclusion useful — bookkeeping tools interleaved into a loop must not launder it — and it is the pi extension's (undocumented) semantics, kept on purpose and written down. - Calls without an agent are ignored. A direct
ctx.tools.execute()caller (tests, non-loop consumers) has no model to remind and no live agent object to key on.
Reminder delivery
Reminders ride additionalContexts as their own entries (source {kind: 'plugin', plugin: 'repeat-tool-guard'} — the label is load-bearing per HookContext), never a content replacement: the tool/result event stays the tool's own output for audit, and the loop appends buffered contexts as context/messages after the step's results, which the session renders as tagged synthetic-user envelopes and derived history replays. Thresholds escalate: the first configured threshold gets a short "you are repeating yourself, analyze the previous result" nudge; each later threshold gets the detailed form naming the tool, the repeat count, and the canonical arguments (head-truncated at argumentsPreviewChars, default 500 — a looping write-sized payload must not ride into the next request unbounded; the chain key always compares the full canonical string), and stating that the calls made no progress. The pi original hardcodes the gentle text to the literal count 3; the guard keys it to thresholds[0], fixing that bug in the port. A downstream hook bridge contribution remains a separate array entry, so both plugins retain their source, envelope, and metadata.
Config
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder
include: [] # tool-name patterns to track; empty ⇒ all tools
exclude: [todo_write] # tool-name patterns transparent to the chain
argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
thresholds is validated at load and throws on an empty list, a non-integer, a value below 2, or a duplicate — misconfiguration fails loud, replacing the pi original's silent fall-back to defaults. include/exclude entries support * wildcards. Patterns are predicates over whatever tools exist at call time, not references to a registry entry, so an entry matching no currently registered tool is NOT an error — unlike toolOrder's referent check, exclude: [mcp_*] must stay valid in a deployment that loads no MCP tools.
Testing
- Unit: A real loop with a scripted adapter covers counting and reset rules, untracked transparency, disposal cleanup, per-agent isolation, canonical argument key order, escalation, denied calls, no-agent execution, wildcard escaping, invalid config, and downstream block or replacement decisions at per-file 100% coverage.
- Snapshot: The keyless
repeat-tool-guardscenario makes five identicaltodo_writecalls and pins the gentle third-call and detailed fifth-call reminders in both ACP output and the session log. The plugin is loaded in the live example but remains inert in other scenarios. - E2e: None; the plugin is deterministic and provider-independent, and its seam contracts are covered by their owners.
Alternatives considered
- Append the reminder into the tool result (
acceptwith replacedcontent— the pi extension's mechanism, which patches result content because that is the only channel its API offers) — rejected: it makes the loggedtool/resultlie about what the tool returned, andadditionalContextsis the separate sanctioned channel for post-execute commentary, with loop-level buffering that preserves call/result adjacency. - Count in
tools/pre-executewith a pending-reminder map (the pi two-phase shape) — rejected: post-execute alone sees(exec, result)together and also fires for denied calls, so one listener with no cross-event state covers strictly more attempts with less machinery. - Escalate to
blockat the highest threshold — rejected for the initial scope: a blocked call punishes legitimate identical repeats (polling a long-running terminal, re-checking a file the agent expects to change), and an advisory reminder keeps the model in control. Revisit with evidence; the decision shape (PostToolDecision) already supports it. - A per-deployment external hook via the CC/Codex bridges (a
PostToolUsescript) — rejected as the answer: it works for one deployment, but a shipped, unit-tested,cordis.yml-configurable plugin is the harness-native form, without per-call subprocess cost. - A loop-level step or repetition budget in
agent-loop— rejected: "plugins, not loop changes"; a hard step budget is a blunter, orthogonal control that would need its own proposal. - Fuzzy/near-identical detection (normalized paths, similar-but-not-equal arguments) — rejected: exact match after canonicalization is cheap, deterministic, and explainable to the model; similarity thresholds invite false positives and need evidence before they earn complexity.
- Placing the package in
core/— rejected: core is the product spine; a behavioral guard is an optional leaf plugin, and thetodo/precedent is a small dedicated group per plugin family.
Consequences
- The reminder is advisory by design: idempotent polling patterns that repeat identical calls on purpose still receive nudges past the thresholds, and the pressure valves are config (
thresholds,exclude) plus reminder text that explicitly allows finishing when enough evidence has been gathered. Each trigger costs reminder tokens on the next request; thresholds bound the frequency. - Chain state is in-memory only: a session resumed from persistence starts with a fresh chain, so a loop spanning a resume draws its reminders later than a live one — accepted, the guard is a heuristic nudge, not a logged invariant, and persisting counter state would buy little for real complexity.
- When multiple post-execute producers attach context on one call, each contribution stays a separate
HookContext; ordering follows waterfall nesting and each entry retains its own provenance. - Implementing the snapshot tier surfaced a hidden assumption in the suite kit: the fixture guard equated "authored model scenario" with "override-driven". The
Scenariotable now carries an explicitoverriddenflag, and the sidecar's presence is checked BOTH ways against it (an unregistered stray sidecar would silently replace the derived script) — the suite kit is stricter than it was before this plugin existed.
Deferred
- Compaction does not reset chains: a compacted history changes what the model sees, but the repetition risk usually survives compaction.
- Escalating to
blockat a high threshold is not implemented;PostToolDecisionalready supports it if evidence arrives. - Subagent chains stay isolated per agent; no sharing mechanism exists until a concrete case appears.
中文
问题
模型陷入循环时,会以字节级相同的参数反复发起同一个工具调用——重新运行一条失败的 grep、重新读取一个未变化的文件、轮询一条已经给出答案的命令——每一轮往返都消耗 token、挂钟时间以及(对付费 API 而言)金钱,却不带来新信息。harness 目前没有任何机制能察觉这一点:循环没有步骤预算,没有插件追踪调用重复,模型只有在碰巧改变自身行为时才能跳出。这种失败模式真实存在且检测成本极低——pi-repeat-tool-guard 正是以 pi coding-agent 扩展的形式提供了这一功能:统计连续相同调用次数,超过阈值后追加一条 <system-reminder> 告诉模型停止重复并换个方向。
harness 已经具备 pi 扩展所使用的全部 seam,而且更好:拦截 seam Agent Note赋予 tools/post-execute 一种经过认可的方式,将面向模型的上下文附加到已完成的调用上;循环缓冲并注入该上下文,同时保持调用/结果的邻接关系;注入的上下文是一条已记录的 context/message——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺少的只是插件本身。
决策
该守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻止或改写调用;模型自行决定是换种方式重试还是结束。
插件为 @deepseek-ai/dsh-repeat-tool-guard,位于 packages/guard/repeat-tool-guard/,开辟 guard/ 分组用于循环卫生插件(单包(package)分组有先例:todo-write Agent Note发布了 todo/tool-todo)。它注册两个监听器,将状态保存在以存活 Agent 对象为键的 WeakMap 中——工具注册表是上下文级别的单例,其 waterfall(瀑布式事件)交错所有 agent(智能体)的调用(subagent 运行在同一个上下文上),因此按 agent 分键是正确性要求,而非锦上添花;弱对象键还使得纯清理用途的 disposal 监听器不再必要。
tools/post-execute(waterfall)——唯一的检测点。监听器同时接收(exec, result),因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要它,仅因为其tool_call/tool_result钩子是分开的事件)。它始终通过next()委托,当命中阈值时,将提醒前置到下游决策的additionalContexts——这正是钩子桥接已采用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非tools/pre-execute,因为 post-execute 也会为被拒绝的调用触发(ToolRegistry.execute将 deny 路由到同一条流水线),而模型反复敲击一个被拒绝的调用恰恰是值得打破的循环。agent/prompt-submit(waterfall)——纯重置钩子:通过next()委托,清除提交 agent 的链。用户介入改变了上下文;跨越介入的重复不是循环。
检测语义
链的键是 (tool name, canonical arguments);与前一个被追踪调用相同的调用递增该 agent 的连续计数器,不同的被追踪调用将其重置为 1。规范化方式为深度键排序加 JSON.stringify:ToolExecution.arguments 按构造就是循环中 JSON.parse 的输出(或格式错误的参数 JSON 的原始字符串回退,其本身也是可比较的值),因此 pi 原版对 bigint/循环引用/undefined 的处理在此没有输入,被有意去除。
两条刻意的规则,均记录在包 README 中,因为它们是读者否则只能猜测的行为:
- 未追踪的调用对链透明。 被
include/exclude排除的调用既不递增也不重置计数器,因此grep X → todo_write → grep X在todo_write被排除时仍计为两次连续的grep X。这正是排除功能有用的原因——穿插在循环中的簿记工具不得为循环洗白——也是 pi 扩展的(未文档化的)语义,有意保留并明确写下。 - 没有 agent 的调用被忽略。 直接调用
ctx.tools.execute()的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的存活 agent 对象。
提醒投递
提醒作为独立条目搭载在 additionalContexts 上(source 为 {kind: 'plugin', plugin: 'repeat-tool-guard'}——依照 HookContext,该标签承载语义),绝不替换 content:tool/result 事件仍是工具自身的审计输出,循环则在步骤结果之后把缓冲的上下文追加为 context/message,会话将其渲染为带标签的合成 user 信封,并由派生历史回放。阈值逐级升级:第一个配置阈值获得简短的「你正在重复自己,请分析先前结果」提示;后续各阈值获得详细形式,包含工具、重复计数和规范参数(在头部截断到 argumentsPreviewChars,默认 500——循环中的 write 级 payload 不得无界地进入下一次请求;链键始终比较完整规范字符串),并说明这些调用没有取得进展。pi 原版把温和文本硬编码为字面计数 3;本守卫以 thresholds[0] 为键,修复了移植中的这一 bug。下游钩子桥贡献仍是独立数组条目,因此两个插件都保留各自的 source、信封与元数据。
配置
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder
include: [] # tool-name patterns to track; empty ⇒ all tools
exclude: [todo_write] # tool-name patterns transparent to the chain
argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
thresholds 在加载时校验,遇到空列表、非整数、小于 2 的值或重复项时抛出异常——配置错误快速失败,取代 pi 原版的静默回退到默认值。include/exclude 条目支持 * 通配符。模式是对调用时实际存在的工具的谓词,而非对注册表条目的引用,因此匹配不到当前已注册工具的条目不是错误——与 toolOrder 的引用检查不同,exclude: [mcp_*] 在未加载 MCP 工具的部署中也必须保持有效。
测试
- 单元测试: 使用脚本化适配器的真实循环,覆盖计数与重置规则、未追踪透明性、dispose(资源释放)清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游阻止或 replacement 决策,达到逐文件 100% 覆盖率。
- 快照测试: keyless 的
repeat-tool-guard场景发起五次相同的todo_write调用,在 ACP 输出和会话日志中固定第三次调用的温和提醒与第五次调用的详细提醒。该插件在实时示例中加载,但在其他场景中保持静默。 - E2e 测试: 无。该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。
曾考虑的替代方案
- 将提醒追加到工具结果中(以替换
content的方式accept——pi 扩展的机制,它修补结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的tool/result对工具实际返回的内容撒谎,而additionalContexts是 post-execute 评注的独立认可通道,循环级缓冲保持了调用/结果的邻接关系。 - 在
tools/pre-execute中计数并使用 pending-reminder map(pi 的两阶段形态):否决。post-execute 单独就能同时看到(exec, result)且也为被拒绝的调用触发,因此一个监听器、无跨事件状态即可以更少的机制覆盖严格更多的尝试。 - 在最高阈值升级为
block:在初始范围内否决。阻止调用会惩罚合法的相同重复(轮询长时间运行的终端、重新检查 agent 预期会变化的文件),而建议性提醒让模型保持控制权。待有证据后重新审视;决策形状(PostToolDecision)已支持此选项。 - 通过 CC/Codex 桥接的逐部署外部钩子(一个
PostToolUse脚本):否决作为最终答案。它对单个部署有效,但一个已发布、有单元测试、可通过cordis.yml配置的插件才是 harness 原生的形式,且没有逐调用的子进程开销。 - 在
agent-loop中设置循环级步骤或重复预算:否决。「用插件,不改循环」;硬性步骤预算是一种更粗粒度的正交控制,需要自己的提案。 - 模糊/近似相同检测(路径归一化、相似但不完全相同的参数):否决。规范化后的精确匹配成本低、确定性强、且可向模型解释;相似度阈值引入误报风险,需要证据才能换取复杂度。
- 将包放在
core/:否决。core 是产品主干;行为守卫是可选的叶子插件,todo/的先例是每个插件族一个小型专属分组。
后果
- 提醒在设计上是建议性的:有意重复相同调用的幂等轮询模式仍会在超过阈值后收到提示,减压阀是配置(
thresholds、exclude)加上明确允许「在已收集足够证据时结束」的提醒文本。每次触发在下一次请求中增加提醒 token 的开销;阈值限制了触发频率。 - 链状态仅存于内存:从持久化恢复的会话以全新的链开始,因此跨越恢复的循环比实时循环更晚收到提醒——可以接受,守卫是启发式提示而非已记录的不变式,持久化计数器状态带来的收益不值得其复杂度。
- 当多个 post-execute 生产者在同一次调用上附加上下文时,每项贡献保持为独立的
HookContext;顺序遵循 waterfall 嵌套关系,每个条目保留自己的溯源信息。 - 实现快照层时暴露了 suite kit 的一项隐藏假设:fixture guard 把「撰写的模型场景」等同于「由 override 驱动」。
Scenario表现在携带显式的overridden标志,并且 sidecar 是否存在会以双向方式与其核对(未注册的游离 sidecar 会静默替换派生脚本)——suite kit 比本插件出现前更严格。
延后事项
- 压缩(compaction)不重置链:压缩后的历史改变了模型所见的内容,但重复风险通常在压缩后仍然存在。
- 在高阈值升级为
block未实现;PostToolDecision已支持此选项,待证据到来时启用。 - subagent 的链按 agent 隔离;在出现具体用例之前不提供共享机制。