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:
- Reads the budget from the registry (
ctx.tools.get(exec.name)?.timeoutMs) and armsdeadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')— one signal fusing the caller's abort with the plugin's timer. - Swaps that derived signal onto
execfor the downstream dispatch, then restores the caller's own signal afterwards, sotools/post-executestill sees the caller's signal. - 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 reminderthresholds 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.