# dsh-lazy-tools **CodeBuddy-style deferred tool loading for DeepSeek Harness.** A `tool_search` + `defer_execute_tool` overlay that keeps tool schemas out of the model's context until they are actually needed. *Reduce token usage · shrink the context window · keep tooling discoverable* [![CI](https://github.com/studyzy/dsh-lazy-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/studyzy/dsh-lazy-tools/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node](https://img.shields.io/badge/node-%5E22.19.0%20%7C%7C%20%3E%3D24.0.0-brightgreen)](package.json) [中文](README.md)
--- ## What it does Most coding agents put the JSON Schema of *every* available tool into the prompt — even tools the model never ends up using. `dsh-lazy-tools` flips that model around: tools are **deferred** by default and only enter the context when the model asks for them. **Works out of the box, zero configuration**: the long tail is deferred while a coding core stays available (`read`, `write`, `edit`, `bash`, `glob`, `grep`, `web_search`, `web_fetch`, `ask_user_question`, `skill` — see [Defaults](#defaults)). Deferred tools are removed from the **tool list the model sees**, so their schemas (and names) never reach the model context. The model discovers them on demand through `tool_search`, then calls them directly after activation. Removal happens on the `system-prompt/assemble` waterfall — the per-scope tool list the agent loop actually sends (it becomes the request header and the provider's tool declarations). The plugin never mutates the tool registry, so it does not care which plane a tool comes from. This is a **pure exposure-control layer**: - It does **not** own or implement any tools. - Deferred tools are ordinary registry tools (host composition, agent preset, third-party/MCP tools); this plugin only observes their `name` / `description` / `parameters` in the model-facing list for discovery. - It **cannot proxy execution** — `tool_search` / `defer_execute_tool` only *activate* a tool, after which the model calls that tool directly. Because it is implemented purely at the agent layer, it works with **any model and any provider** (DeepSeek, OpenAI, Anthropic, Gemini, …) and does not depend on provider-native deferred-tool protocols such as Anthropic `tool_reference` or OpenAI's deferred-tool input items. ## Features - 🔍 **On-demand discovery** — `tool_search` looks tools up by exact name (`tool_names`) or by keyword (`queries`, Chinese and English), activating a match when found. - ⚡ **Lazy activation** — matched tools are recorded in the agent's activation set and become directly callable from the next model request. - ⚡ **Activate by name** — `defer_execute_tool` activates a known tool by its exact name. - 🧭 **Non-intrusive** — the registry is never touched: the plugin only subtracts from the model-facing tool list on `system-prompt/assemble`, so it coexists with every other `restrict`, every tool source, and every plane, and never widens another policy. - 🎛️ **Flexible configuration** — CodeBuddy-style `Defer(...)` / `NoDefer(...)` patterns with `*` wildcards and a global `deferToolLoading` switch. - 🛡️ **Self-safe guards** — `tool_search` and `defer_execute_tool` are registered on the agent's own scope and never deferred, and the reserved `run_code` transport is pinned the same way, so `Defer(*)` cannot lock the system out. ## Installation Install from the GitHub repository as an external bundle: ```bash dsh plugin --profile add git:github.com/studyzy/dsh-lazy-tools ``` > You may also `git clone` it locally and install from the directory. The > bundle injects a plugin named `lazy-tools` via `cordis.patch.yml`. ### Compatibility This plugin targets **DeepSeek Harness 0.2.0-rc.2** (`@deepseek-ai/dsh-*` 0.2.0-rc.2, `@deepseek-ai/cordis` 4.0.4, `@deepseek-ai/schemastery` 3.18.4). Those exact versions are declared in `peerDependencies`, so DSH's plugin compatibility preflight accepts the bundle: ```bash # local directory install (refresh the profile dependency tree after changing deps) dsh plugin --profile add link:/path/to/dsh-lazy-tools ``` > If a DSH upgrade reports mismatched peers, align `peerDependencies` / > `devDependencies` to the new `@deepseek-ai/dsh-*` versions, rerun > `pnpm install && pnpm run check`, then repeat the `add` command above. ## Configuration **No configuration is required**: with no `config` at all the plugin runs its built-in preset, which is what you get right after installing it. ### Defaults | | Contents | |---|---| | Deferred | `Defer(*)` — everything but the guards, discovered on demand via `tool_search` | | Always available | `read`, `write`, `edit`, `bash`, `glob`, `grep`, `web_search`, `web_fetch`, `ask_user_question`, `skill` | | Guards (never deferred) | `tool_search`, `defer_execute_tool`, `run_code` | **Naming either `defer` or `noDefer` replaces the preset entirely**, so everything follows what you wrote: `defer: []` still means "defer nothing", and `defer: ['glob']` defers exactly `glob` — it is not re-protected just because `glob` ships in the default core. Configure only to change that default. Configuration lives in the plugin's `config` field in the profile's `cordis.patch.yml` (the user patch layer), using CodeBuddy-style syntax: ```yaml - id: lazy-tools name: '@deepseek-ai/dsh-lazy-tools' config: defer: ['glob', 'web_search', 'Defer(fetch_*)'] noDefer: ['bash'] deferToolLoading: true ``` | Key | Type | Default | Description | |---|---|---|---| | `defer` | `string[]` | see Defaults | Tool names or `Defer(pattern)` entries to defer. Bare names are equivalent to `Defer(name)`. `*` is the only wildcard — `Defer(*)` defers everything except the guard tools. | | `noDefer` | `string[]` | see Defaults | Tool names or `NoDefer(pattern)` entries that must stay directly callable. Bare names are equivalent to `NoDefer(name)`. **Always wins over `defer`.** | | `deferToolLoading` | `boolean` | `true` | Global switch. When `false`, nothing is deferred. | Modifiers are case-insensitive (`defer(bash)` ≡ `Defer(bash)`). Precedence (highest first): `noDefer` > `defer` > `deferToolLoading`. ### Examples ```yaml # Replace the default core with bash only (everything else stays deferred) config: noDefer: ['bash'] ``` ```yaml # Defer just these two and keep everything else visible config: defer: ['glob', 'web_search'] ``` ```yaml # Defer every fetch_* / web_* tool, keep bash always available config: defer: ['Defer(fetch_*)', 'Defer(web_*)'] noDefer: ['bash'] ``` ```yaml # Defer everything except the guard pair (tool_search / defer_execute_tool) config: defer: ['Defer(*)'] ``` ```yaml # Defer everything but keep a small always-on core (noDefer wins over Defer(*)) config: defer: ['Defer(*)'] noDefer: ['read', 'write', 'edit', 'bash'] ``` ```yaml # Defer the MCP gateway tools by prefix config: defer: ['mcp*'] ``` ## How it works | Component | Role | |---|---| | `tool_search` | Searches tools that are not currently visible. `tool_names` does an exact lookup, `queries` does a keyword lookup (Chinese and English). Matches are activated and returned. | | `defer_execute_tool` | Activates a deferred tool by exact name so the model can call it directly. Useful for activating a tool the model already knows about. | | Hiding | On `system-prompt/assemble`, deferred tools are filtered out of that scope's model-facing tool list (the registry itself is untouched). | | Interception | A `tools/pre-execute` listener returns `deny` when the model calls a not-yet-loaded deferred tool directly, pointing back to `tool_search` / `defer_execute_tool`. | | Activation | Matched tools are recorded in the agent's activation set and appear in the tool list — directly callable — from the next model request. | ### Flow ``` Prompt: only the guards + always-on core schemas are visible (the zero-config default) │ ▼ model: tool_search({ tool_names: ["todo_write"] }) │ └─ returns the match status, records it in the agent's activation set ▼ next turn: model calls todo_write directly (full schema now in the tools list) ``` ## Design constraints & known limitations - **Activation applies from the next turn.** After `tool_search` activates a tool, the *current* model request's tool list is already fixed (schemas arrive through the next request header), so a direct call in the same turn is still intercepted by `tools/pre-execute`. Call it on the next turn after the result returns. - **Deferred tools are invisible until searched.** The model does not see deferred tools' names; it relies on `tool_search`'s retrieval to discover them. - **Restrictions compose for free.** The plugin only subtracts from the model-facing list and never widens other `restrict` calls (e.g. parent/child policy). A tool denied by another policy never reaches the model-facing list or the search catalog, so a lookup reports it `unavailable`. - **Tool origin does not matter.** Host composition, agent presets (on the Web/Desktop surfaces every model-facing tool is mounted by a preset), MCP servers, and tools registered after the agent was created all go through the same deferral/search path. - **PTC presentation degrades to a no-op.** An agent presented in `ptc` mode only sees the reserved `run_code` transport (pinned as a guard, never deferred) and reaches other capabilities through the generated SDK; deferral does not hide them there — it does not error, it simply has no effect. - **State is not persisted across sessions.** The loaded-tool set lives only for the current process; a resumed/forked agent re-defers per configuration. This avoids writing custom session events that are not registered in `KNOWN_SESSION_EVENT_TYPES`. - **Tools in the agent's own scope are never taken over.** Tools registered in that agent's own scope (`tool_search` / `defer_execute_tool` themselves) stay visible; `Defer(*)` does not apply to them. ## Development ```bash pnpm install pnpm run typecheck # TypeScript type check (src) pnpm run typecheck:tests # TypeScript type check (tests) pnpm test # vitest unit + integration tests pnpm run lint # oxlint pnpm run build # tsc + tsdown bundle into lib/ pnpm run check # lint + both typechecks + tests + build ``` See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution flow, the source layout, and how to try a change inside a real harness; [CHANGELOG.md](CHANGELOG.md) for release history; and [SECURITY.md](SECURITY.md) to report a vulnerability privately. CI runs lint, both typechecks, tests and the build on Node 22 and 24 (see [.github/workflows/ci.yml](.github/workflows/ci.yml)). ## License This project is released under the MIT License — see [LICENSE](LICENSE).