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.
| Package | Role |
|---|---|
packages/guard/timeout-policy | Cooperative tool-call timeout enforcement (TOOL_TIMEOUT) |
packages/guard/repeat-tool-reminder | Advisory repeated-call detector |
packages/util/timeout | Shared deadline, idleWatchdog, clampTimeout, TimeoutReason |
packages/runtime-diagnostics/invariants | Configurable 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/askgates (permission, sandbox).- monotonic guards — deny or abstain; identity-protected.
tools/execute— around-dispatch wrappers: timeout, retry, metrics. This is wheretimeout-policyarms its deadline.tools/post-execute— accept, block, replace, or add context. This is whererepeat-tool-reminderinjects 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.
| Utility | Signature | Purpose |
|---|---|---|
deadline(upstream, timeoutMs, code) | Deadline | Fuse upstream cancellation with an identifiable timeout; timeoutMs <= 0 means "no timeout". Returns { signal, [Symbol.dispose]() } (dispose-once timer cleanup) |
idleWatchdog(upstream, timeoutMs, code) | IdleWatchdog | Rearmable timeout around a single outstanding async-iterator demand; pulse() re-arms |
clampTimeout(requested, def, max, name?) | number | Validate a caller hint, apply the backend default, cap at max |
timeoutOf(x, code?) | TimeoutReason | undefined | Recover a TimeoutReason from a signal/reason carrier; scoped by code |
MAX_TIMER_DELAY_MS | 2_147_483_647 | Largest 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.
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):
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
timeoutOftoTOOL_TIMEOUTkeeps a nested outer deadline (anothertools/executewrapper'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
isErrorToolExecutionResultwitherror.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 chaincountresets to1when 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 toargumentsPreviewChars(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:
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.ts—deadline,idleWatchdog,clampTimeout, andTimeoutReason.packages/guard/timeout-policy/src/index.ts— thetools/executewrapper andTOOL_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.