Models & Providers
dsh-plugin-auto-retry
aqiu817/dsh-plugin-auto-retry · ★ 1
DeepSeek Harness plugin that takes over a model-request failure the built-in retry executor declined, continuing its backoff curve, and surfaces the full failure detail the transcript discards.
Manifest details
安装
dsh plugin --profile web add 'dsh-plugin-auto-retry@0.1.0'从 npm 安装预构建包
For install and environment setup, plus plugin development tutorials, see DSH community docs ↗
README
Table of contents
- Contents
- What it solves
- How it relates to the shipped executor
- What is never retried today
- Install
- Configuration
- How it works
- 1. The attachment point
- 2. Backoff continues the official curve
- 3. Counting reuses the official projection
- 4. Durable before the wait
- Why it cannot conflict
- The browser half
- The configuration page
- Safety
- Tests
- Known limitations
- Troubleshooting
- Development
- License
The failures the harness's own retry executor hands back, we pick up.
简体中文 | English
A host-side DeepSeek Harness plugin. It attaches downstream of the shipped
@deepseek-ai/dsh-llm-retry and takes over exactly the model-request failures
that executor declines, continuing its backoff curve for a few more attempts.
It also adds a collapsed panel at the Turn tail that gives back the full failure
detail the native transcript discards or replaces.
No injected fake user message, and no official plugin is replaced or modified.
Contents
- What it solves
- How it relates to the shipped executor
- What is never retried today
- Install
- Configuration
- How it works
- Why it cannot conflict
- The browser half
- Safety
- Tests
- Known limitations
- Troubleshooting
- Development
- License
What it solves
dsh ships a retry executor. @deepseek-ai/dsh-base mounts it:
- id: llm-retry
name: '@deepseek-ai/dsh-llm-retry'
It works the agent/request-error waterfall, rerunning a failed model request
according to the provider route's retryPolicy. But it only works through a
narrow gate:
- only a fixed set of failure codes,
- a bounded budget (five attempts in the default
normalmode), - a set of defaults when an adapter declares no policy.
So a large class of failures is never retried at all. This plugin covers that class.
It does not replace the executor. It attaches downstream of it — official calls
next() whenever it declines, and a waterfall runs outermost-first in
registration order while dsh-base loads before any profile bundle. Official is
therefore always outermost and this plugin always downstream, so every failure
official hands back arrives here, and nothing it already handled ever does.
How it relates to the shipped executor
model request fails
│
├─ dsh-llm-retry (outermost, mounted by dsh-base)
│ ├─ retryable and budget left → backoff() → { kind: 'retry' } ← we never see it
│ └─ otherwise → next() ─────────────────────────────────┐
│ │
└─ auto-retry (downstream, this plugin) ←──────────────────────┘
├─ hard-denied code → leave it terminal
├─ not allowlisted → leave it terminal
├─ budget exhausted → leave it terminal
└─ otherwise: record durably → cancellable wait → mark started → { kind: 'retry' }
The shipped default normal policy (dsh-llm/lib/types/retry-policy.js):
retryableCodes: EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT
maxRetries: 5
backoff: 500ms → 10000ms, jitter 0.1
What is never retried today
| # | Gap | Why official skips it | This plugin |
|---|---|---|---|
| G1 | code not in retryableCodes |
explicit set membership | ✅ takes over |
| G2 | budget exhausted | bounded budget | ✅ takes over |
| G3 | retryPolicy === undefined (failure before adapter resolution) |
no policy | ✅ (allowlist only) |
| G4 | UNKNOWN — non-HarnessError through the adapter boundary |
not in the set | ⚠️ opt-in |
| G5 | tool / middleware / extension failures | never reaches agent/request-error |
❌ out of scope |
| G6 | direct ctx.llm.stream() consumers |
no agent turn | ❌ out of scope |
| G7 | aborted |
user cancellation | ❌ never |
| G8 | CONTEXT_WINDOW_EXCEEDED / IMAGE_OFFLOAD_REQUIRED |
owned by compaction / image offload | ❌ never |
Concretely, on the adapters in this profile:
| Adapter | Codes it produces that are never retried |
|---|---|
@deepseek-ai/dsh-llm-pi-ai |
PI_AI_ERROR (its catch-all), QUOTA, INVALID_REQUEST, AUTH, UNKNOWN |
@deepseek-ai/dsh-plugin-kilo-gateway |
PROVIDER_ERROR, INVALID_MODEL, INVALID_REQUEST, QUOTA, AUTH |
Two comments already in the tree say the same thing:
dsh-plugin-kilo-gateway/lib/index.js:229: "Classifying everything as one code (as this adapter used to) silently disabled retrying entirely, because the default retryable set does not contain that code."dsh-llm-agentrouter/lib/index.js:144: "INVALID_REQUESTis absent from the harness's default retryable set, so without this a 400 ends the turn."
Install
A standard dsh bundle. The sanctioned path is plugin_manager with
install_bundle, which writes the profile dependency and patch for you:
plugin_manager(action: "install_bundle", target: "dsh-plugin-auto-retry")
Manually, add the dependency and the bundles row to the profile's
package.json (~/.dsh/profiles/<name>/package.json):
{
"dependencies": {
"dsh-plugin-auto-retry": "^0.1.0"
},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-plugin-auto-retry"
]
}
}
}
While working on the sources, point the version at the checkout instead, so no
npm install is needed after each edit:
"dsh-plugin-auto-retry": "link:/absolute/path/to/dsh-plugin-auto-retry"
cordis.patch.yml is attached through dsh.bundle.patch automatically; the
- insert: row does not need to be written by hand.
Run npm test once after installing: contract.test.mjs checks that every file
package.json lists, and every dependency directory, is really readable, and
re-imports the graph 25 times.
This is not a
requiredbundle, so a composition failure writes nothing to the startup log. Confirmauto-retryappears on the Plugins page.
Configuration
All six fields are .volatile(), so editing them on the Plugins page does not
reload the fiber — and enabled: false stops a running chain mid-flight.
Where to edit: Sidebar → Plugins → the dsh-plugin-auto-retry bundle →
open it, and the form is on that page. It is contributed by this plugin's browser
half through the plugins.bundle.config seat, keyed by the package name.
This is easy to miss: a Host
Configschema alone puts no control on the Plugins page. A configuration area appears only while a plugin claims a seat — bundle-levelplugins.bundle.config(key = package name) or row-levelplugins.row.config(key =<package name>#<row id>, which needs another click into that row). This plugin claims the bundle-level seat: it declares exactly one row, so the two would carry identical content, and the bundle seat is one click shallower. A host-only plugin (for exampledsh-plugin-continue-on-limit) therefore shows no configuration there — itsConfigstill applies, it simply has no editor.The bundle-level seat does not hand over a form object (the row-level one does), so the browser half sources one from the
configFormsservice for namespaceauto-retry. That service belongs to the settings package, so it is not declared in the browser half'sinject; this half asks for it through its own fiber instead, so a deployment that left settings out loses the configuration page and nothing else — the retry panel keeps working.The namespace
auto-retryhas to match the row id incordis.patch.yml; a drift leaves the page permanently empty (a contract test cross-checks the two).
Edits stay staged until Save, which submits only the fields that changed, all under one revision fence, so a concurrent write is refused rather than silently overwritten. Leaving the page discards staged edits. A numeric field that is out of bounds or unparseable blocks the save and names the field rather than writing a value the Host would reject.
| Field | Type | Default | Bounds | Meaning |
|---|---|---|---|---|
enabled |
boolean | true |
— | master switch |
extraRetries |
integer | 3 |
0–10 | attempts on top of official's budget; 0 keeps it inert |
initialDelayMs |
integer | 1000 |
0–600000 | first delay when official never tried |
maxDelayMs |
integer | 60000 |
0–600000 | outer ceiling, also clamps a provider delay |
jitterRatio |
number | 0.2 |
0–1 | symmetric jitter |
retryableCodes |
string[] | see below | at least 1 | codes we are willing to take over |
retryableCodes is edited as switches, not typed: nothing exposes the
universe of codes an adapter may return, so the page ships a catalog in three
groups.
| Group | Rendered as | Codes |
|---|---|---|
| On by default | switches | EMPTY_RESPONSE RATE_LIMIT SERVER TIMEOUT TRANSPORT PROVIDER_ERROR PI_AI_ERROR |
| Opt in | switches | QUOTA ACCOUNT_QUOTA INVALID_REQUEST UNKNOWN |
| Never retried | switches, disabled | AUTH MISSING_CREDENTIAL INVALID_CREDENTIAL NO_ADAPTER INVALID_MODEL INVALID_CONFIG CONTEXT_WINDOW_EXCEEDED IMAGE_OFFLOAD_REQUIRED ABORTED |
The third group is still listed, with its switches disabled: that is how the page says both which codes exist and that these cannot be turned on — the hard floor is a policy constant, not a preference. Switch state is seeded from the accepted value rather than from the catalog default, and a code outside the catalog (one an adapter invented) lives in the "other codes" textarea, one per line.
Leaving no code at all is not allowed. The page says so while the change is
being staged and refuses the save, because of what the Host does: codesWithin()
reads an empty list as a typo and falls back to the default allowlist —
otherwise a mistyped configuration would quietly switch the plugin off while the
settings page still showed it on. Saving one would leave the page saying "no codes"
while seven were still taken over. Pick a code, or use enabled: false /
extraRetries: 0 to mean "take over nothing" — those two are unambiguous and
cannot disagree with the page.
Hand-editing
cordis.patch.ymltoretryableCodes: []falls back the same way. A configuration the page can express should not take effect outside it.
Default allowlist:
EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, ← official's own five (taking over = extending)
PROVIDER_ERROR, PI_AI_ERROR ← the two adapter catch-alls
Hard floor (code constants, not configurable, evaluated before the allowlist):
AUTH, MISSING_CREDENTIAL, INVALID_CREDENTIAL, NO_ADAPTER,
INVALID_MODEL, INVALID_CONFIG,
CONTEXT_WINDOW_EXCEEDED, IMAGE_OFFLOAD_REQUIRED, ABORTED
The hard floor wins over the allowlist: a typo must not be able to authorize a retry of an authentication failure or a context overflow.
QUOTA, ACCOUNT_QUOTA, INVALID_REQUEST, and UNKNOWN are neither defaulted
nor denied — each is as likely to be permanent as transient, so the deployment
decides by adding it to retryableCodes. That keeps the harness's own rule
(rout on code, never parse message) while leaving room for the relay quota pools
and channel-dialect 400s that are real in practice.
How it works
1. The attachment point
ctx.on('agent/request-error', async (payload, next) => {
const terminal = await next(); // downstream/terminal speaks first
if (terminal !== void 0) return terminal;
if (lifetime.signal.aborted || payload.signal.aborted) return terminal;
// …eligibility → counting → durable record → cancellable wait → { kind: 'retry' }
});
Official does not call next() when it retries, so a failure it handled is
physically unreachable here — double-retrying is impossible by construction.
The failure happens inside the still-open step, before any tool result is
synthesized. That keeps the request reconstructible from the session log, avoids
re-running side-effectful tools whose outcome the loop deliberately left unknown,
honours the same signal, injects no fake user message, and never appends from
inside a session/event callback.
2. Backoff continues the official curve
Read official's progress for this step and provider from the shared projection, then:
if (official <= 0) return localDelay(ourRetry, settings, random); // official never tried → 1s
const curve = { // official tried → continue
initialDelayMs: officialPolicy.initialDelayMs,
maxDelayMs: Math.min(officialPolicy.maxDelayMs, settings.maxDelayMs),
jitterRatio: officialPolicy.jitterRatio,
};
return localDelay(official + ourRetry, curve, random); // one step past its longest
| Case | Official attempts | Our 1st / 2nd / 3rd delay |
|---|---|---|
PI_AI_ERROR (official never touches it) |
0 | 1s / 2s / 4s |
RATE_LIMIT, budget exhausted |
5 (base 500, cap 10000) | 10s / 10s / 10s (saturated) |
Restarting at one second would hammer a provider that had just asked us to slow down, which is why the curve is continued rather than restarted.
A valid providerRetryAfterMs wins, clamped to maxDelayMs.
One deliberate divergence: official's
normalmode gives up entirely whenproviderRetryAfterMs > maxDelayMs. This plugin clamps instead and keeps the attempt — the provider was telling us when to come back, not to stop.
3. Counting reuses the official projection
const key = policyKey(settings); // '["auto-retry",…]'
const entry = state?.[JSON.stringify([provider, key])];
const ourRetry = (entry?.retry ?? 0) + 1;
const retryId = entry?.retryId ?? randomUUID();
Official's projection resets on step/start and turn/end — exactly the
per-step scope this needs — and folds from the log, so it survives a restart.
4. Durable before the wait
llm/retry is appended before sleeping and llm/retry-started before answering,
reusing the official vocabulary so the shipped projection (and the invariant
companion, when mounted) understands them. A rejected append abandons the retry
rather than proceeding without a record.
Why it cannot conflict
| # | Guarantee | Basis |
|---|---|---|
| 1 | Different granularity | official never calls next() when it retries |
| 2 | Namespaced policy key — ours is JSON.stringify(['auto-retry', …]), official's is JSON.stringify([mode, …]) |
first element differs, so the shared projection keeps two separate chains |
| 3 | Extend, never replace | one extra listener; no official config is read or written |
| 4 | Separate budget | counted under our own key |
| 5 | Same cancellation semantics | AbortSignal.any([payload.signal, lifetime.signal]) |
| 6 | Durable before wait | known event types |
| 7 | Satisfies every invariant rule | retry positive and ≤ maxRetries, inside an open turn and step, provider match, retryId continuity |
| 8 | always mode is unaffected |
official asks downstream first via settleDownstream |
| 9 | Transcript untouched | no followup(); zero overlap with dsh-plugin-continue-on-limit |
| 10 | Cancellation is never retried | ABORTED is in the hard floor |
The browser half
The native transcript already renders llm/retry as a collapsed <details>
(ModelRetryItem), so our retries appear automatically.
What the native surfaces lose is the content:
| Surface | Today |
|---|---|
failureMessage() in dsh-client-ui-chat |
for AUTH, QUOTA, ACCOUNT_QUOTA, and the account codes it replaces the raw text with a short localized phrase |
TurnErrorItem |
renders inline and cannot be collapsed, so a long errorChain takes over |
| both | expose no status, requestId, provider route, turn/step, or wait time |
This plugin adds a collapsed panel at the Turn tail
(conversation.chat.turnTail), rendered only for a Turn that retried or failed:
- one-line summary:
Retried 3 time(s) this turn · finally failed with RATE_LIMIT - on expansion, every attempt with its origin (plugin / harness),
attempt N/M, code, HTTP status, provider, turn·step, wait, request id, and the raw message verbatim
Styles use theme tokens only and are removed with the fiber.
The configuration page
The same browser half claims plugins.bundle.config (key
dsh-plugin-auto-retry), the configuration area on the bundle's page: four numeric
inputs, one master switch, and one group of code switches. It carries no validation logic of its own —
the bounds only keep the inputs honest; readSettings() and the schema remain the
authority. Save submits only the fields that changed, all under the revision the
Host reported, so a concurrent write is refused rather than silently overwritten.
The switches are order-insensitive: they always submit in catalog order, so reordering alone is not an edit. A hard-floor code that is already in the configuration renders checked and disabled — the page reports the configuration rather than quietly rewriting it, since a list the reader never touched must not produce a write.
The bundle-level seat is resident: while the bundle is present the area is there,
whether or not the Host actually serves the auto-retry namespace. So the page
distinguishes three states — reading (loading), unwritable (unavailable: the
deployment does not serve the namespace, or this page is read-only), and ready —
and the first two only state the fact rather than drawing a form, because an input
whose Save could only fail is worse than a sentence.
Safety
Three gates: extraRetries (the budget on top of official's), the continuing
backoff, and enabled (which stops a running chain immediately).
Every setting is type-checked and clamped on read, because a .volatile() field
is written straight into its reference cell and never re-validated. Negative
numbers fall back to the default rather than to 0 — 0 is the power to switch
a gate off, and a mistyped negative must not have it.
Array.isArray in the allowlist check is load-bearing, not noise: a bare string
reaches String.prototype.includes, where any substring matches and an allowlist
typo would silently authorize codes nobody listed.
Tests
npm test
Five layers: pure policy, host wiring, browser fold and field map, the load
contract (the loader's own jc() reproduced, plus every manifest path checked),
and one registration against the real SlotCore shipped in the application —
which asserts the registry accepts this plugin's options and builds the entry
shape the renderer reads. That last file reads app.asar from the install
directory, so it skips elsewhere (DSH_APP_ASAR points it at another install).
"A mock accepts it" and "the registry accepts it" are different questions: a shape that only the mock accepts surfaces in a browser as a configuration area that never appears at all.
npm testisnode --test "test/**/*.test.mjs"— the same command CI andprepublishOnlyrun.--test-isolation=none(npm run test:in-process) is an escape hatch for a sandbox that forbids child processes:node --testspawns one per test file by default, which the DSH file sandbox does not allow. That flag needs Node 24+, so it is not the default — a package declaringengines: >=22would otherwise die withbad optionon the version it claims to support.
Known limitations
- Text an adapter already truncated cannot be recovered.
kilo-gatewaydoesdetail.slice(0, 300). The panel guarantees everything the host recorded is shown in full — not that the provider's original response is. - Tool, middleware, and extension failures are out of scope. They never reach
agent/request-error, and the harness deliberately does not auto-retry operations whose outcome is unknown; by thenTOOL_OUTCOME_UNKNOWNresults exist, so a turn-level retry risks repeating side effects. - Direct
ctx.llm.stream()consumers are out of scope — no agent turn means no boundary to attach to. - Subagents are not distinguished. Agent-scoped dispatch covers every agent, matching official behaviour.
QUOTA,INVALID_REQUEST, andUNKNOWNare off by default. They need a deployment's explicit opt-in.
Troubleshooting
Nothing happens after installing. Not a required bundle, so nothing is
logged on a composition failure. Check the Plugins page for auto-retry, then
look for auto-retry: lines in ctx.logger.
It failed but was not retried. Check enabled; whether extraRetries is
spent (a warn is logged); whether the code is allowlisted (UNKNOWN, QUOTA,
and INVALID_REQUEST are not by default); and whether it is AUTH,
CONTEXT_WINDOW_EXCEEDED, or ABORTED, which are never retried.
Official retried, but the plugin did nothing. That is correct — official does
not call next() when it retries, which is exactly how the two avoid conflict.
The panel does not appear. It renders only for a Turn that retried or failed.
Also confirm the browser half is registered: dsh.client.platform is web and
@deepseek-ai/dsh-client-ui-conversation is installed.
Development
npm install --ignore-scripts
npm test
lib/policy.js pure decisions, zero framework imports
lib/index.js host half: waterfall listener, durable events, wait
client.js browser half: event fold, tail panel, styles, dictionaries
cordis.patch.yml composition
client.js is a browser-format artifact: it registers a lazy factory through
window.__ModuleLoader__.load instead of exporting. test/client.test.mjs stubs
that global to drive it.
License
MIT © 2026 Rina