Bundle
dsh-subagent-model
A subagent tool that forces each child onto an explicitly chosen provider/model and reasoning effort instead of inheriting the parent's route
- Source
- dat-lequoc
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# 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.** `subagent` takes a required `model` argument 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_message` queues 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 `subagent` call 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
```sh
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:
```yaml
- 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`:
```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, spelled `provider/model-id`, plus the optional `inherit/current` entry. 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 to `provider/default`, sends no explicit effort — and for `inherit/current` that 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 on `inherit/current` overrides 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.** `resolveModelInfo` is not required to discover capabilities itself: an adapter may answer it from a cache that only `listModels` fills, 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:
```yaml
- 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
```sh
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.
```sh
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`](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.js` is a hand-written lazy-CJS factory.** The repository's `tsdown` client preset is not published, so an out-of-tree package must reproduce that artifact format itself. The card therefore uses `React.createElement` directly 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 `toolName` collide at registration, by design.
Install
dsh plugin --profile web add github:dat-lequoc/dsh-opinionated-subagent
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-subagent-model from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.