dsh-minimal-router · v1.0.0
Give the model only the tools it needs right now. Fixed prefix, variable tail. Measured in a fresh session: 165 tools → 66 tools, 131,694 → 43,238 bytes (−67.2%), promptTokens 47,885 → 25,688 (−46.4%) at 99.16% cache hit.
A DeepSeek Harness host plugin. No build step, no dependencies.
Install
Option 1 · Just ask the agent (easiest)
Open DSH and tell the AI:
Install this plugin for me: https://github.com/qwq1223211/dsh-minimal-router
Then go to Settings → Plugins and disable, then enable the "Minimal Router" row. A linked install does not hot-reload, so this step is mandatory.
Option 2 · Terminal
dsh plugin --profile desktop add github:qwq1223211/dsh-minimal-router
This simply forwards its arguments to
pnpminside the profile directory. If the plugin does not appear in the list afterwards, ask the agent to enable thedsh-minimal-routerbundle, or toggle it on under Settings → Plugins.
Option 3 · Clone and install locally (always works)
git clone https://github.com/qwq1223211/dsh-minimal-router.git
Then hand the agent the absolute path of that folder:
Install this directory as a bundle:
<absolute path>/dsh-minimal-router
No registry, no package-name resolution, nothing but the clone. If options 1 and 2 fail, use this one.
Verify
Ask the agent for route_status. A JSON payload containing stages, tagCounts, and modelGuard
means the plugin is installed and live.
Why
DSH sends every installed plugin's tool schemas on every request. With a few dozen plugins that's 100K+ characters of tool descriptions the model re-reads even when it is just chatting.
This plugin groups tools by name, decides which group the current task needs, and (optionally) physically removes the rest from the request — then puts the routing hint on the tail of the context instead of the head, so the prefix stays cacheable.
Numbers (same machine, same model, measured with dsh-cache-probe)
| Metric | Before (auto) |
After (core stage) |
|---|---|---|
| Tools advertised | 165 | 66 (−60%) |
| Tool declaration bytes | 131,694 | 43,238 (−67.2%) |
| promptTokens per request (fresh session) | 47,885 | 25,688 (−46.4%) |
| Cache hit rate | 91.4% (2nd step) | 99.16% |
| One-time cost of switching stage (small session) | — | ~3,000 tokens |
| One-time cost of switching stage (500k-token session) | — | 512,000 tokens, full price ⚠️ |
Measured in a brand-new, clean session (one system prompt, no history debt) — that is the ceiling this design actually reaches.
Also measured, and easy to miss: a long-lived session had 9 copies of the system prompt appended (159,334 chars) versus 17,685 in a fresh session. Starting a fresh session saved more than this plugin does. Use both.
Honest caveat: most of the saved tokens were already cache hits (cheap). The bill moves less than the byte count suggests. The bigger win is the model no longer staring at 165 tools while choosing one.
Install
# in a DSH session, ask the agent to run:
plugin_manager install_bundle target=C:\path\to\dsh-minimal-router
Then Settings → Plugins → disable, then enable the row (a linked install does not hot-reload).
Tools you get
| Tool | Purpose |
|---|---|
route_stage(stage, force?) |
Switch stage |
route_status() |
Current stage, tag census, guard state, unclaimed groups |
route_reset() |
Back to auto, releases every restriction |
route_init() |
Generate custom_stages.sample.json from your real tools |
route_* and guard_* are never blocked. Escape hatches are a hard rule here.
Default stages
| Stage | Allowed groups | Auto-triggered by |
|---|---|---|
auto (default) |
everything, no trim | — |
core |
base tools only | core / general |
unity-code |
unity + 13 Unity subsystems | code, script, compile, error, unity |
unity-ui |
unity, ui, uikit, uiflow, dialogue, quest | ui, layout, button, panel |
art |
art, meshy, image, asset | texture, model, sprite, mesh |
audio |
audio | sfx, music, audio |
Tags are derived from tool names (unity_object_create → unity, mcp__meshy__x → meshy). Groups no stage claims are always allowed, so a newly installed plugin is never blocked for being unclassified.
This table looks Unity/Meshy-heavy because that is what the author works on — not because the plugin only supports game development. Swapping it for Python, data analysis, writing, or video editing takes about a minute; see the next section.
Bring your own domain
# ask the agent:
route_init
It writes custom_stages.sample.json containing:
discovered— every tag in your environment, with counts and whether a stage claims itsuggestions— one ready-to-edit stage per unclaimed tag_readme— the 5 steps
Rename to custom_stages.json, edit, restart the plugin. Merge order is built-in → custom_stages.json → cordis.patch.yml; a malformed file is ignored with a warning, never fatal.
Why not fully automatic?
Because "never block the wrong tool" outranks "save the most tokens".
For any tool group that no stage claims, the only safe default is to allow it. Guessing what your brand-new tool does would sometimes guess wrong — and a wrong guess blocks you in the middle of a task, which is far worse than not saving a few thousand tokens.
route_init is the compromise: one scan of your tools, one template, about a minute of editing, then routing is automatic forever. It lists every unclaimed group with its tool count, so the decision you are confirming is a small, concrete one.
One line: it is not lazy. Blind guessing is just more expensive than one minute of confirmation.
Hard rules (not configurable)
- Dead-loop lint — before a write-like tool runs, source is scanned for
while(true)/for(;;)/do{}while(true)without abreak/return/throwin the body. Comments and string literals are stripped first, sogrep "while(true)"is not a false positive. - Out-of-stage calls are denied with an imperative message naming the stage to switch to.
- Escape hatches stay open, always.
Degradation guard (the important one)
The savings depend on the model supporting in-history system prompt updates. DSH's DeepSeek adapter declares this per model:
{ id: "deepseek-flash", systemPromptUpdate: "in-history", toolUpdate: "addition-only" }
{ id: "deepseek-v4-pro" /* neither — equivalent to leading-only */ }
On a model without it, every tail change rewrites the prefix and every step becomes a full cache miss. So:
| in-history | not supported / unknown | |
|---|---|---|
| Tail hint | on | off (empty string) |
| Physical trim | on | off |
| Out-of-stage deny | on | on (touches no prompt) |
| Dead-loop lint | on | on |
A boxed warning is printed once per model. Unknown models degrade by default (fail-safe).
Custom / proxied models that you have verified:
trustedModels: ['my-flash'] # precise
unsafeForce: true # blanket, at your own risk
Demo it without paying for it: simulateModel: deepseek-v4-pro.
Safety gate for huge contexts
Switching stage in a very long session costs a full prefix rebuild. When the last request exceeded contextGuardThreshold (default 100000 tokens), route_stage is refused with an explanation, and requires force: true to proceed.
Configuration
- insert:
- id: minimal-router
name: '@local/dsh-minimal-router'
config:
defaultStage: auto
enforceStage: true
enforceByRestrict: true # physically trim tools
requireInHistoryUpdates: true # keep true when publishing
contextGuardThreshold: 100000
Tests
node test/router.test.mjs # 102 tests
Companion plugins: dsh-cache-probe (measures cacheReadTokens / prefix fingerprint) and dsh-minimal-guard (consecutive-error circuit breaker).
License
MIT.
No comments yet. Be the first to write one.