Skip to content

A guard in dsh is a policy that can terminate or inject into the agent loop without rewriting the loop's core. Two guards live in packages/guard/* — a cooperative timeout enforcer and an advisory repeat-call reminder — built on the shared timeout arithmetic in packages/util/timeout, with package-owned runtime invariants (packages/runtime-diagnostics/invariants) as the safety net beneath them.

PackageRole
packages/guard/timeout-policyCooperative tool-call timeout enforcement (TOOL_TIMEOUT)
packages/guard/repeat-tool-reminderAdvisory repeated-call detector
packages/util/timeoutShared deadline, idleWatchdog, clampTimeout, TimeoutReason
packages/runtime-diagnostics/invariantsConfigurable package-owned runtime invariant registry

What a "guard" does in the loop

Guards plug into the tools waterfalls (permissions shows the ordering) without merging their logic into a single policy service:

  • tools/pre-execute — allow/deny/ask gates (permission, sandbox).
  • monotonic guards — deny or abstain; identity-protected.
  • tools/executearound-dispatch wrappers: timeout, retry, metrics. This is where timeout-policy arms its deadline.
  • tools/post-execute — accept, block, replace, or add context. This is where repeat-tool-reminder injects its nudge.

So the two guards cover the two verbs a guard may perform: terminate (a timed-out call is replaced with a structured TOOL_TIMEOUT result) and inject (a repeated call gets model-facing context prepended, never a veto).

Cooperative timeout enforcement

The pattern bottoms out in packages/util/timeout/src/index.ts. Its core object is TimeoutReason — an Error subclass carrying a capability-owned code and an elapsed timeoutMs — delivered through abort signals only; each capability still owns the mechanism that stops its work and translates reasons into public outcomes.

UtilitySignaturePurpose
deadline(upstream, timeoutMs, code)DeadlineFuse upstream cancellation with an identifiable timeout; timeoutMs <= 0 means "no timeout". Returns { signal, [Symbol.dispose]() } (dispose-once timer cleanup)
idleWatchdog(upstream, timeoutMs, code)IdleWatchdogRearmable timeout around a single outstanding async-iterator demand; pulse() re-arms
clampTimeout(requested, def, max, name?)numberValidate a caller hint, apply the backend default, cap at max
timeoutOf(x, code?)TimeoutReason | undefinedRecover a TimeoutReason from a signal/reason carrier; scoped by code
MAX_TIMER_DELAY_MS2_147_483_647Largest delay Node schedules without clamping

deadline uses AbortSignal.any([upstream, timer.signal]), so a race resolves to a single cause: timeoutOf reads TimeoutReason only when the timeout won, while an upstream cancel leaves an ordinary abort reason.

The timeout-policy plugin

packages/guard/timeout-policy/src/index.ts registers one tools/execute wrapper. Its premise is cooperation: a tool declares timeoutMs on its definition and promises to honor exec.signal; the wrapper arms that deadline and maps its own expiry to the TOOL_TIMEOUT error.

ts
export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'

The wrapper reads ctx.tools.get(exec.name, exec.agent)?.timeoutMs — a tool declaring no budget gets no deadline (delegate unchanged):

ts
using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
const upstream = exec.signal
exec.signal = d.signal
try {
  const result = await next()
  if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
    return toolTimeoutResult(timeoutMs)   // replace with structured TOOL_TIMEOUT
  }
  return result
} finally {
  exec.signal = upstream   // post-execute never sees the (possibly aborted) timeout signal
}

Key properties:

  • Scoping timeoutOf to TOOL_TIMEOUT keeps a nested outer deadline (another tools/execute wrapper's timer that fired first) from being misread as this plugin's own timeout — it reads as an ordinary upstream cancel.
  • The replacement result is an isError ToolExecutionResult with error.code === 'TOOL_TIMEOUT', so a retry/sandbox plugin (and replay) can route on it.
  • The deadline is around-dispatch: it never abandons the tool promise or races it destructively — the tool sees the aborted signal, reaches quiescence, and the wrapper substitutes the structured result.

The repeat-tool-reminder guard

packages/guard/repeat-tool-reminder/src/index.ts is advisory: it enriches tools/post-execute decisions with logged model context without vetoing or rewriting calls. The goal is to break a model that loops on the exact same call.

Detection

Per agent (WeakMap<Agent, Chain>), it keeps the last tracked call's canonical identity and its run length:

  • canonicalize(exec.arguments) — deep key-sort of the parsed-JSON arguments (or the raw-string fallback for malformed JSON), so two calls differing only in property order canonicalize identically.
  • key = JSON.stringify([exec.name, canonical]); the chain count resets to 1 when the key changes.
  • Counting happens in tools/post-execute — because denied calls also flow through this waterfall, and a model hammering a denied call is exactly the loop worth breaking.

What it injects

At each configured threshold (thresholds, default [3, 5, 8]) it returns a UserMessage:

  • the first threshold emits a gentle reminder ("You are repeating the exact same tool call with identical arguments…");
  • later thresholds emit a detailed reminder naming the tool, the run length, and the canonical arguments (consecutive_calls: N, arguments), preview-truncated to argumentsPreviewChars (default 500) so a large payload never rides into the next request unbounded.

The reminder rides additionalContexts on the post-execute decision — so a blocked call still gets the nudge — and is stamped source: { kind: 'plugin', plugin: 'repeat-tool-reminder' } (the label is load-bearing: an unlabeled context would render as a user prompt in derived history). It is observe-and-enrich, never veto: state always advances (observe runs before next()).

A user interjection resets the chain: an agent/pre-step listener deletes the agent's chain when the incoming messages contain a user-sourced message (repetition across an interjection is not a loop).

Config

thresholds (default [3, 5, 8]), include/exclude (*-wildcard predicates over tool names at call time — a pattern matching no currently registered tool is valid, e.g. exclude: [mcp_*] stays legal without MCP tools), argumentsPreviewChars (default 500). Misconfiguration fails loud at load (empty list, non-integer, < 2, duplicates, or non-integer preview chars all throw).

Invariants as the safety net

packages/runtime-diagnostics/invariants/src/index.ts (@deepseek-ai/dsh-invariants) is a configurable registry for package-owned runtime invariant contributions. Every workspace package registers checks from a ./invariant companion (for example, credentials uses it to enforce that credentials/updated only fires while a live credentials service is mounted, flagging provider leaks). Its plugin config selects contribution packages:

ts
export interface Config {
  enabled?: boolean            // default true
  package_allowlist?: string[] // regex sources admitting package names; empty admits all
  package_blocklist?: string[] // exclude after allowlist matching
}

A violated invariant throws a package-attributed InvariantError (code: 'INVARIANT') with the owning package name. As a guard, it is a safety net rather than a loop-timer: it makes runtime contract violations loud and attributable instead of silently corrupting state.

Reference: the official pattern doc

The repo's docs/defensive-patterns.md frames the broader defensive posture these guards belong to. Its emphasis is on cause classification and interval ownership: never treat a status event or an idle signal as the result of one operation when many follow-ups share an interval, and never wait on a transition that cannot occur. The guards here apply that discipline concretely — timeout classification (TOOL_TIMEOUT vs upstream cancel) and repeat detection (canonical identity) are both about attributing a loop to its true cause.

Further reading

  • Permissions & approval — where the guards sit in the pre-execute/execute/post-execute order.
  • Sandbox architecture: overview — the escalation vocabulary that a timeout-retry plugin can route on via TOOL_TIMEOUT.
  • packages/util/timeout/src/index.tsdeadline, idleWatchdog, clampTimeout, and TimeoutReason.
  • packages/guard/timeout-policy/src/index.ts — the tools/execute wrapper and TOOL_TIMEOUT.
  • packages/guard/repeat-tool-reminder/src/index.ts — the chain detector and its two reminder tiers.
  • packages/runtime-diagnostics/invariants/src/index.ts — the package-attributed invariant registry.