dsh-subagent-model
A minimal, opinionated subagent for DeepSeek Harness: you decide which models a child may run on, at which reasoning effort, and a correction reaches a working child at its next step instead of after its turn.
What it does
- Forces the child's model.
subagenttakes a requiredmodelargument whose choices are your allowlist. A child never silently inherits the conversation's model. - Forces the child's reasoning effort. Effort is set per route by you, not by the delegating agent — it has no effort argument and cannot inherit yours.
- Refuses a wrong effort before spending anything. A configured effort is validated against that exact model's advertised set, before any provider call.
- Steers a running child at its next step. The shipped
send_messagequeues behind the whole current turn; a correction aimed at work in flight can land minutes late. This one joins the turn in progress. - Shows the route in the transcript. Each
subagentcall renders the model it ran on, so you never read the session log to check. - Configurable in the UI. Settings → Plugins, with effort dropdowns populated from what each model actually supports.
- Neutral until you opt in. Installed, it behaves exactly like the shipped tool; forcing starts when you edit the allowlist.
Every piece is optional and composes per row: mount only the delegation frontend, only the steering send_message, or both.
Why
A child started with no agentOptions inherits its parent's route: resolveChildAgentOptions spreads the parent's provider/model first, and the shipped @deepseek-ai/dsh-tool-subagent takes only { description, prompt } — it exposes no way for a caller to choose. So a worker silently runs on whatever the main agent runs on.
This plugin replaces that frontend. The model argument is required, its enum is your configured allowlist, and the chosen route plus its configured effort are pinned onto the child.
Installing changes nothing until you say so
The allowlist ships seeded with one entry, inherit/current, which explicitly follows the calling turn's own model and effort. So a fresh install behaves exactly like the shipped tool, and there is nothing to undo if you change your mind.
Forcing is opt-in: add the routes you want, and remove inherit/current to make inheritance impossible. While it is present the model may still name it, so it is a permission, not a fallback — the tool call visibly records which route the child ran on either way.
inherit/current resolves the route recorded for the current turn, not the session-creation model, so a mid-session model switch is respected.
How it forces the route
The harness splits these two facts, so the plugin uses two mechanisms:
| Fact | Mechanism | Durable? |
|---|---|---|
| provider + model | SubagentStartRequest.agentOptions, which takes precedence over the inherited parent route |
yes — no bridge needed |
| reasoning effort | installModelSelection on the child's own context at its agent/created edge |
reservation is in-memory; the child's first request/header becomes the authority |
agentOptions has no effort field — an effort reaches a request only through the agent/request waterfall. lib/effort.js uses the harness's own installModelSelection primitive (the same one the Web model picker uses), which also keeps prompt {{model}} interpolation and the logged request/header agreeing with what is actually sent.
agent/created is a synchronous publication boundary, so the selection is installed before the child can assemble a prompt or issue a request.
Install
dsh plugin --profile web add /path/to/dsh-subagent-model
That mounts the host half only: the settings namespace that owns your allowlist. It registers no tool.
The tool row belongs to an agent preset, because a delegation tool is per-agent composition and must replace the shipped row rather than sit beside it — two rows registering the same subagent name collide, and leaving the shipped one mounted would give the model a way to bypass this policy.
In your preset's agent.cordis.yml, replace the tool-subagent row:
- id: tool-subagent
name: dsh-subagent-model/spawn
config:
provider: spawn
toolName: subagent
backgroundMode: continuable # or one-shot
maxDepth: 3 # or provider-managed
Configure
Settings → Plugins → Subagent model. The card lists your allowed routes, each with an effort dropdown populated from what that exact model advertises, plus an "Add a route…" picker fed by the live model catalog. Edits stage locally and commit on Save through the revision-fenced settings scope, so a half-finished edit never reaches a running agent. The banner states plainly whether you are currently inheriting or forcing.
Both halves ship in this one package: installing it puts the card on the settings page. The Plugins tab renders only namespaces a registered card claims, so there is no generic schema-driven form to fall back on.
Equivalently, in ~/.dsh/settings.yaml:
dsh-subagent-model:
routes:
- kiro/claude-opus-5
- deepseek/deepseek-chat
# - inherit/current # remove to forbid inheriting the conversation's model
efforts:
kiro/claude-opus-5: high
deepseek/deepseek-chat: provider/default
routes— the complete allowlist, spelledprovider/model-id, plus the optionalinherit/currententry. Only the first/splits a provider route, so a model id may itself contain slashes. Defaults to[inherit/current].efforts— effort per route. A route absent from the map, or set toprovider/default, sends no explicit effort — and forinherit/currentthat means following the calling turn's own effort. Any other value must be an effort that exact model advertises; an unadvertised id is refused before any provider call rather than clamped. An explicit effort oninherit/currentoverrides the inherited one, so you can follow the model but pin the effort.
Both are read at every tool call, so an edit applies to the next delegation with no restart.
Behavior worth knowing
- An empty allowlist unregisters the tool. It never falls back to the parent's model — silently inheriting is the exact behavior this plugin exists to prevent. The prompt section still states the rule so the model can explain the refusal.
- A route that no longer resolves is skipped with a warning, and the remaining routes keep working. If none resolve, the tool is unregistered.
- The model cannot choose an effort. There is no effort argument, and a child never inherits the parent's.
- Settings are re-read inside
execute, so a schema captured a step ago cannot authorize a route you removed since. - Foreground calls are marked concurrency-unsafe, because they share one parent-matched effort reservation slot.
- Each named provider is listed before its routes are resolved.
resolveModelInfois not required to discover capabilities itself: an adapter may answer it from a cache that onlylistModelsfills, and report a static fallback list while that cache is cold. Without listing first, an effort the provider genuinely accepts is refused as unadvertised. Only providers your allowlist names are listed, so a delegation never wakes an unrelated adapter.
Config reference
| Field | Default | Meaning |
|---|---|---|
provider |
spawn |
The ctx.subagents provider to start children on |
toolName |
subagent |
Model-facing tool name; must be unique among live tools |
backgroundMode |
continuable |
continuable returns a durable subagent id reachable by send_message; one-shot defaults to waiting |
persona |
— | Optional per-child persona shadowing the deployment persona |
maxDepth |
3 |
Child recursion cap, or provider-managed to send none |
Seeing which model a subagent ran on
Each subagent call in the transcript renders one line: a state dot (running / done / failed), the word Subagent, the route the child runs on, and the call's short description.
It is read-only and adds no logic. The route is the model argument the tool already requires, so nothing new is logged or computed. A call whose arguments cannot be read — still streaming, interrupted, or made by the shipped frontend, which has no model argument — renders the ordinary summary and claims no model rather than guessing one.
The row claims the subagent tool name. A keyed tool view replaces the generic row rather than decorating it, so this is a deliberate takeover of that one name; every other tool keeps its shipped rendering. Setting a different toolName in the spawn row means calls fall back to the generic row.
Steering a running subagent
lib/control.js is an optional send_message replacement. Mount it in place of the shipped control row:
- id: tool-subagent-control
name: dsh-subagent-model/control
The shipped tool routes every delivery through ctx.subagents.followup(), which calls Agent.followup() and therefore targets next-turn. For a child that is mid-turn — running tools, one per step — the message is accepted and then waits for the whole turn to end, so a correction aimed at work in flight arrives after that work is done. Measured in a real session: a child counting to ten with one bash step per turn had a steer accepted at +7.4s and claimed at +54.9s, a 47.5-second stall.
This version calls Agent.steer() for a running direct child, which targets next-step and is consumed at the next step boundary. Everything else is delegated to the native service unchanged: an idle, waiting, or absent child (it owns waking and cold resume), and any authority mismatch (it owns the authoritative rejection). Ownership accounting and settlement are untouched.
The result reports which path ran, as delivery: 'next-step' or 'next-turn', so the model can tell whether a correction joined the current turn.
A steer that races settlement is not silently lost: disposal clears the inbox, so if the child leaves the registry in the same tick, the delivery falls back to the native path.
Routes
| Route | Purpose |
|---|---|
GET /subagent-model/catalog |
The model catalog the settings card reads: every route with its native input modalities and advertised reasoning efforts, plus the inherit/current entry. Registered through a nested plugin injecting webServer, so the package still mounts in profiles with no web server. |
Tests
npm test
51 tests, all passing. policy.test.mjs and reservations.test.mjs are dependency-free — the route, effort, and reservation-matching rules are pure and need no harness. integration.test.mjs mounts the tool on a real Cordis context with the real ToolRuntime/SubagentRuntime and asserts against a capture provider that the built SubagentStartRequest carries the chosen route, including that the seeded inherit route reproduces the shipped behavior. defaults.test.mjs pins the behavior-neutral seed. client-card.test.mjs exercises the browser card without a browser — it supplies a module loader and a React stub that records the element tree, then asserts the slot registration, that nothing is written before Save, that Save writes efforts before routes, and that a removed route takes its effort with it. It also pins every theme token the card names against the set the Theme provider publishes, because an invented token cannot fail loudly — CSS falls through to the literal fallback, so a typo renders the card unreadable rather than erroring. The harness-dependent suites self-skip when those packages are not resolvable.
Live end-to-end check
tests/live-headless.patch.yml runs the real thing: it disables the profile's
shipped tool-subagent row, mounts this frontend in its place, and pins the
PARENT to a route that is not on the allowlist, so a child reaching the chosen
model proves the route was forced rather than inherited.
dsh plugin --profile headless add /path/to/dsh-subagent-model
dsh --profile headless --patch tests/live-headless.patch.yml \
"Use the subagent tool once with model antigravity/gemini-3.7-flash and prompt 'name your model'."
Verified on a real run: with the parent on unlimited/claude-sonnet-5 and the
allowlist forcing antigravity/gemini-3.7-flash at high, the child's durable
request/header recorded provider: antigravity, model: gemini-3.7-flash,
reasoningEffort: high — so the effort bridge reaches the actual request, not
just the tool's label — and the child reported itself as Gemini 3.7 Flash.
Naming a route the user removed is refused by the schema enum before execution.
The parent route is pinned in the patch layer, but a agent-default-model block
in ~/.dsh/settings.yaml outranks it: set that to the same route, or remove it,
or the run boots on whichever provider settings names.
Designed, not built
docs/refork-spec.md specifies refork_subagent: continue a stalled or wrong-model child's whole conversation on a different route, by copying its completed-turn history into a new child rather than resuming the old one on a model that did not produce it.
The spec is complete enough to implement from — every mechanism is cited in current source, the load-bearing assumption is measured on a real stalled child, and the two unverified risks are marked. Two constraints are worth knowing before asking for it: a child killed mid-turn loses that partial turn, because a seed must end at a turn/end; and combining a seeded child with continuable mode is a combination the shipped fork provider deliberately avoids, so it needs measuring first.
Known limitations
- The effort reservation is in-memory. A process restart between a child's creation and its first request loses the reserved effort, and that child falls back to its route's provider default. The route itself is durable, so this cannot silently change models.
lib/client.jsis a hand-written lazy-CJS factory. The repository'stsdownclient preset is not published, so an out-of-tree package must reproduce that artifact format itself. The card therefore usesReact.createElementdirectly and draws its own chrome — the client bundle-purity gate rejects value imports across plugins, so it cannot reuse the shipped card components.- The card needs the catalog route to offer choices. Without a web server the settings namespace still works from YAML; the picker and effort dropdowns are simply empty.
- One tool row per provider. Two rows sharing a
toolNamecollide at registration, by design.
No comments yet. Be the first to write one.