M7.3
Declarative composition
1,960 words · about 12 min
Concept
If every part is a plugin, then "which parts, in what order, with what config" is data — and if it is data, it can be a file, and if it is a file, an operator changes behaviour without a build. That is the whole argument. The failure it prevents: a deployment difference (a Windows host, a sandboxed CI runner, a demo needing an extra tool) forcing a code change and a release.
Mechanism: `cordis.yml` is a list of rows
MechanismA composition is a YAML list of plugin rows. Each row has an id (its stable handle for patching), a name (the package specifier), and optional config and disabled. From examples/headless-agent/cordis.yml:
# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed
# twin (a `providers` dict keyed by route; `reasoning: high` replaces
# thinking/reasoningEffort). Shipped default: full thinking at max effort on
# every request.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
thinking: enabled
reasoningEffort: max
models:
- id: deepseek-v4-pro
contextWindow: 128000
- id: deepseek-v4-flash
contextWindow: 128000
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 60000Read the seam in that fragment: the bash row names a provider, and switching to @deepseek-ai/dsh-bash-sandbox is a one-word edit. Note also the comment on the adapter row — swapping to the pi-ai twin is described as a row change, exactly the M7.2 claim.
Further down the same file, the compaction and subagent rows:
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
config:
thresholdRatio: 0.8
retainRatio: 0.16
maxTokens: 8192
compactionRetries: 1
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
backgroundMode: continuable
maxDepth: 1Every Module 4 compaction knob and every Module 6 delegation knob is a config field, not a constant. AGENTS.md makes this a rule with teeth: "No hardcoded tunables in plugins: deployment-varying choices are validated Config fields changeable from cordis.yml; a DEFAULT_* constant or test hook is not configurability. Protocol constants, external specs, and security invariants stay fixed." That last sentence is the boundary — configurability is for what varies by deployment, not for what varies by attacker.
Mechanism: `!!js` and where interpolation is allowed
MechanismRows can carry expressions:
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.cwd()
- id: persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"The rules, from AGENTS.md and docs/cordis-primer.md:
- The tag is
!!js, never!js. - Interpolation is allowed in a row's
configand in itsdisabledfield. "Other entry metadata stays literal, so conditional composition also uses overlays" (AGENTS.md). configis interpolated after declared injections activate, against that plugin's own context — soctx.<serviceName>is available.disabledis evaluated at every mount decision against the loader context.
That timing difference is not trivia. It is what lets a row read a service to configure itself, as in the app-argument pattern from docs/user/develop/basic/publish.md:
- id: my-app
name: '@example/my-app'
inject: [myAppStartup]
config:
port: !!js ctx.myAppStartup.port ?? 8080"Loader mounts the composition once, waits for each row's ordinary injections, and only then evaluates that row's !!js config against its injected context."
And disabled is how one file serves two platforms. packages/bundle/base/README.md: bash-sandbox/tool-bash carry disabled: !!js process.platform === 'win32', while pwsh-sandbox/tool-pwsh mount on win32 only with the inverted expression — "one shared patch file, exactly one shell stack per host." The reason exactly one may mount is a seam invariant: "both executor families register the same bash service, so an incomplete recipe fails loud at load." Two providers of one ctx key is a duplicate registration, and it fails at load rather than silently picking one. That is AGENTS.md's "Misconfiguration fails loud" rule doing its job — a harness that silently resolved one provider would produce a deployment where nobody could explain which shell ran.
Mechanism: profiles, bundles, and layer order
MechanismComposition scales through layering. packages/boot/app-boot/README.md and docs/user/develop/basic/publish.md define two concepts, each a package.json with a different dsh manifest key:
- A bundle is an npm package shipping a configuration layer, declaring
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }. It answers what does this package contribute? - A profile is a directory under
$DSH_HOME/profiles/<name>describing one runnable composition, declaringdsh.profilewith an orderedbundleslist. It answers which bundles compose this setup, in what order?
"A bundle is what you author and distribute; a profile is what a user boots with dsh --profile <name>. Nothing is both."
The effective configuration composes over an empty root by applying, in order:
- Each bundle patch named in
dsh.profile.bundles, in list order —@deepseek-ai/dsh-basefirst, then each installed bundle in the order added. - The profile's own
cordis.patch.yml. - The home-level
$DSH_HOME/cordis.patch.yml. - Each
--patch <path>overlay, in argv order.
Two rules govern a patch: it targets a row by id and replaces that row's whole config (no deep merge), or it uses insert to add rows. docs/user/develop/basic/publish.md draws out both consequences: your patch can override earlier rows by id "but must restate every key the row needs, not just the changed one," and "users can override your rows in their profile's cordis.patch.yml without touching your package, so prefer configuration defaults users are likely to keep."
The no-deep-merge choice deserves a defence, because it is inconvenient. A deep merge means the effective config of a row is a function of every layer that touched it, and diagnosing "why is timeoutMs 5000" requires reading all of them. Whole-config replacement means exactly one layer determines a row's config and --dump-config can show you which. packages/bundle/base/README.md lists the cost in its own limitations: "A patch replaces whole row configs — profile overrides must restate every field a row keeps; there is no deep-merge layer." Both designs are defensible; dsh traded ergonomics for diagnosability, and said so.
Inspect the result without booting:
dsh --profile web --dump-configpackages/boot/app-boot/README.md explains why that command can be trusted: "composeEntries applies patch layers over an empty entry list through the include's own applyEntryPatches, so composition, flag derivation, and config dumps cannot drift from what boots." The dump is not a reimplementation of composition; it is composition, minus the mounting. A config-inspection tool that reimplements the layering algorithm will drift from it, and the drift will be discovered during an incident.
Worked example: a real overlay
Worked exampleexamples/web-cordis/cordis.yml is a patch overlay, not a tree, and its header says so:
# Opt-in Web composition for inspecting the self-referential Cordis tools.
# Temporary Plugin code can reach every injected live capability; treat this
# deployment like shell access, not as a security boundary.
# This file is a PATCH OVERLAY over the web profile (dsh-base + dsh-web-app
# bundle layers), not a tree: `dsh web --patch` applies it as one more sibling
# patch list at the same include level, so these patches reach every bundle
# row. A patch replaces the targeted row's whole `config`.
# Pinning the port here keeps this demo off the default 3080.
- id: webserver
config:
host: 127.0.0.1
port: 3081
- insert:
- id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner'
- id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis'Nineteen lines, and both patch operations are visible. The webserver row is targeted by id and its config replaced — note it carries no name, because the row already exists in a lower layer. The insert block adds two rows that turn an ordinary web agent into one that can rewrite its own plugin tree (M7.7). Nothing was forked; the base and web-app bundles are untouched.
Mechanism: per-session presets
MechanismProfiles compose a process. Presets compose a session, and the distinction is the interesting one.
From packages/preset/README.md: "An agent preset is a directory holding one agent.cordis.yml. Mounting it under an agent's scope context gives that session its own tools and prompt sections while every other live session keeps its own, so one process can run several differently composed agents at once."
The composition split it assumes: "registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them."
Mechanically (packages/preset/agent-presets/README.md), a preset is mounted once per process under a standing scope, and each session naming it joins by having its agent scope key parented to that mount's. Registrations file into the calling context's scope layer, and an agent's views resolve agent → preset → global, nearest shadowing farthest. So the preset's tools exist once and cover every joined agent, while a sibling preset's listeners stay deaf.
The guardrails are the teaching material. mount() rejects three things:
- An unscoped target — "Mounting into a context that carries no agent scope would register the preset's tools globally, for every agent in the process."
- A row that never became usable — a row still waiting for a service the composition never supplies.
- A row that published a service into the root realm — "Such a service is process-global, so the second preset publishing the same name collides with the first, and a host reader would resolve one preset's instance for every session. A preset that genuinely owns a service puts it behind an
isolaterealm."
The third is the subtle one and is worth the extra sentence: per-session composition and process-global service registration are fundamentally incompatible, and a framework that lets you do both silently produces cross-session bleed that surfaces as an impossible bug months later. dsh refuses at mount instead, and re-checks on every service notification "because a row that publishes from a timer or an asynchronous continuation would escape the one-shot audit."
Two further design notes with transferable lessons:
A preset file is an input, never a persistence target. The Loader writes a tree back to its source file when it decides config changed — and a row disposing its own fiber is enough to trigger that. Inherited, one session's runtime state would be burned into a file every session shares. The mounted subtree therefore overrides write() as a no-op.
Switching is restricted to a blank agent. recompose() re-links only an agent that has produced nothing, because "swapping tools mid-conversation would leave logged tool calls the new composition cannot make." That is the Module 4 model-visible ⟺ logged invariant reaching into composition: the preset decides tool schemas and prompt sections, so a switch is appended as an agent-preset/selected session event, and every reconstruction path resolves rather than reading the creation header. Reading the header alone "would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on."
Tradeoffs
Declarative composition moves failures from compile time to load time. dsh compensates with fail-loud rules and --dump-config, but a typo'd package name is a runtime error, not a type error. It also makes the config a security surface: packages/preset/agent-presets/README.md is explicit — "a preset is exactly as privileged as the plugins it names; a user preset — authored by a person or by an agent — carries the same trust as shell access." Anyone who can write your composition file owns your process.
How others differ
Claude Code composes from layered JSON/Markdown settings (user, project, local, enterprise) with a fixed, non-replaceable core. It cannot swap a session store, but it also cannot be broken by a bad row, and its layering is deep-merged per key rather than whole-value replaced — the opposite trade to dsh's. Codex is similar in spirit with a TOML config. LangGraph and comparable frameworks compose in code, keeping type safety and losing the ability for an operator to recompose without a rebuild. There is no consensus here, and this is one of the places to be honest: whether agent composition belongs in config or in code is genuinely unsettled, and the answer probably depends on whether your operators are engineers.
Check yourself
4 questions on M7.3