Skip to content

dsh-subagent-model

Verified

dsh-subagent-model Β· v0.1.1 Β· MIT

DeepSeek Harness plugin that asks which provider and model should run each delegation before the child starts, so a subagent can go to a cheap or local route instead of the parent's.

Install

dsh plugin add dsh-subagent-model

Confirm the layer applied with dsh --profile default --dump-config β€” see the install guide.

Source

Tags

Readme

🧭 dsh-subagent-model

Host-layer DeepSeek Harness (DSH) plugin that asks which provider and model should run each delegation before the child starts, so a subagent can go to a cheap or local route instead of inheriting the parent's.

npm version CI Node.js License: MIT GitHub

πŸ“‹ Table of Contents

The question, answered in a running session β€” the task, the routes this plugin configures, and the models the session's own directory offers, with prices and context windows:

The delegation dialog: the task dossier, three quick choices with their descriptions, the DeepSeek provider group with prices and context windows, and the footer's keep and auto answers

A model picker (dsh-model-chooser) draws that list; without one, the harness's own question card renders the same question as a plain option list.

What It Fixes

A subagent inherits the parent's provider and model unless the request overrides them (resolveChildAgentOptions in dsh-subagent). The delegation tool passes no model, so a child's route is a config-time decision β€” while the moment you actually know what the task is (its label, its prompt size, whether it is a durable background child) is the moment the delegation starts.

That is where this plugin asks. A research delegation that reads a directory does not need the parent's frontier model, and a local gateway can serve it for nothing; a hard refactor may still want the expensive route. The plugin turns that from a guess into one question per delegation, answerable on information.

It costs nothing when it is not used: no tool, no prompt section, no instruction text, no added tokens.

How It Works

One thing is installed, scoped to the plugin's own fiber: wrappers around ctx.subagents.start and ctx.subagents.startContinuable, filtered to one provider name (default spawn). They ask, then hand the answer to the real method as the child's agentOptions.

Three facts explain why the wrap happens there and nowhere else:

  • Both entry points carry a child's model by different paths. start hands the child to a provider that composes it; startContinuable composes the child itself and reads request.agentOptions directly. A provider-level hook would miss every durable background child.
  • The registry is a host singleton, the tools are not. The delegation tools (tool-subagent, and every copy of it in a preset) live in the AGENT PRESET's composition β€” for studio-style deployments a generated file in the harness home β€” while the subagents registry they resolve is owned by the host composition. Wrapping the registry therefore reaches every preset, including one generated outside this repository, and needs no composition surgery. Patching a tool row instead would miss every preset the profile layer cannot see.
  • The chosen lane wins by construction. resolveChildAgentOptions applies the request's agentOptions over the parent's route, so the child's model is the one you picked.

The wrapper is keyed by provider name, so a delegation to any other provider β€” fork, a remote backend β€” is forwarded untouched.

What it asks about

scope defaults to labelled, which means a start carrying a task label. The delegation tool is the one caller that sends one, and that label is what makes the dialog answerable ("Task: …"). The workflow engine's children and Ralph's rounds start on the same provider without a label, so they keep the route their caller chose instead of raising a dialog per phase. Set scope: any to be asked about those too.

What the Dialog Offers

What the human sees depends on the UI that answers it. The harness's own question card renders the options as a list:

Subagent model
  Which model should run this delegation?
  Task: Check the flaky test
  Prompt: 8 characters (~2 tokens at 4 characters per token β€” an estimate, not provider-reported)
  Child: durable background child (continuable; later turns go through send_message)
  Route without this choice: requesty/deepseek-v4.1-flash
  [ PX13 local (fast) ] [ PX13 local (deep) ] [ Cloud small (haiku) ]
  [ Keep the parent model (requesty::deepseek/deepseek-v4.1-flash) ] [ Auto for this session ]

With a model picker that recognises this question (see the picker contract), the same question is answered by a searchable list of every configured model instead, and the lanes list becomes optional β€” it stays available as a quick-choice group, with its configured descriptions.

The parent option's label carries the inherited route in the parenthesised provider::model form, so a picker can mark that row as the current one; the router matches it by prefix. A lane label may therefore not start with the parent answer.

Complexity is deliberately not estimated: the prompt size is a measurement, a difficulty score would be invented.

The dossier the dialog shows is exactly that: the task label, the prompt size as an estimate, the child kind, the route it would inherit, and the route the tool configuration already requests.

The Model Picker Contract

A UI may answer this question with a route label β€” provider::model β€” because a picker's list is the live catalog rather than this dialog's options. It arrives as the answer's free text (custom): the host refuses a selected label that is not among the question's own options, with bad-response (matchesQuestions in packages/host/apiproxy), and a single-choice question requires custom instead of a selection. A route label sent as a selection is accepted as well, for a UI whose options carry them; the router reads either.

The separator is :: and not / because a model id may itself contain a slash (this deployment's requesty route serves anthropic/claude-haiku-4-5), so a provider/model label could not be split back apart. The same separator is the key format of the picker's own catalog, which is where the contract comes from.

Offering every catalog route as a dialog option would legalize a selection, but it would also push hundreds of entries into the question β€” and into the native question card that answers it when no picker is mounted. The free-text field is the protocol's own way to answer with something the asker did not offer.

The router then checks the route against the mounted llm service before using it:

Verdict When Result
accepted the provider is listed and the model is in a readable model list, or nothing readable could contradict the pair the child runs on that route
refused the provider is missing from the provider list, or the model is missing from a readable model list the configured default, one warning
unjudgeable no llm service, an unreadable provider list, or an empty model list accepted β€” an enumeration that does not answer is not a route that does not exist

Model lists are cached per provider for five minutes, so a burst of delegations does not enumerate per child, and a list refreshed by the picker becomes visible within that window.

The plugin that implements this contract today is dsh-model-chooser, whose picker also answers the composer's model seat.

Guarantees

The router never blocks a delegation. Every failure path returns the request it was given, with a warning: no lane configured, no userQuestions service, a question that throws, an answer that names nothing. A router bug may send a delegation to the wrong route; it may never strand a session. The one exception is caller cancellation, which the caller's own signal owns β€” the signal is handed to the dialog, so a cancelled delegation does not leave a question waiting.

Situation Result
ask: false, or the session already answered "Auto" the configured default route, no dialog
A start without a task label, and scope: labelled the request as built, silently
No userQuestions provider (headless profile) the configured default, one warning
A nested delegation (the parent is not a live runtime root) the configured default, one info line β€” the harness refuses human interaction there
Any other question failure the configured default, one warning
The answer names no configured lane and is not a route label the request as built, one warning
The answer is a route the llm service contradicts the configured default, one warning
A bug in the plugin itself the request as built, one warning

Nothing model-visible is added. No tool, no prompt section, no instruction text: the plugin is host-side only. A session's request prefix is byte-identical with and without it, and the parent's own prefix cache is untouched β€” only the child's requests change.

Misconfiguration fails at load. A lane without label, provider, or model, a duplicate label, a label equal to (or starting with) one of the router's own answers, an unknown default, and an unknown scope all fail when the plugin mounts.

Installation

dsh plugin --profile <profile> add dsh-subagent-model

The package carries a dsh.bundle.patch layer, so the CLI appends it to the profile's bundle stack and the router row mounts. No other row is touched β€” no tool row is repointed, which is the point of wrapping the registry.

Give the router its lanes in the profile's own patch layer (~/.dsh/profiles/<profile>/cordis.patch.yml). A patch replaces the targeted row's config, so this is the whole config for the router row:

- id: subagent-model
  config:
    provider: spawn          # the provider whose starts are routed
    scope: labelled          # 'any' asks about workflow phases and Ralph rounds too
    ask: true                # false = never ask, always use `default`
    default: parent          # or a lane label, for the no-dialog paths
    lanes:
      - label: PX13 local
        provider: px13
        model: qwen36
        description: Local 27B on the PX13 gateway. Free; slower first token.
      - label: DeepSeek flash
        provider: deepseek-official
        model: deepseek-v4-flash
        description: Cheapest cloud route.

Then restart the DSH server and hard-refresh the browser.

Updating an installed copy

The profile installs this package from npm (dsh-subagent-model@^0.1.x). To ship a change: run npm test, bump the version, npm publish (whose prepublishOnly runs the tests), then dsh plugin --profile <profile> install and restart the server.

A file: install behaves differently, and the difference bites: pnpm hard-links the package directory, and an editor that replaces a file breaks that link, so the profile silently keeps the previous content while your working tree moves on. If you develop against a file: install, delete profiles/<profile>/node_modules/dsh-subagent-model and re-run the install after every edit.

Configuration

Key Default Meaning
provider spawn The provider whose starts are routed. Every other provider is untouched.
scope labelled labelled asks only about starts carrying a task label (the delegation tool's marker); any asks about every start on that provider.
ask true Whether to ask before a delegation.
default parent What the no-dialog paths use: parent, or a lane label.
lanes[] β€” Optional quick choices offered above the picker's list. Empty is the normal configuration when a model picker answers this question.
lanes[].label β€” The dialog option, and the exact string an answer is matched on.
lanes[].provider β€” Provider route for the child.
lanes[].model β€” Model id for the child.
lanes[].description β€” Optional line under the option in the dialog.
lanes[].maxTokens β€” Optional per-child output cap, carried into agentOptions.

Verification & Testing

npm run link:harness   # once: link this machine's harness for the integration tests
npm run test:unit      # decision logic; no harness packages
npm run test:integration  # real dsh-subagent, real Loader, real bundle layers
npm test               # both, under a second

The integration lane is the one that matters for the claims this plugin makes. It mounts the shipped dsh-subagent service and drives it from a different context than the installer (a delegation tool resolves the registry from its own realm, so a same-context test would prove nothing), rewrites the continuable spec, replaces the provider, and disposes the fiber. A second file composes the REAL dsh-base and dsh-web-app patch layers and fails if this package's layer changes any row.

To see it work: dsh --profile <profile> --dump-config shows the row and its lanes and grep -i garden shows nothing. Then delegate something and read the child's own session log β€” its request/context event names the route the child actually ran on, which is the only claim that counts.

Model, Token, and KV-Cache Effects

  • Nothing is added to any prompt. No tool, no prompt section, no instruction text: the router is host-side only. A session's request prefix is byte-identical with and without it.
  • The parent's prefix cache is untouched. Routing a child changes the child's own requests; the parent's next request reuses its prefix as before.
  • The cost change is the point. A delegation sent to a local route costs nothing at the provider; the same delegation on a cloud route costs what that model charges.
  • The dialog blocks the delegation call. A foreground delegation waits for its child anyway, so nothing is lost there; a continuable/background delegation normally returns a durable child id immediately and now waits until the question is answered.

Known Limitations

  1. Nested delegations cannot ask. A child agent spawning a grandchild is not a runtime root, and ctx.userQuestions.ask refuses human interaction for an agent owned by another live agent. Those delegations take default, reported as one info line β€” choose a default you are happy for depth β‰₯ 1.
  2. Scope is a caller's marker, not a caller identity. scope: labelled recognises the delegation tool by the task label it always sends; the workflow engine's children and Ralph's rounds are excluded by the label's absence, which is an observable property of those callers rather than a guarantee about them. scope: any drops the distinction β€” and then replaces a workflow phase's explicit provider/model with the dialog answer.
  3. One routed provider per mount. provider names one provider; a second routed provider needs a second mount.
  4. "Auto for this session" is process-local. It lives in memory and disappears with the server; a durable record would need a session event type an out-of-repo plugin cannot add.
  5. A lane's route is checked, not proven. The providers are compared against llm.listProviders() once, before the first dialog, and a mismatch is warned about β€” it does not remove the lane, because a provider list can legitimately be incomplete. A wrong model id inside a valid provider route is not detected at all; the child fails at its first model call.
  6. The continuable path is verified at the spec boundary. The integration test records the spec handed to subagents.startContinuable, because materializing a durable child needs the whole agent spine. What is proven is that the rewrite reaches the method that composes the child.
  7. The prompt-size number is an estimate (4 characters per token). Provider-reported usage exists only after the child ran.

🀝 Contributing

See CONTRIBUTING.md for the five hard rules this plugin is built on, the layout, and how to run the two lanes.

πŸ”— Links

  • dsh-model-chooser β€” the model picker that answers this question with a live catalog
  • dsh-cache-guard β€” the sibling plugin that prices automatic context rewrites
  • dsh-pathfix β€” the sibling plugin that repairs whitespace-broken path arguments
  • DeepSeek Harness β€” the harness this plugin extends

License

MIT Β© loonylabs-dev