# Tool Drawer · dsh-tool-drawer [中文](README.md) | **English** > You installed a pile of plugins; the tool list keeps growing, and every request pays tokens for > tools this task will never touch. **Tool Drawer** keeps heavy groups collapsed, leaves the ones you > actually use on the bench, lets stale ones sink on their own — and brings a tool back with one call, > a guard that catches a real call and reopens it, or an explicit `lazy_tools_enable`. **Status:** `0.17.x` — used daily on one DSH installation; the config surface may still change. Tested hosts: **DSH 2.0.20-next** and **2.0.21-next** (2.0.21 replaced `pwsh` with `ops_*`; the plugin does not branch on versions — the protected list is the union of both generations, and names a host does not have are no-ops). Issues and PRs are welcome, but maintenance is **best-effort**. If DSH ships native on-demand tool loading, this project should retire (see *Exit criteria*). --- ## Works out of the box The defaults are the whole feature set. **No settings page, no config file, nothing to write:** - Four groups ship ready (`cua-screen` / `cua-input` / `browser-heavy` / `vision-heavy`), and they collapse by recent use; - A collapsed tool **comes back the moment you call it** (a guard catches the call and reopens it) — you never need to know which group it belongs to; - When a capability is needed the model calls `lazy_tools_enable`; a guard also reopens the **single called tool** (not the whole group) before rejecting it; - To look around: `lazy_tools_status`; to open everything for this session: `lazy_tools_mode { idleDays: 0 }`. Install it, keep working. ## If you want to tune it Lightest first — all four places are plain text: | What you want | Where to change it | |---|---| | Level / idle days / dock visibility / classification nudge | the settings page, or `~/.dsh/lazy-tools.policy.json` | | Groups for tools with a shared prefix (`dsh_browser_*` …) | `groups` in the package `cordis.patch.yml`, or config `extraGroups` (**appends**, never replaces the defaults) | | Tools with no shared prefix (`click`, `type_text` …) | `~/.dsh/lazy-tools.groups.json`; or let the agent call `lazy_tools_save_groups` (no `groups` = classification material, with `groups` = write) | | Read-only inspection | `lazy_tools_status` / `lazy_tools_list` / `lazy_tools_health` | | `lazy_tools_list { ungrouped: true }` | Lists ungrouped tools with descriptions plus name-prefix buckets (absorbs the old `scan` / `classify`) | | `lazy_tools_save_groups` | Writes the chosen groups to `~/.dsh/lazy-tools.groups.json`; takes effect next session unless `applyNow: true` | Match patterns accept `*` globs and `re:` regexes; each group may carry `keep` (never collapse) and `expect` (lower bound on member count). **Three red lines** (this project holds itself to them too): do **not** change the tool table mid-session by default (exceptions: explicit `enable`/`disable`/`mode`/`save_groups { applyNow: true }`, and self-heal — all logged) · recovery tools never go into a group · failures must be reported. Details in [DESIGN.md](DESIGN.md). --- ## How much it saves **Measured** (2026-10-28, 168 registered tools on one machine): ``` all tool definitions 178,702 chars # visible-in-registry + collapsed (this session's scope) collapsed (48 tools) 94,792 chars (53%) visible in registry (101) 83,910 chars not mounted (19 tools) 23,637 chars # never entered context — not counted as savings ``` > 19 tools here (`cua_driver_native__*`) are never mounted into the session by the host, so they > never cost a token; they are listed separately instead of being folded into "how much it saves". > On a machine where every tool mounts, that row is 0 and the figures become > "collapsed 67 / 118,429 chars / 58%". > Another 34 tools live **outside the tool registry**: 33 host core tools (`read` / `write` / `pwsh` / > `glob` / `web_fetch`, …) plus 1 agent-scoped `ops_discover_tools`. They are outside this table and are > never collapsed. Convert to tokens with your own provider's tokenizer — only character counts are reported here, because description density differs too much between languages for a fixed ratio to be honest. Reproduce it anytime with `lazy_tools_health { bench: true }`; every number in this README comes from that command. > **Cache accounting:** the numbers above are **characters of tool definitions**, not a matching discount on your bill. In steady state tool schemas are served from the prefix cache, which usually costs a fraction of a miss (the exact ratio is provider-specific — this project does not invent one). Changing the tool table **mid-session** invalidates that cache once; this plugin only does so when the user or the model needs a tool *right now* (self-heal, or explicit enable/disable/mode/save_groups { applyNow: true }). ## Why Every tool schema is tokens in *every* request. A desktop-automation plugin can carry 50+ tools, a browser plugin 30+, a vision plugin a dozen — hundreds of definitions the model reads on each turn, even when the task never needs them. The numbers are measured, not estimated — see the table above, which is the **only** place these figures live. It also explains the one caveat: 19 tools this host never mounts are counted separately, and a machine where everything mounts collapses 67. **These are cold-start / not-yet-warmed numbers.** Over time, groups that get used stay open by design, so the collapsed count drifts down — that is the heat model working, not a doctored figure. Re-run `lazy_tools_health { bench: true }` to reproduce. ## How it differs from a proxy or an embedding router | Approach | What it costs | |---|---| | MCP proxy (`search_tools` + `execute_tool`) | every real call goes through a generic wrapper — you lose the native schema validation of arguments | | Embedding router (intent → tool) | ~90 MB encoder, one retrieval per turn, and a mis-route silently hides the tool you needed | | **This plugin** | one `restrict({deny})` call on the agent's own scope — no process, no extra hop, no model, no third-party package (the only import is the host's own `@deepseek-ai/schemastery`, used to render the settings form, resolved at runtime and optional) | The trick is simply **gating at the registry-scope layer instead of at the transport or serialization layer**. If your host exposes a per-agent "hide these global tools" primitive, use it. Details and trade-offs: [DESIGN.md](DESIGN.md). ## Install This package is a **bundle** (host half + a `settings.section` client panel). Three ways to install, pick one — and do not hand-edit `cordis.patch.yml`: **Straight from GitHub (the command the plugin directory shows):** ``` dsh plugin --profile web add github:kmyy350234-dz/dsh-tool-drawer ``` **Or use the prebuilt tarball attached to the release (no npm account needed):** ``` dsh plugin --profile web add https://github.com/kmyy350234-dz/dsh-tool-drawer/releases/latest/download/dsh-tool-drawer.tgz ``` **Or clone it anywhere and install with the plugin manager:** ``` plugin_manager install_bundle target: /absolute/path/to/dsh-tool-drawer ``` That links the checkout into the profile, selects the bundle, and starts the client half. The bundle patch already ships four groups (`cua-screen` 19, `cua-input` 37, `browser-heavy` 28, `vision-heavy` 8); only the latter two carry `keep` lists. Edit `cordis.patch.yml` in the checkout (or override the row in the profile patch) to change members. Config changes apply on reload; **host code changes need a process restart**. The settings page is drawn by the client half and talks to the host over a loopback route. ## Auto groups The more plugins you install, the more unmanaged tools appear and keep paying tokens every request. `autoGroups` recognises tool families by **name prefix** and groups them for you: The directory listing notes that the shipped groups only cover `cua_driver_native` / `dsh_browser` / `vision`, and that every other family has to be added by hand. **`autoGroups` closes exactly that gap**: the remaining families are recognised and grouped for you — drop back to the config only when you want to change the pattern or add a `keep` list. | Mode | Behaviour | |---|---| | `off` | Do nothing | | `suggest` | Only report suggestions under `autoGroups.suggestions` in `lazy_tools_list { ungrouped: true }`; create nothing | | `on` (default) | Create the groups and collapse them by the usual rules. A collapsed tool is restored automatically the first time it is called, so a mis-grouped family costs one extra round-trip at worst | **A newly installed plugin may be auto-collapsed**: auto groups have ids starting with `auto-` (e.g. `auto-foo`); `lazy_tools_disable auto-foo` dissolves one. Core tools are never included, and a collapsed tool is restored by the guard the first time it is called. The rules are fixed in code, not guessed: - Split on `__` or `_`; **descend only when child families cover ≥80%** of the parent, otherwise keep the whole family (`mnemon`'s 17 tools are not split into two groups of 3); - A prefix needs ≥ `minTools` (default 3) tools; - Prefixes already ≥80% covered by existing groups are skipped; - **Core tools are never grouped** (`read`, `write`, `edit`, `pwsh`, `grep`, `glob`, `skill`, `todo_write`, `lazy_tools_*` …); - Computed once at session start and frozen for the session (never breaks the prefix cache); - Auto groups are prefixed `auto-`, visible in `lazy_tools_status`, and removable with `lazy_tools_disable auto-xxx`. **Built-in tools are out of scope**: `read`, `schedule`, `job` and friends are not in the plugin registry, so auto grouping never touches them. Custom match patterns or `keep` lists still go in the config — see the next section. A manual `cordis.patch.yml` row is still possible (see [profile-patch.snippet.yml](profile-patch.snippet.yml)) but it gets you only the host half — no settings page. ## Config reference (per group) | Field | Meaning | |---|---| | `id` | group id, used by every tool call | | `match` | array of patterns. Plain strings are globs (`*` wildcard, anchored); prefix `re:` for a raw regular expression | | `keep` | tool names that must never be treated as "stray" — the ones you deliberately leave visible | | `expect` | **lower bound** on the group size; only `matched < expect` produces a `warnings[]` entry (absorbing match patterns grow, and that is fine) | | `defaultOn` | `false` = hidden unless enabled (almost always what you want) | | `title` | free-form label shown by `lazy_tools_status` | ## Managed roles (`managedRoles`, off by default) > **It only narrows.** `restrict` intersects along the scope chain, so a managed role can never *add* tools the > orchestrator did not grant — it only removes tools a role session should not see. Set `managedRoles` in `~/.dsh/lazy-tools.policy.json` (**JSON**, same file as the saved policy), or in the plugin config (profile YAML); the policy file wins when both exist. ```json { "managedRoles": { "dsh-superagent-worker": { "allowGroups": ["cua-screen", "browser-heavy"] }, "subagent": { "allowGroups": ["*"] } } } ``` - Group ids must exist on this machine — check `lazy_tools_status` → `groups[].id`. - Unknown ids are reported in `health.errors`; a role whose ids are **all** unknown is disabled instead of locking that session down to CORE tools (managed sessions reject `enable`/`disable`, and self-heal does not bypass the policy, so a wrong config would otherwise have no escape inside the session). - `allowGroups: ["*"]` disables group filtering for that role. - Without `managedRoles` nothing changes. ### Per-subagent roles: from the session label (2026-11-03) The host's `subagent` tool takes no preset or metadata, so every subagent inherits its parent's preset — preset-based role filtering never distinguishes them (measured: two differently-labelled subagents got identical surfaces). Three signals are tried in order, and **each one must be configured in `managedRoles`**: 1. **A candidate segment of the session label** — split on `:` `/` `|` and whitespace, lowercase, then take the first segment that is a key in `managedRoles` (an exact match beats the trailing summary words). Example: `dsh-my-go:hermes: fix the parser` resolves to `hermes`. 2. The generic `subagent` key. 3. The preset id. ```json { "managedRoles": { "researcher": { "allowGroups": ["browser-heavy", "vision-heavy"] } } } ``` Only subagent sessions are affected (a label on your own session changes nothing), and an unconfigured role does nothing at all — no key, no change. The short-lived `[role:xxx]` prompt marker was **removed in 0.18.2**: writing it has no effect and records no error. ## Per-session group list (0.18) The resident prompt lists the groups the **calling session** actually has mounted, not a fixed global list: - mounted = tools visible to that session ∪ tools this plugin itself collapsed; only groups with at least one member are listed; - **if nothing is mounted the whole section disappears** (the host drops empty sections) — no more advertising a group this session cannot reach; - tools blocked by another layer (a host permission gate, another plugin's deny) are **not** advertised; - the "N tools are ungrouped, go group them" nudge only appears in default-preset human sessions; - when the calling session cannot be resolved, the 0.17.x behavior (list every group) is kept — the failure direction is "unchanged", never "shorter". Tests: smoke `[64]` (filtering / empty section / unresolved scope falls back) and `[65]` (nudge only in default sessions). ## Tools | Tool | What it does | |---|---| | `lazy_tools_status` | per group: `on`, `mode` (`hide`/`only`/`all`), `matched`, `visible`, `onlyCount`, `expect`/`expectOk`, `hot`, `lastUsed` | | `lazy_tools_enable {group \| groups, only?, name?}` | show a group; `only: ['name']` shows exactly those tools; `groups: [...]` for batches; **`name: ''` reveals just that one tool** (absorbs the old `resolve`) | | `lazy_tools_disable {group \| groups}` | hide it again (clears any `only` allow-list) | | `lazy_tools_list {group?, ungrouped?}` | tool names in a group, including hidden ones; `ungrouped: true` adds ungrouped tools with descriptions plus name-prefix buckets and `autoGroups` suggestions (absorbs the old `scan` / `classify`) | | `lazy_tools_mode {level?, idleDays?, reset?, collapse?}` | **this session only**: change the threshold and recompute which groups start open (`reset` → global policy, `collapse` → clear this conversation) | | `lazy_tools_health {tool?, layersFor?, bench?, full?}` | contract self-check; default is a **slim view** (`healthy`, per-group state, `warnings`, `stray`, `client`, `stats`); `{ full: true }` adds ledger `usage`, `contracts[].detail`, `nameProbe`, `filterReport`; `{ bench: true }` adds the schema-tax report | | `lazy_tools_save_groups {groups?, applyNow?}` | write groups to `~/.dsh/lazy-tools.groups.json` (next session unless `applyNow: true`); **without `groups`** it returns classification material instead (bare-named, ungrouped tools with descriptions) | `healthy: true` means: five contracts OK **and** no errors (host dispose races excepted — they stay as evidence) **and** no warnings. ## Settings The settings page is drawn by the client half and talks to the host half over a **loopback route** (`127.0.0.1`); it never connects to an external address. The plugin directory's capability scan labels such calls as "connects to servers" — that label refers to this local HTTP call, not to any upload. `lib/config.js` exports a `Config` schema, so DSH renders these fields in its own settings page: | Field | Default | Meaning | |---|---|---| | `level` | `medium` | collapse aggressiveness: `low` 14 d / `medium` 7 d / `high` 3 d | | `presets` | `{14,7,3}` | days per level — change them to your own pace | | `idleDays` | follows `level` | override the day threshold (`0` = never collapse by time) | | `minTools` | `3` | **auto-grouping** threshold: a prefix needs at least this many tools to become a group (floor 3 — smaller values are clamped) | | `showDock` | `false` | show the level control above the composer | | `showClear` | `false` | show the independent "clear now" button above the composer | | `classifyNudge` | `true` | when bare-named tools have no group, remind the model once in the system prompt to classify them | | `autoGroups` | `on` | automatic grouping: `off` / `suggest` / `on` | Both composer toggles are independent; the drawer control is mounted per conversation, so it appears after switching conversations (or reloading). The panel is bilingual and follows the host locale service. Group definitions stay in the bundle patch — written once, rarely touched. The schema is resolved at runtime by scanning `%USERPROFILE%\.dsh\profiles\*\` for `@deepseek-ai/schemastery` (a package DSH already ships), using Node built-ins only; if it can't be found, `Config` is `undefined` — **no settings page, plugin unaffected**. The panel writes `~/.dsh/lazy-tools.policy.json`, which overrides the YAML defaults; delete that file to fall back. ## Why it *feels* automatic (without a model in the loop) Most people install a pile of plugins and use each of them rarely — but when the moment comes, the tool has to be there. Two deterministic mechanisms cover that, and none of them guesses: 0. **Usage ledger + session-start decision** — every real call is recorded in `~/.dsh/lazy-tools.usage.json` (timestamps only). When a session starts, a group opens fully when the share of its tools used within `idleDays` reaches `warmRatio` (default 0.30); below that only those warm tools are revealed (`only`); with none warm the group starts collapsed. Group-level timestamps are display-only (`lastUsed`) and no longer drive the decision. The decision happens **once, at the session boundary**, so it never invalidates an active prompt cache. 1. **Self-heal / `lazy_tools_enable { name }`** — on an `unknown tool` error, `enable { name }` finds the owning group and reveals just that tool; a dispatch guard also tries to reveal it *before* the call is rejected, and the call proceeds immediately (only the guard bumps `stats.selfHealed`; `enable { name }` is not counted). The old `resolve` is now this entry point. No embeddings, no judge model, no proxy: the "intelligence" is just **acting on evidence the host already gives you** (a message, an error) instead of asking the model to remember. ## What it protects you from - **Stray tools** — a plugin upgrade adds `vendor__new_tool`; if no group matches it, it silently becomes visible and burns tokens. `stray` reports visible tools that look like a group's family but are matched by nothing (`keep` excluded). - **Count drift** — `expect` turns "28 hidden" into a checked assertion. - **Client half not mounted** — `health.client` records the last report from the browser side (`seen` / `at` / `agoSeconds` / which panels). A healthy host does not imply the settings page mounted: on 2026-10-24 all four host contracts were green while the client entry failed to load and the Web UI dropped into recovery mode. This field turns that class of failure into a readable number instead of something you only discover on restart. - **One bad name in a batch** — if a name in the deny list cannot be restricted, the plugin retries name by name: the rest are still hidden, and the rejected names are recorded (a single bad name no longer voids the whole batch). - **Zone races** — a sub-agent disposed mid-flight only bumps `stats.restrictSkipped` and does **not** count as a failure. - **Reload orphans** — restriction disposers are owned through `ctx.effect`, so a hot reload lifts them. Known gap: `own()` sets `done` before calling dispose, so if dispose throws, later lifts and reloads become no-ops (tracked in ticket `lift-orphan-done-before-dispose`). ## Tests ```bash node test/smoke.mjs # assertions against a fake host, no DSH required (count: use the `node test/smoke.mjs` output) ``` The fake host implements the five contracts this plugin depends on, plus the host's **optional** `tools.view(scope)` read-only view (the prompt section's main read path; its three degraded shapes — absent, empty, throwing — each have a test). A contract change breaks the test before it breaks your session. > Changing this plugin? Read [MAINTAINING.md](MAINTAINING.md) first: any client-side change requires a cold boot, and recovery mode leaves the user unable to operate the UI. ## Limitations (please read) 1. **Global tools only.** Tools registered inside an agent scope cannot be hidden — the host rejects it. 2. **Name-based grouping.** DSH's `ToolSchema` has no tags, so groups are globs/regexes over names. 3. **Toggling invalidates the prompt cache.** Enable once and leave it; prefer `only:` when you keep switching. 4. **Auto-collapse only at session start.** It never re-shuffles a running session, because that would invalidate the prompt cache mid-task. 5. **Failure mode is fail-open**: if a contract breaks, tools come back and you spend tokens — it will not lock you out. 6. **Self-heal only covers tools that exist but are hidden.** A misspelled or not-yet-loaded name cannot be repaired; use `lazy_tools_enable { name: "" }`. 7. **UI is bilingual, tool descriptions are not.** The settings panel and the composer row follow the host locale service; model-facing tool descriptions are registered once at boot and stay in Chinese, because the host process has no view of the UI language. ## Exit criteria If DSH ever ships native on-demand tool loading (it already carries `deferLoading` and tool-addition messages), this plugin should retire rather than compete. ## Prior art & acknowledgements Nothing here is a new idea. These are the projects that shaped it — I read 1–3 closely, 4–7 mostly from their READMEs. If I've mischaracterised yours, open an issue and I'll fix it. | Project | Layer | What I took / why I diverged | |---|---|---| | [tool-attention](https://github.com/asadani/tool-attention) — [arXiv:2604.21816](https://arxiv.org/abs/2604.21816) | per-turn intent routing over embeddings + two-phase schema loading | established the size of the "tools tax"; I skipped embeddings because a mis-route silently hides a needed tool and the encoder is a large dependency for a token saving | | [mcp-lazy-load](https://github.com/PeterCha90/mcp-lazy-load) | MCP proxy with `search_tools` / `execute_tool` | shows how far reduction can go; I kept real calls native instead of routing them through a generic executor | | [dsh-mcp-loader](https://github.com/MikotoMyWife/dsh-mcp-loader) | DSH plugin: per-server loaders + per-agent `restrict` masks | same mechanism layer (it also documents that `restrict` only accepts globally registered names); mine is namespace-agnostic and adds `only:` | | [lazy-mcp](https://github.com/voicetreelab/lazy-mcp) | proxy layer, tree per server | vocabulary for grouping | | [opencode-lazy-loading](https://github.com/omarwaly-ai/opencode-lazy-loading) | request-layer stripping | reference for the large-reduction claim | | [dsh-sieve](https://github.com/Sev7eEn7/dsh-sieve) | DSH context filtering with a judge model | the reason I avoided model-in-the-loop gating | | [microsoft/agent-framework — progressive disclosure](https://github.com/microsoft/agent-framework/blob/e39a8a2e/python/samples/02-agents/mcp/mcp_progressive_disclosure.py) | on-demand MCP disclosure | terminology reference | ## What this is not - **Not a proxy, gateway, or MCP client.** It does not touch transport and adds no hop. - **Not an auto-router.** It never guesses which tool you meant; you ask, explicitly. - **Not a security boundary.** A hidden tool is only hidden from the model's tool list — nothing here sandboxes or denies execution. Use the host's permission system for that. ## License MIT — see [LICENSE](LICENSE).