9.9 KiB
RFC: Repeat-tool-call guard plugin
Status: implemented
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 RFC 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 RFC shipped todo/tool-todo). It registers three listeners and holds all state in plugin-local maps keyed by AgentId — 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.
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, folds a reminder onto the downstream decision'sadditionalContext— 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.agent/status(emit) — ondisposed, drop the agent's state, bounding the maps over harness lifetime.
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 noAgentIdto key on.
Reminder delivery
Reminders use additionalContext with the plugin source, preserving the original tool/result. The first threshold emits a short nudge; later thresholds include the tool, count, and a bounded argument preview while comparison still uses the full canonical string. Existing downstream context is concatenated under the guard's source because HookContext supports one source.
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, andadditionalContextexists precisely as 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, the fold concatenates under the guard's
source; ordering between plugins follows listener registration order. The seam cannot represent mixed provenance — a limit inherited fromHookContext, not owned by this plugin.
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.