dshkit

Loop guards in DeepSeek Harness: timeouts and the repeat-tool reminder

Two plugins that stop an agent burning budget in a loop — a zero-config tool-call timeout enforcer reading each tool's declared timeoutMs, and an advisory repeat detector that nudges rather than blocks. Config, chain semantics and why cooperative cancellation is not a kill.

Updated 2026-08-145 min
Short answer

dsh-tool-call-timeout-policy is a zero-config tools/execute wrapper that arms a deadline from each tool's own declared timeoutMs and returns a structured TOOL_TIMEOUT result. dsh-repeat-tool-reminder watches for consecutive identical tool calls and injects an escalating advisory at configured thresholds — it never blocks a call. Both are guards: self-contained consumers of core services, not swappable capabilities.

An agent stuck in a loop is the failure mode that costs real money, and DeepSeek Harness ships two plugins for it. They are grouped as guards — self-contained consumers of core services and extension points, explicitly not swappable capabilities.

The distinction matters: a seam has providers you choose between. A guard is just a plugin you either compose or do not.

Tool-call timeouts

@deepseek-ai/dsh-tool-call-timeout-policy is a single tools/execute around-dispatch listener. It is zero-config:

- id: timeout-policy
  name: '@deepseek-ai/dsh-tool-call-timeout-policy'

That is the whole row. The budget is not configured here — it is read from the tool's own declaration, ToolDefinition.timeoutMs, set by the owning tool plugin. The shipped web_fetch and web_search declare theirs through dsh-tool-web's fetchTimeoutMs / searchTimeoutMs config.

The consequence is a nice piece of design: a mistyped tool name is not possible, because the guard never names a tool. It reads whatever is dispatched.

What it does per call

For a tool that declares a timeoutMs, the listener:

  1. Reads the budget from the registry (ctx.tools.get(exec.name)?.timeoutMs) and arms deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT') — one signal fusing the caller's abort with the plugin's timer.
  2. Swaps that derived signal onto exec for the downstream dispatch, then restores the caller's own signal afterwards, so tools/post-execute still sees the caller's signal.
  3. After dispatch, if its own timer fired, replaces the result with a structured error:
{
  "isError": true,
  "error": { "message": "…", "info": { "name": "ToolTimeoutError", "code": "TOOL_TIMEOUT" } },
  "content": "Error: tool call timed out after <ms>ms"
}

A tool that declares no budget delegates untouched.

The replacement is keyed off the signal (timeoutOf), not off the result's shape — because dispatch normalises an upstream-abort error into an ordinary error result first, and the wrapper needs to know whether its timer was the cause.

Cooperative, not a hard kill

So only signal-forwarding tools should declare a budget. If you write a tool and want a timeout to mean anything, you must forward exec.signal down to whatever actually does the work.

The token effect is worth noting for cost work: zero tokens on non-timeout calls, and a timeout adds one small retained error result — while potentially preventing a much larger late provider result from entering context.

Composition order is semantics

Multiple tools/execute listeners compose by cordis registration order. Combined with a future retry or metrics wrapper, registration order chooses the meaning: timeout registered outer means "the timeout covers the whole retry operation"; registered inner means "it covers each attempt".

That is a real decision, not an implementation detail, and it is expressed only by ordering.

The repeat-tool reminder

@deepseek-ai/dsh-repeat-tool-reminder is an advisory loop-breaker, not a model-facing tool. It never appears in the tool list, never vetoes or rewrites a call. It adds exactly one behaviour: count consecutive identical calls, and at configured run lengths inject a reminder.

- id: repeat-tool-reminder
  name: '@deepseek-ai/dsh-repeat-tool-reminder'
  config:
    thresholds: [3, 5, 8]        # consecutive counts that trigger a reminder
    include: []                  # patterns to track; empty ⇒ all tools
    exclude: [todo_write]        # patterns transparent to the chain
    argumentsPreviewChars: 500   # cap on arguments quoted in the detailed reminder

thresholds fails loud at load: an empty list, a non-integer, a value below 2, or a duplicate throws — never a silent fall-back to defaults. The first threshold delivers a short generic nudge; every later one delivers the detailed form naming the tool, the run length and the canonical arguments.

The first-threshold text the model receives:

You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call.

The decision stays with the model. A legitimately repeated call is delayed by nothing and blocked by nothing.

Chain semantics

The chain key is (tool name, canonical arguments), canonicalised by deep key-sort plus JSON.stringify — so argument objects differing only in property order count as identical.

Four rules worth internalising:

Untracked calls are transparent. A call excluded by include/exclude neither increments nor resets the counter. So grep X → todo_write → grep X still counts as two consecutive grep X when todo_write is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it.

Denied calls count. Detection sits on tools/post-execute, which also runs for calls a pre-execute listener denied — a model hammering a denied call is exactly the loop worth breaking.

Per-agent keying. A WeakMap<Agent, Chain> keys each chain by the live agent object, so one agent's repetition never trips another's reminder even though sub-agents interleave through the same waterfall. A user prompt resets the submitting agent's chain.

In-memory only. A session resumed from persistence starts with a fresh chain. The guard is a heuristic nudge, not a logged invariant.

How the reminder is delivered

Reminders ride the post-execute decision's additionalContexts with source {kind: 'plugin', plugin: 'repeat-tool-reminder'}never a content replacement, so the tool/result event stays the tool's own output for audit.

The loop buffers the context and appends it as an injected user/message after the step's tool results. So the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event type.

That is the same discipline you see everywhere in this codebase: if the model saw it, the log can rebuild it.

If you care about agent cost

These two are the cheapest guardrails available, and they address different halves of the problem:

  • Timeouts bound a single call that hangs — and, importantly, keep a slow provider's late result out of context.
  • The repeat reminder bounds the pattern where every individual call succeeds quickly and the agent still makes no progress. No timeout catches that one.

Neither is a budget cap. For hard bounds you want the goal round cap, the workflow engine's maxTotalAgents, and Ralph's maxRounds — see the workflow engine. Guards reduce waste; caps stop it.

Frequently asked

Does the timeout plugin kill a runaway tool?

No. The derived signal only notifies; termination stays with the tool and the capability it forwards exec.signal to. Declaring timeoutMs means 'cooperative with exec.signal' — a tool that ignores the signal will not stop.

Do I configure timeouts per tool in the guard?

No, the guard is zero-config. The budget is read from the tool's own ToolDefinition.timeoutMs, set by the owning tool plugin — so a mistyped tool name is not possible.

Does the repeat reminder block repeated calls?

Never. It adds exactly one behaviour: an advisory reminder at configured run lengths. The decision to retry, gather evidence or finish stays entirely with the model, and a legitimately repeated call is delayed by nothing.

Do excluded tools break a repeat chain?

No — untracked calls are transparent to the chain. grep X → todo_write → grep X still counts as two consecutive grep X when todo_write is excluded, so bookkeeping tools cannot launder a loop.

Keep reading