Certified Agent Harness Engineer
Course sections

M2.4

Loop-hygiene guards

1,108 words · about 7 min

Concept

A correct loop can still be a useless loop. The characteristic failure of a tool-using model is not crashing — it is not making progress: calling grep with the same pattern nine times, retrying a denied operation forever, or hanging on a tool that never returns. None of this is an error by any exit condition in M2.1. The loop is behaving exactly as specified, forever.

Guards are the layer that watches for unproductive patterns and enforces per-call budgets. packages/guard/README.md frames the category precisely: a guard is a self-contained consumer of core services and extension points, not a swappable capability. You do not swap in a different repetition detector the way you swap a filesystem backend; you either load the guard or you do not.

Mechanism — repetition detection

Mechanism

packages/guard/repeat-tool-reminder/src/index.ts is 233 lines and worth reading in full. The design decisions:

It counts in tools/post-execute, not pre-execute. The reason is documented in the source and is not obvious: denied calls also flow through this waterfall (ToolRuntime.execute routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking. A pre-execute detector never sees the denials, which is the highest-value case.

It canonicalizes arguments before comparing. sortJsonValue deep key-sorts and then stringifies, so {a:1,b:2} and {b:2,a:1} produce the same chain key. Without this, a model that varies property order — which they do — defeats detection entirely.

It is advisory and never vetoes. The source comment is the design statement: observe-and-enrich, never veto: count first (state advances regardless of the downstream outcome), DELEGATE so a later listener can still block or replace, then fold the reminder onto whatever came back. The reminder rides as additionalContexts on the decision, and because it rides both decision variants, a blocked call still gets the nudge. Escalation is two-tier: a GENTLE_REMINDER at the first configured threshold, then a detailed reminder naming the tool, the run length, and the canonical arguments.

A user interjection resets the chain. It listens on agent/pre-step and deletes the chain when any entering message has source.kind === 'user', with the comment: a user interjection changes the context; repetition across it is not a loop.

The reminder is a labeled plugin message. It is built with createUserMessage carrying source: { kind: 'plugin', plugin: 'repeat-tool-reminder', form: 'notice', … }, and the source comment calls the label load-bearing (an unlabeled context would render as a user prompt in derived history). This is the AGENTS.md model-visible ⟺ logged invariant in action: the guard's nudge reaches the model, so it must be a logged session event, and it must be attributable so a transcript does not attribute plugin text to the human.

Config is thresholds (default [3, 5, 8]), include/exclude wildcard tool-name patterns, and argumentsPreviewChars (default 500 — bounding the reminder, never the detection, since the chain key always uses the full canonical string). Validation is fail-loud: an empty threshold list, a non-integer, a value below 2, or a duplicate throws at plugin load. Per AGENTS.md, misconfiguration fails loud rather than silently falling back — a repetition guard that silently disabled itself would be worse than no guard, because you would believe you had one.

Mechanism — tool-timeout policy

Mechanism

packages/guard/timeout-policy/src/index.ts is 81 lines and is the cleanest example of cooperative cancellation in the repo:

ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
  const timeoutMs = ctx.tools.get(exec.name, exec.agent)?.timeoutMs
  if (timeoutMs === undefined) return next()          // no declared budget: delegate unchanged

  using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
  const upstream = exec.signal
  exec.signal = d.signal                              // swap in the derived deadline
  try {
    const result = await next()
    if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
      return toolTimeoutResult(timeoutMs)             // OUR timer fired: replace the result
    }
    return result
  } finally {
    exec.signal = upstream                            // always restore
  }
})

Four things here are each preventing a specific failure:

  1. The derived signal chains from exec.signal. A user Ctrl-C still propagates through the timeout wrapper to the tool. A fresh unchained controller would make tools with timeouts uncancellable by the user.
  2. It awaits next() rather than racing it. The module doc says it: arms that deadline and maps its own expiry to TOOL_TIMEOUT without racing or abandoning the tool promise. Promise.race([toolPromise, timeoutPromise]) is the obvious implementation and it is wrong — it returns while the tool keeps running, which is exactly the orphaned-side-effect problem from M2.3.
  3. The expiry check is scoped by code. timeoutOf(d.signal, TOOL_TIMEOUT) asks specifically whether this wrapper's timer fired. Without the scoping, a nested outer deadline that fired first would be misread as this plugin's timeout; scoped, it reads correctly as an ordinary upstream cancel.
  4. The finally restores the upstream signal. Post-execute listeners must never see this plugin's possibly-already-aborted timeout signal, or they would conclude the whole agent was cancelled.

The replacement result is structured — isError: true with error.info = { name: 'ToolTimeoutError', code: TOOL_TIMEOUT } — so a retry or sandbox plugin, and replay, can route on the code rather than string-matching a message.

Step caps and no-progress detection

Be honest here: dsh does not ship a step cap or a general no-progress detector in the loop. The loop bounds concurrency within a step (DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10), not step count. The agent/pre-step waterfall is where a step cap belongs — a listener can return { kind: 'reject' } and the turn ends blocked — and repetition detection is the shipped approximation of no-progress detection.

This is a genuine gap relative to harnesses that hardcode a turn budget, and the field has no settled answer for what "no progress" even means. Repeated identical calls are detectable. A model that alternates between two useless calls, or reads the same file with one character of difference, or writes and reverts the same edit, is not caught by any shipped heuristic in any harness the author is aware of. Treat published claims of general loop detection with suspicion.

How others differ

Claude Code enforces per-tool timeouts (notably on Bash, with a configurable ceiling) and surfaces the timeout to the model as an error result — the same shape as toolTimeoutResult. It also applies budget limits that end a turn. Codex CLI similarly bounds turns. The architectural difference in dsh is that both guards are plugins loaded by configuration, not loop code: a deployment can run with no timeout policy at all. That trades safe defaults for the ability to compose policy per deployment, which is consistent with the repo's "no hardcoded tunables in plugins; deployment-varying choices are validated Config fields" rule — but a learner should note that "configurable" and "safe by default" are in tension, and dsh leans configurable.


Check yourself

4 questions on M2.4

Start drill