fix(web): keep the subagent registry in the host plane

`dsh web` failed to boot: the aggressive plane split moved `subagents` and
its spawn/fork backends into the preset's entry-local realm, but
`dsh-host-apiproxy` is a host row that injects `subagents` to answer the
browser's cross-session queries, so it waited forever for a service only
sessions now provided. A per-session copy is wrong twice over — a provider
name registers once, so the second session would have collided anyway.

The registry and its backends go back to the host composition; the presets
keep the delegation TOOLS, which resolve that host registry. `workflows`
stays entry-local: nothing outside an agent reads it.

The web real-composition test could not have caught this, because it
disabled `api-gateway` — the very row whose pending injection names the
break. It now boots with the api-proxy enabled and the browse directory
picker substituted, so the boot audit covers the whole host-plane injection
graph. Re-introducing the old split makes it fail with the three pending
entries, which is how this was verified.
This commit is contained in:
Yichen Jiang
2026-08-04 12:46:57 +08:00
parent 3a60d97d21
commit 6595301afc
7 changed files with 51 additions and 56 deletions

View File

@@ -22,7 +22,7 @@
You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session.
Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its delegation backends. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service.
Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it.
Load the `editing-cordis-compositions` skill before writing or changing a composition.
@@ -157,29 +157,21 @@
# ── delegation and workflows ────────────────────────────────────────────────
# Every backend and every tool that reaches `subagents` or `workflows` shares
# one realm: a consumer left outside it would resolve the host's registry
# instead, which this preset does not populate.
# The `subagents` registry and its spawn/fork backends live in the HOST
# composition: the registry is a process singleton whose cross-session queries
# the api-proxy serves to the browser, and a provider name may only be
# registered once. This preset contributes the delegation TOOLS, which resolve
# that host registry.
#
# `workflows` is different — nothing outside an agent reads it — so every row
# that reaches it shares one entry-local realm here, and a consumer left
# outside would resolve a host registry this preset does not populate.
- id: delegation
name: cordis:group
group: true
isolate:
subagents: true
workflows: true
config:
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn
name: '@deepseek-ai/dsh-subagent-spawn'
config:
providerName: spawn
- id: subagent-fork
name: '@deepseek-ai/dsh-subagent-fork'
config:
providerName: fork
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'

View File

@@ -11,9 +11,11 @@ Every capability in this harness is a plugin row in a `cordis.yml`. There is no
Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared.
**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, and the model route. One instance for the process.
**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.
**Agent preset.** What one session contributes to those registries: its tool plugins, its persona, its delegation backends, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.
**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.
**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.
A preset is a directory holding one `agent.cordis.yml`. The shipped ones live beside the deployment's composition; locally authored ones live under `$DSH_HOME/.agent-presets/<name>/`.

View File

@@ -172,29 +172,21 @@
# ── delegation and workflows ────────────────────────────────────────────────
# Every backend and every tool that reaches `subagents` or `workflows` shares
# one realm: a consumer left outside it would resolve the host's registry
# instead, which this preset does not populate.
# The `subagents` registry and its spawn/fork backends live in the HOST
# composition: the registry is a process singleton whose cross-session queries
# the api-proxy serves to the browser, and a provider name may only be
# registered once. This preset contributes the delegation TOOLS, which resolve
# that host registry.
#
# `workflows` is different — nothing outside an agent reads it — so every row
# that reaches it shares one entry-local realm here, and a consumer left
# outside would resolve a host registry this preset does not populate.
- id: delegation
name: cordis:group
group: true
isolate:
subagents: true
workflows: true
config:
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn
name: '@deepseek-ai/dsh-subagent-spawn'
config:
providerName: spawn
- id: subagent-fork
name: '@deepseek-ai/dsh-subagent-fork'
config:
providerName: fork
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'

View File

@@ -30,19 +30,22 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
// outcome. Point it at a temp file for the same reason the roster below
// names only the shipped root.
{ id: 'settings', config: { path: settingsFile, watch: false } },
// Host rows with side effects outside this process: a bound port, a
// served asset tree, a telemetry exporter.
// Host rows with side effects outside this process: a bound port, a served
// asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
// stay ENABLED on purpose — the api-proxy is the host row that injects
// `subagents`, `workspace`, and the rest of the agent plane, so disabling
// it would hide exactly the breakage this file exists to catch: a service
// moved into the presets that a host row still waits for. The boot audit
// is that assertion.
{ id: 'webserver', disabled: true },
{ id: 'telemetry-otel', disabled: true },
{ id: 'modules', disabled: true },
{ id: 'connection', disabled: true },
// NOT a side-effect row: the api-proxy cannot mount in THIS layer at all,
// because it injects `subagents` and the subagent registry moved into the
// presets here. That is the breakage a later layer returns to the host
// plane; when it does, this line comes out and the boot audit covers the
// whole host-plane injection graph again.
{ id: 'api-gateway', disabled: true },
// The shipped `-auto` chooser resolves its interaction from a running
// host and so waits for the webserver disabled above; the browse variant
// supplies `directoryPicker` without one.
{ id: 'directory-picker', disabled: true },
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
// The roster AppCLIEntry would patch in; only the shipped root, so a
// developer's own `~/.dsh/.preset` cannot change this test's outcome.
// `default` here is the COMPOSITION default — the base layer the settings
@@ -401,7 +404,7 @@ describe('the default preset as a user setting', () => {
})
describe('a session keeps the preset it was created with', () => {
it('records the preset the gateway guard reads', async () => {
it('refuses to adopt a live session under a different preset', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-locked'),
meta: { agentPreset: 'core-web' },