dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
Hard break on repeated identical tool calls in DeepSeek Harness (DSH): a synchronous monotonic ctx.tools.guard gate that denies the 2nd identical call per agent. Dependency-free.
⚠️ Supported dsh:
^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-00.8.x covers the dsh 0.1.7 line and dsh 0.2.0 (from rc.1, up to but not including 0.2.1). On dsh 0.1.5, install
dsh-repeat-tool-breaker@0.7. The same range is declared as a peer dependency on@deepseek-ai/dsh, so a resolver can see it; it is markedoptionalbecause dsh is provided by the host at runtime. Note the0.2.0-rc.1lower bound rather than0.2.0: semver admits a prerelease only when a comparator in the same set names one, so>=0.2.0would reject every 0.2.0 rc. 0.1.7 replaced the settings API, so one build cannot register a settings page on both generations — the two are served by two lines rather than by a version probe.On dsh 0.1.5, 0.8.x still detects repeats, still blocks shell HTTP and still delivers its advisories (verified on 0.1.5-rc.2: the advisory persists with this release's message source). The one thing that is lost is the settings page, because the Host no longer registers a namespace there and the card needs 0.1.7's client services. Use
@0.7on 0.1.5 if you want the settings UI.
Escalating repeat detection for an agent's tool calls. A local
DeepSeek Harness (DSH) plugin
that registers one synchronous gate on the public ctx.tools.guard API. A
measure repeated inside the agent's sliding window is not stopped at the first
threshold: it escalates — a light warning at 7, a written-summary demand at 11,
and the gate at 12 (16 for the coarser host: measure), where the operator is
offered a turn-scoped exemption
(onLimit: ask, the default) or the call is denied outright. When the gate
denies, the call never executes and the model gets an isError result starting
with REPEAT_TOOL_BLOCKED that quotes the previous result and says what to do
instead.
Since 0.5.0 a second track watches the same fingerprints for consecutive
failures rather than occurrences: an advisory at 3, and the same gate at 5.
Both tracks share one gate, one measure set and one exemption policy. Numbers in
this README belong to the occurrence track unless they are labelled failWarnAt
or failLimit.
In one paragraph, the v1 → v2 → 0.5.0 story:
v1 lost because it counted byte-identical consecutive calls while the model varied a presentation field (
description: '1st'|'2nd'|'3rd', churningtimeoutMs) and ping-ponged between host spellings (open-data.canada.ca↔open.canada.ca) with a different--max-timeeach time — every call looked new, so the counter never advanced. v2 wins by deleting decoy arguments before any fingerprint is built, then counting semantic fingerprints (exact:,cmd:,net:,host:,site:,sink:,family:,verb:) over a per-agent window of the last 16 calls, so a repeat has to change the actual resource — not its spelling — to pass. 0.4.0 adds escalation: the same measure warns, demands a summary, and only then gates — retuned in 0.4.2 to 7 / 11 / 12, withhost:at 16 — and the gate asks the operator rather than blocking silently. 0.5.0 adds the failure track: the same fingerprints are counted for consecutive failures too — advisory at 3, gate at 5 — because repeating a call that works is fixation, while repeating one that fails is not learning.
| Loop | Caught by | |
|---|---|---|
| A | the same read/write/bash arguments again, verbatim | exact: (12) |
| B | description: '1st'/'2nd'/'3rd', command unchanged | decoy arguments are stripped before fingerprinting, so the calls become byte-identical → exact: (12) |
| C | curl --max-time 60 open-data.canada.ca ↔ curl --max-time 30 open.canada.ca | net: (12) after host-alias folding, plus sink: (12) and cmd: (12) after volatile-flag stripping |
A fixed strategy is a fourth failure mode that none of those loops catches: a
high-volume but non-repetitive run against one target, where every query string
differs so net: never collides. On the reference deployment one agent made 30
bash/curl calls against a single host in one turn and never converged. The
host: measure (new in 0.4.0, cap 16) is what makes it visible; see
How a call is fingerprinted.
The failure track (0.5.0) is a different axis from all of these: it fires on a fingerprint that keeps failing, even when the number of attempts is far too low to reach the occurrence stages — a model guessing at an endpoint that does not exist, getting a 404, and rephrasing the request. See The failure track.
The sibling official plugin @deepseek-ai/dsh-repeat-tool-reminder (advisory, at
3/5/8 repeats) may stay on — the two compose, with the reminder as the soft nudge
and this breaker as the escalating gate that asks at 12.
The occurrence track counts repeats. Every measure — one fingerprint identity
such as exact:<tool>:…, net:<host><path>?<query>, or host:<host> — has the
same three-stage structure. A stage fires once per crossing, on exact
equality: the count including the current call must equal the threshold, so
the window sliding does not re-announce it. The
failure track below has its own two stages.
| Repeats | Setting | Stage | What happens |
|---|---|---|---|
| 7 | warnAt | 1 — light warning | The model is told it is repeating and should consider whether a different route would get there faster. Nothing is blocked and nothing is demanded. |
| 11 | summarizeAt | 2 — summary demand | The model must write down what it established, what it assumed without verifying, what failed and why, and at least two approaches it has not tried — plus an instruction to use a larger per-batch amount so there are fewer batches. Nothing is blocked. |
| 12 | the limits entry | 3 — the gate | onLimit: ask (default) offers the operator a turn-scoped exemption; onLimit: deny blocks outright. An unattended ask degrades to a denial. |
The occurrence cap is 12 for the action-identity measures (exact, cmd, net,
sink) and 16 for the coarser host: measure — the whole window. See
Tuning. The failure track's gate is
failLimit, a separate setting, at 5.
limits is the occurrence stage-3 threshold. There is deliberately no separate
gateAt: a second gate number would be the same value written twice, and two
knobs that must agree will eventually disagree.
Stages 1 and 2 are advisory. A guard can only return a denial, so the guard
computes the advisory while it still sees the counts and tools/post-execute
attaches it as an additionalContexts entry — the channel
@deepseek-ai/dsh-repeat-tool-reminder uses — stamped source.kind: 'plugin'.
It composes with a downstream block rather than replacing it. (An unlabeled
context would render as a user prompt in derived history, which is why the
source is mandatory.)
Each stage is an ordinary, independent setting, and no setting is validated against another:
0, a negative number, or null disables a stage silently — that is the
documented off-switch, not a value to reject;limits entry at or below a stage means "no escalation for this measure":
exact: 5 under warnAt: 7 gates at 5 with no warning at all;When several measures cross a stage on the same call, the strongest stage wins and ties go to the highest count.
The gate fires before the advisory, so a summarizeAt at or above every cap is
dead code, and no threshold above the window (16) can fire at all. Nothing
validates one setting against another — a limits entry below a stage remains a
deliberate way to skip an advisory — but the shipped defaults keep summarizeAt
strictly below every cap, which is why the stages and the caps move together. The
measurement behind the 0.4.2 numbers is in
docs/issue-b-thresholds.md.
onLimit — what happens at the gate| Value | Behaviour |
|---|---|
ask (default) | The operator is offered a turn-scoped exemption, once per measure per turn. Approving exempts exactly the fingerprints that hit, stops counting them for the rest of the turn, and lets later identical calls ride along; declining stops the asking for those measures and denies them until the next human message. |
deny | Never ask. The cap-th call is denied outright with REPEAT_TOOL_BLOCKED. |
ask is the default because it is fail-closed. Every unattended outcome of an
approval is a denial — rejected (the session policy is never), cancelled
(the turn was aborted), and unavailable, the value the registry falls back to
when no answerer is registered — so a headless profile degrades to deny on its
own and nothing stalls. Exemptions are per fingerprint: an exemption for
host:api.weather.gc.ca says nothing about exact: or about a different host.
Asking used to be local-only (localHosts: ask). Since 0.4.0 it is what
onLimit does for every measure; see Local addresses.
onLimit governs both tracks. A failure hit and a repeat hit go through the
same exemption prompt and the same refusal set, so approving exempts exactly the
fingerprints that hit whichever track flagged them. A second gate would have
needed a second ask, a second refusal set and a second way to get stuck.
New in 0.5.0. A second escalation track watches the same fingerprints for consecutive failures instead of occurrences. It exists because failure is a much stronger signal than repetition: repeating a call that works is fixation, repeating one that fails is not learning. Its thresholds therefore sit well below the occurrence ones (7 / 11 / 12).
| Consecutive failures | Setting | Stage | What happens |
|---|---|---|---|
| 3 | failWarnAt | 1 — failure advisory | The model is told the target has failed three times in a row, with the failure reason quoted, and is pointed at the error, at whether the target exists, and at the cause. Nothing is blocked. |
| 5 | failLimit | 2 — the gate | A call carrying that fingerprint is blocked, through the same onLimit policy as the occurrence gate — ask by default (fail-closed), deny for a hard break. |
The motivating case is a model guessing at an endpoint that does not exist: it keeps getting a 404 and rephrasing the request. The failure is the signal, and it is available long before repetition counting would notice.
A failure is not result.isError. That flag is true only when the call
failed — a thrown error, an unknown tool, a sandbox denial, an abort — and it is
false for the two shapes that matter most: a non-zero exit code and an HTTP
error status. Measured over 40 recorded sessions, the thrown case covered 40 of
6765 bash results (0.6%), so counting only it would make the track blind.
lib/failure.js reads the structured result.value each tool declares in its
output.schema, structured first:
exitCode (bash);statusCode >= 400 (web fetch);timedOut;signal;A status code is definitive in both directions: a 200 is a success whatever
words the page contains, so the text is never consulted for a fetch that reported
one. That matters — a fetched document mentioning "HTTP Error 400" is not a
failure.
The text fallback then covers what the structured value cannot, and it is
restricted to shell tools on purpose. A shell's exitCode: 0 proves nothing:
curl … | python3 … | head exits with head's status, and a script that catches
its own HTTP error exits 0 too. So for a shell the text is the remaining evidence:
[exit code: N], (HTTP nnn), [timed out after Nms], [killed by signal: …],
[sandbox: file access denied — the harness's own markers;Traceback (most recent call last) and a line-anchored Python exception
(SyntaxError:, urllib.error.URLError:, …) — a script that crashed while the
pipeline still exited 0;HTTP Error nnn — urllib's message, printed by a script that caught it and
carried on;curl: (n) — curl's own diagnostic.One more case needs the command, not just the output, so classifyFailure takes it:
when a shell command asked curl for the status (-w "%{http_code}" or
%{response_code}), a leading 4xx/5xx in the output is the status by construction.
That is how a model checks an endpoint by hand — curl exits 0 for a 404 unless it was
given --fail, and the output is a bare number that nothing else would recognise. The
anchor is deliberate: the same output often carries a byte count
(153226 /tmp/…) whose digits contain something like 532, and a plain wc -c
number without the write-out request is not a status.
Every other tool answers through its structured value, so its prose is never
guessed at: that is what keeps a fetched page, or a read of a Python file
containing the word ValueError:, from being read as a failure.
Two things are deliberately not failures: aborted — a cancellation is
external to the model's choice — and a background job that started
(kind: 'background', whose exit code belongs to a later call).
A call is blocked only when it carries a fingerprint that has already failed
failLimit times in a row. A call that does not carry it — reading the error
log, grepping the code, trying a different endpoint — is allowed. Blocking the
recovery action is how a guard turns a stuck model into a wedged one.
A success of the failing fingerprint clears its streak. A success of a different fingerprint does not: a model that fails a build, reads a file, and fails the build again has failed the build twice, and the read is not progress on the build.
A fingerprint whose limits cap is null is disabled for both tracks. This
is load-bearing rather than tidy: measured over the corpus, the longest failure
streaks sat on exactly those disabled measures (9 on family:http-fetch, 8 on
verb:curl, 7 on verb:export), so counting them would have reintroduced the
0.4.0 bug through a new channel. The failure track also skips exempted
fingerprints, exactly as the occurrence track does.
ctx.tools.guard is synchronous and runs before execution, while the outcome
is known only in tools/post-execute. The failure gate therefore cannot stop the
call that produces the fifth failure: after 5 failures, the 6th call carrying
that fingerprint is blocked.
REPEAT_TOOL_BLOCKED is not the model's failure. A denial this plugin issued is
excluded from the streak — otherwise the guard would feed itself: deny a call,
the streak grows, and the next call is denied one step earlier. A declined ask is
excluded too, because that call never ran.
When several fingerprints cross the failure stage together, the advisory names
the most actionable one, using the same measureRank tie-break as the occurrence
stages — a target-scoped measure such as host:, never a truncated exact:
command line.
failWarnAt follows the same on/off rule as warnAt: a positive integer enables
it, and 0, a negative number or null disables it silently. failLimit: null
disables the gate while keeping the advisory. The two are independent of each
other and of the occurrence settings; as everywhere else in this plugin, nothing
is validated against anything.
Measured support (tools/failure-run-measurement.mjs, over 107 recorded
sessions, counting only enabled measures): 3.6% of calls fail; 9 sessions reach a
3-failure streak and 5 reach 5, while zero known-good runs reach 3. The
clearest real case was a session hitting host:api.github.invalid — a reserved,
permanently nonexistent host — 8 times in a row.
^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 (session format 4), declared as an
optional peer dependency on @deepseek-ai/dsh — optional because the host provides
dsh, and a non-optional peer would make npm/pnpm install a second copy into the consumer's
tree. The dsh 0.1.7 line and dsh 0.2.0 share one build: the Host-half packages this plugin
calls (dsh-tools, dsh-web, dsh-web-fetch-http, dsh-settings,
dsh-api-settings-controller) are byte-identical across the two. The compat suite targets
dsh 0.2.x only — it passes on 0.2.0-rc.2, and 0.1.7 is historical: it was verified when the
settings split landed, run-compat.sh now refuses a non-target install unless
COMPAT_ALLOW_ANY_VERSION=1 is set, and its default prefix is named for the target
(.rtb-compat-020) so a stale 0.1.7 tree cannot be measured by mistake. 0.1.7 removed the
imperative settings API and rejects the message-source shape
that 0.1.5 requires, so one build cannot serve both generations: use 0.7.x on dsh
0.1.5.tools service. The settings page additionally needs the
web surface and a profile that mounts @deepseek-ai/dsh-client-ui-plugin-manager.@deepseek-ai/schemastery (^3.18.4) — the exported Config
schema is built with it, and 0.1.7 renders the settings form from that schema (which is why
.volatile() is required and why 3.18.2 is too old). The browser half additionally requires
react and @deepseek-ai/dsh-client-ui-primitives, both client module-table seeds the
platform provides rather than dependencies of this package.The package declares "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }, so
it is a first-class profile bundle: no hand-written mount row is needed.
dsh plugin --profile <name> add dsh-repeat-tool-breaker
Then add it to the profile's ordered bundle list
($DSH_HOME/profiles/<name>/package.json):
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-repeat-tool-breaker"
]
}
}
The bundle's patch layer mounts the plugin with no config:, so the
fail-loud DEFAULTS really are the defaults. To tune it, reconfigure the row by
id from the profile's own cordis.patch.yml — remember a patch replaces the
targeted row's whole config instead of merging into it, so restate every field
you want (see Configuration).
Naming a bundle-less package in dsh.profile.bundles is a hard boot error
(declares no dsh.bundle in its package.json), which is why the manifest above
is required for this path.
Clone this repo and add an insert entry to a profile (see
Configuration for the full snippet), then boot with the
overlay:
git clone https://github.com/snailium/dsh-repeat-tool-breaker.git
dsh --profile <name> --patch /path/to/overlay.yml --dump-config # resolve check, does not boot
dsh --profile <name> --patch /path/to/overlay.yml "reply ok" # real apply run
Here name must be an absolute path to this checkout's index.js, because
the package is not resolvable from the profile directory.
dsh plugin --profile <name> add dsh-repeat-tool-breaker
dsh plugin add forwards to the profile's package manager, so the plugin becomes
a normal profile dependency and its name resolves to the package specifier
dsh-repeat-tool-breaker from a hand-written insert row. The files/exports
entries in package.json control what ships.
Tool dispatch on the DeepSeek Harness runs:
tool/call
→ tools/pre-execute (allow / deny / ask)
→ tools/guard() ← THIS plugin's gate
→ tools/execute (the real tool body)
→ tools/post-execute
→ tools/result
Returning a string from a guard is a final, monotonic denial: it cannot be
re-allowed by listener ordering, and — critically — the tool body never runs.
That is what distinguishes the gate from the official reminder, which only injects
a softer "you repeated X" message after the call already executed. Since 0.4.0 the
breaker also has its own two advisory stages, delivered on the same
tools/post-execute channel (see The occurrence track),
and since 0.5.0 the failure advisory rides it too.
The guard is deliberately synchronous: no await, no DNS, no disk reads.
Each call contributes a set of fingerprints. Any one of them reaching its cap
denies the call, so dodging one (a new host spelling) still collides on another
(a new sink: or cmd:).
| Fingerprint | Built from | Catches |
|---|---|---|
exact:<tool>:<json> | tool name + arguments with decoy fields deleted, keys deep-sorted | A, B |
cmd:<verb>:<command> | verb + command with volatile flags (--max-time, -s, --retry, timeout N, -sSL clusters…) removed | C, B |
net:<host><path>?<query> | http(s) URL with the scheme defaulted, www. and default ports dropped, host aliases folded, the fragment discarded, a trailing slash trimmed, and the query kept (sorted, tracking parameters removed) — the query is what makes ?page=2 a different resource | C, and it must NOT fire on pagination |
host:<host> | normalized host of each URL, with no path and no query (new in 0.4.0) | a fixed strategy: many distinct requests against one target, which net: cannot see because every query string differs. Local hosts are excluded by default (includeLocal) |
site:<last-2-labels> | registrable-ish site of each URL (IP literals stand alone) — note this merges api.github.com into github.com | not capped by default: siteOf() collapses to two labels, so it merges unrelated services (api.weather.gc.ca → gc.ca); the host: measure is the discriminating one |
sink:<path> | -o/--output/-O/>/>>/tee target of a shell command — except generic destinations (/dev/null, -, …), which say nothing about which resource was fetched | C |
family:http-fetch | every curl / wget / http / httpie / URL-taking tool call | nothing by default — a volume budget no setting of which avoided false positives |
verb:<cmd> | the first non-wrapper command word (sudo, timeout 30, FOO=1 are transparent) | tool-swapping within one verb |
localhost, loopback, RFC1918 and link-local hosts are what a development loop
talks to — a dev server, a local inference endpoint, a container — and a
target-scoped fingerprint cannot tell them apart from a web crawl. localHosts
decides whether local traffic is fingerprinted at all:
| Value | Behaviour |
|---|---|
deny (default) | local calls are counted and blocked like any other host |
allow | local traffic is never fingerprinted: no net:, site:, `host: |