Source: Cordis 在做什么:从 DeepSeek Harness 看 by Antinomie on antinomie.org. The piece is the site’s current homepage post.
DeepSeek Harness (DSH) is an agent harness built on Cordis. The first thing that clicks is not a privileged core loop. Model adapters, tool registries, session logs, and the agent loop itself are all plugins.
This is the first post in my DeepSeek Harness series. I am starting with a close reading of how DSH actually uses Cordis: services, inject, reversible ctx.effect registration, Loader, and the config layers that turn composition into data.
Version note: Code citations pin DeepSeek Harness
master@47f943859b. Cordis is the vendored source snapshot@deepseek-ai/[email protected]invendor/cordis/package.json.Cordis has a theoretical background in A Programming Paradigm for Spatiotemporal Composability (PDF; cited as “paper section x.y”). This post does not unpack the theory. It uses the mechanisms to answer one question: how does DeepSeek Harness use the features Cordis provides?
Every code citation keeps
file:lineand links to the GitHub source at that commit.
1. Premise: Everything Is a Plugin
DSH is an agent harness.
Its first principle is: model adapters, the tool registry, session logs, and the agent loop itself are all Cordis plugins. There is no privileged core. So “how DSH uses Cordis” and “how DSH is built” are the same question.
The second principle is about how packages are split: one capability becomes three packages. The Definition package defines the service. The Provider package supplies an implementation. The Consumer package uses the capability. Providers attach an implementation to a service name in two ways:
- Exclusive: the provider occupies the service name. Only one implementation is live at a time. Shell works this way:
bash-sandboxoccupiesctx.shell(section 3). A second implementation throws atprovidetime (the duplicate check in section 5). Which implementation is running is not a runtime race. Configuration decides it statically.dsh --profile web --dump-configshows the stacked tree that actually takes effect. - Registry-style: several implementations coexist under one capability. The Definition package holds a registry. That registry is not a framework feature. It is just a
Mapon the service, for exampleWebRuntime‘sprivate searchProviders = new Map<string, WebSearchProvider>()(packages/web/web/src/index.ts:85). “Register into the registry” means the provider calls a register method and lands in thatMap(store.setin section 5). The registry absorbs high-frequency implementation changes: swap one implementation and you only undo one entry and add another. Plugins that depend on the service stay still.
Take the shell capability that will keep showing up:
- Definition:
@deepseek-ai/dsh-shell(ShellExecutorplus a type patch, unpacked in section 3) - Provider:
@deepseek-ai/dsh-bash-sandbox(SandboxBashExecutorin section 6, plus bash-local and the pwsh family) - Consumer:
@deepseek-ai/dsh-tool-bash, which declaresinject = ['tools', 'shell', 'systemPrompt', 'shellEnv'](packages/shell/tool-bash/src/index.ts:31), wraps shell as a bash tool the model can see, and callsctx.shell.run(...)at line 380
Provider and consumer never import each other. Both only know the Definition package and the service name. Swap the implementation and the consumer does not change a line.
flowchart TB
Def["Definition
@deepseek-ai/dsh-shell"]
Prov["Provider
dsh-bash-sandbox / bash-local / pwsh"]
Cons["Consumer
dsh-tool-bash"]
Def --> Prov
Def --> Cons
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
class Def blueClass
class Prov orangeClass
class Cons greenClass
I have to admit: because DeepSeek Harness needs a plugin tree with spatiotemporal independence, the framework code is expensive to understand. As a developer, we only need the mechanisms. And after this article, the surprise is that most of that complexity is eaten by DeepSeek Harness itself. Adding a plugin does not add extra complexity.
2. One Boot: Five Lines, Every Concept
Run dsh --profile headless "fix this bug" and you land in boot() (packages/boot/app-boot/src/index.ts:757-784, error handling omitted):
1 | export async function boot( |
Those five lines map to five Cordis mechanisms:
new Context(): create the root of the plugin tree. Context is both the service store (every laterctx.xxxis registered on it) and a tree node. Each mounted plugin gets its own child context. Unload the parent and the subtree unloads with it.ctx.provide('dshHomePath', ...): the first service registration. Hang a value on ctx so the whole system can read it.ctx.plugin(Loader): programmatically mount the first plugin. Loader reads YAML config.mountRootInclude(...): handcordis.yml(plus patch layers, section 7) to Loader, which expands it declaratively into the full plugin tree.await ctx.get('loader')?.await()plusassertEntriesActivated(...): wait until the tree is in place, then audit. If an entry never activates (for example a declared dependency will never exist), fail here and name what is missing. Do not run with a half-loaded tree.
flowchart LR
A["new Context()"] --> B["provide dshHomePath"]
B --> C["plugin Loader"]
C --> D["mount cordis.yml"]
D --> E["await + audit"]
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
class A blueClass
class B orangeClass
class C greenClass
class D purpleClass
class E tealClass
The next three sections unpack the three most important mechanisms on this tree: services, dependency declarations, and reversible registration. Section 6 looks at a plugin as a whole. Section 7 covers the config layers.
Plugin: the unit of loaded code
A plugin is the unit of loaded code: an inject declaration plus a block that runs after its dependencies are ready. That block has two shapes:
- A function plugin exports
apply(ctx, config). - A class plugin (
extends Service) has noapply. Its constructor is that block.
Both receive the same (ctx, config). A plugin has a lifecycle: instantiate on mount, reclaim everything it registered on unload.
Service: an object shared by name
A service is an object one plugin shares with the rest of the system by name. The provider uses provide to bind an object to a service name ('web', 'shell', 'llm'). The consumer reads the same name as ctx.web.
The object can be anything. dshHomePath is just a string. loader is an instance with methods. After Loader is mounted with ctx.plugin, its constructor binds itself to the loader service name (vendor/loader/src/index.ts:90; a class plugin’s constructor is the startup code, see section 3):
1 | ctx.reflect.provide('loader', this, this[Service.check]) |
The only shared contract is the service name. web-search-exa declares that it needs web. It does not know or care which plugin provides the object.
If the provider is not loaded yet, reading the name returns nothing. If the provider is unloaded, the binding disappears. “I need this service” is not the same as “I can read it now.” That is why we need inject: declare which services a plugin needs to start, then wait until those services are in place. That is section 4.
And “wait until ready” is not a one-shot. If a running service is swapped (for example the shell implementation plugin changes from bash-local to pwsh), Cordis reloads every plugin that declared it needs shell and re-runs apply on the new tree. If a service loses its provider, those plugins are stopped first. Dependencies always point at the provider that is currently in effect, not the one that existed at first boot.
Why reload instead of quietly handing the new object to a running plugin? Because apply already ran. The old implementation may already be captured in event listeners or timer closures. Swap only the object and the plugin still holds the old reference. Undo all of its effects and re-run apply on the new implementation. That is the only consistent way to land the whole plugin on the new implementation.
Why can reload happen at any time? Because every effect is reversible. There is no half-success, half-failure middle state. That is section 5.
Keep two layers of change distinct. The example above swaps the service provider, which triggers reload. When a registry-style provider such as web-search-exa is swapped (sections 4 and 5), no plugin reloads. The registry undoes one entry and adds another. The next ctx.web.search() follows the selection rules onto the new provider. That is the point of the three-package split in section 1: high-frequency provider changes stop at the registry layer and do not disturb plugins that depend on the service.
The plugin tree: who mounts whom
The plugin tree is the set of “who mounted whom” edges. There are two ways to declare those edges. Start with the programmatic one.
Programmatic:
ctx.plugin(X)mounts X as a child of the ctx at the call site. X’sapplyreceives that new child ctx. If X then callsctx.plugin(Y)inside its ownapply, Y is X’s child. agent-spine in section 4 fires about twentyctx.plugincalls in a row and produces a subtree with twenty-odd children.
Mount and start are separate actions.
Here is everything ctx.plugin(X) does (vendor/cordis/src/registry.ts:316-330, RegistryService.plugin, return wrapping omitted):
1 | plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) { |
Look at the last line: config and the inject declaration enter the fiber at this moment.
A fiber is the runtime object for one mount. It holds that mount’s config, inject declaration, child ctx, and lifecycle state machine. This post does not unpack the state machine. Remember two faces: it holds a disposal list used to undo effects (section 5), and it waits until inject dependencies are complete before starting (section 4).
Fiber’s constructor (vendor/cordis/src/fiber.ts:222-253,265, intercept bookkeeping omitted):
1 | constructor( |
The execute closure is only prepared. The constructor does not call it. The implementation class object is new‘ed only when the fiber starts (inject dependencies are complete, then execute runs).
That is the split:
- Mount = build a fiber and hang it on the tree. Synchronous, immediate, and it does not run plugin code.
- Start = run
execute. Timing is decided by dependencies (section 4). agent-spine can writectx.plugin(...)in any order because every mount finishes immediately. Who starts first is decided by dependencies, not by source order.
After the programmatic path, there is a second way to declare mount edges: declarative.
Declarative: an entry in
cordis.ymlwithgroup: truehas aconfigthat is a list of child entries (in section 7,ptyis nested underpersistent-shellin the minimal preset). Loader instantiates the entry hierarchy into a tree of the same shape. “Unload the parent and the subtree unloads” is one sentence: a child instance registers itself as an effect of the parent instance (vendor/cordis/src/fiber.ts:265), and effects are reclaimed automatically when their instance unloads (section 5). Unload the parent and the whole subtree is reclaimed in cascade.
Why two ways? They face different people.
Declarative is for the orchestrator. That person manages the whole system and wants a data file they can edit line by line: disable a row, replace one row’s config, then hot-reload. To the orchestrator, agent-spine is one line in cordis.yml. How many child plugins it contains is not their problem.
Programmatic is for the component author. Inside a component you need “decide which child plugins to mount from my own config, and with what settings.” Literal YAML fields cannot express that. You write code.
Both paths produce the same tree. Children of a YAML group and children mounted by a bundle go through the same inject wait and the same effect reclaim.
3. Services: Where ctx.shell Comes From
In DSH, ctx.shell, ctx.llm, and ctx.web are not built into Cordis. Packages register them. Registration happens in the Service base-class constructor (vendor/cordis/src/service.ts:42,57):
1 | constructor(protected ctx: Context, name: string) { |
A subclass calls super(ctx, 'shell') and registers itself as ctx.shell. Shell as an example (packages/shell/shell/src/index.ts:40,65):
1 | declare module '@deepseek-ai/cordis' { |
Two mechanisms sit in that snippet:
declare module '@deepseek-ai/cordis'(TypeScript module augmentation): add members to Cordis’sContextinterface so everyctx.shellin the repo is typed. This is not a Cordis feature, but the Cordis service model depends on it for type checking. Every DSH service and event type is declared this way.- The abstract class is the contract:
ShellExecutoris abstract. Implementation packages such asbash-localandpwshsubclass it, load as plugins, and occupy the namectx.shell. Occupying the name is exclusive. Mount a second implementation and you get a duplicate-service error. Whichever row config selects is the implementation at runtime.
ShellExecutor is only the contract. The code that actually loads as a plugin lives in an implementation package, for example bash-local (packages/shell/bash-local/src/index.ts:95-112,122-123):
1 | /** |
The plugin’s whole obligation is those few lines: inherit the contract (the constructor chain ends at super(ctx, 'shell') and occupies the name), declare its own dependencies (it still needs the lower-level subprocess service), and accept config.
There is no apply. When a class plugin starts, Cordis runs new LocalBashExecutor(ctx, config). The constructor is the startup logic. config comes from the mount: the YAML entry’s config: field, or the config argument to ctx.plugin(X, config). The static Config above validates and fills defaults (timeoutMs defaults to 120 seconds in that schema).
How resolve / run / start are implemented is none of Cordis’s business. Cordis only tracks which plugin instance currently owns the name shell (empty, occupied by bash-sandbox, or swapped to pwsh-sandbox) and when each plugin’s declared dependencies are complete. The doc comment at the top of the quote is worth a pause: a still-running background process is killed and joined at composition teardown, “even across an executor reload.” The implementation author wrote the code under the assumption “I may be reloaded at any time.”
4. inject: Declare Dependencies, Infer Load Order
The second plugin mechanism is inject: declare “I need these services,” and Cordis waits until they exist before starting the plugin. A complete function plugin (packages/web/web-search-exa/src/index.ts:32,35,60):
1 | export const name = 'web-search-exa' |
inject = ['web'] means: keep this plugin waiting until some plugin provides the web service, then run apply. The class-plugin equivalent is a static property (packages/core/agent-loop/src/index.ts:296-297):
1 | /** Concrete agent factory and driver service. */ |
The direct payoff of “declare dependencies, start only when they exist”: you do not care about source order when composing plugins. DSH’s fullest example is agent-spine, a bundle plugin that mounts about twenty child plugins in one go (packages/examples/agent-spine-demo/src/index.ts:212-261):
1 | export function apply(ctx: Context, config: Config): void { |
The file-header comment states the semantics (lines 206-207): “Load order is irrelevant (cordis pends each fiber on its inject until the services it needs exist)”. The last line, AgentLoop, is the class with static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']. The llm, tools, and systemPrompt it declares are provided by earlier plugins in the list (LlmRuntime, ToolRuntime, SystemPrompt, …). Move it to the first line and it still waits until those dependencies exist. It sits last only for readability.
5. Registration Is an Effect: Where Unload Comes From
Inside one registration: where ctx.effect hides
The apply of that provider plugin in section 4 does one real thing:
1 | ctx.web.registerSearchProvider(new ExaSearchProvider({ /* ... */ })) |
That call enters WebRuntime.registerSearchProvider, which forwards to registerProvider in the same file. ctx.effect is hidden there (packages/web/web/src/index.ts:98-105,118-125):
1 | /** |
ctx.effect takes a “do” callback. The callback returns (or yields) an “undo” function. The generator body store.set(...) adds the provider to the registry. The yielded store.delete(...) is the undo. Two more details in the quote are worth naming. The second argument 'web.registerProvider()' is a diagnostic label for this effect. It shows up in getEffects() output (for example when you ask which effects an instance holds, or which undo failed). The default is 'anonymous' and it does not change behavior. The dispose returned by ctx.effect is wrapped and returned to the caller. Automatic execution on plugin unload is the backstop. The caller can also use that handle to undo this one registration early. web-search-exa does not take the handle. It relies on the automatic backstop.
Generators: ctx.effect drives them immediately
After ctx.effect receives the callback, it drives it immediately (vendor/cordis/src/fiber.ts:366,375-382, inside _execute, other return-shape branches omitted):
1 | const effect: Effect = runner.execute.call(this) // call the generator function, get an iterator |
So store.set runs at the moment ctx.effect is called, and the undo function is collected at the same time. The returned dispose is a Cordis wrapper. Calling it runs every collected undo, and only once.
Undo: whoever registers, undoes
The key is who this.ctx points at. Look at that apply from section 4 again. This time watch the parameter and which object the property access happens on (packages/web/web-search-exa/src/index.ts:60-61, provider constructor args omitted):
1 | export function apply(ctx: Context, config: Config): void { |
The ctx that apply receives is web-search-exa’s own plugin ctx (the child ctx from section 2). The ctx.web access happens on that ctx. The WebRuntime you get back is not a bare object. Cordis wraps it and, at wrap time, remembers “which ctx is reading.” Methods called through that wrapper trace this.ctx back to it. So the undo function in registerProvider is registered on web-search-exa’s own plugin instance, not on the WebRuntime instance.
Contrast that with WebRuntime calling this.registerProvider(...) internally (for example to register a built-in provider during its own init). Then this.ctx is its own ctx, and undo belongs to its instance. Removing that entry would mean touching WebRuntime itself.
On the real path, undo belongs to the registrar: the entry lives in WebRuntime‘s table, but the function that removes it is kept by the web-search-exa instance. Each side owns its own concern. WebRuntime only holds the table. It does not need to know who put an entry in, and it does not clean up for anyone else. When web-search-exa unloads, the undo it registered (store.delete) runs automatically and the provider entry disappears from the registry. The registry holder’s lifecycle and the registrar’s lifecycle are not coupled.
The registrar never writes cleanup code. Unload itself is triggered by an orchestration action: delete that entry from cordis.yml, set disabled: true, or replace it with a patch. Loader disposes the matching plugin instance and runs every undo it registered.
“Registered on the instance” also has a concrete meaning. Each plugin instance has a disposal list. ctx.effect pushes the undo function onto it (vendor/cordis/src/fiber.ts:203, 520, Fiber):
1 | public readonly _disposables = new DisposableList<Disposable>() // each instance's disposal list |
On unload, clear the list and run each item (lines 675-676, inside _unload):
1 | await Promise.all(this._disposables.clear().map(async (dispose) => { |
Loader and this list split “trigger” from “execute.” Each entry holds a reference to the matching instance (entry.fiber). When a config change hits disable / delete / replace, Loader calls await fiber.dispose() once (vendor/loader/src/config/entry.ts:130-135, Entry._dispose). It knows nothing about what is on the list. fiber.dispose() starts the instance’s own unload path, and _unload above clears and runs the list. Loader decides when to dispose which instance. The instance decides what disposal does. The only seam is fiber.dispose(). That is how “config-driven unload” and “plugin authors write no cleanup” fit together.
Why a generator: hand over as you go
An ordinary function can only return one undo at the end. A generator can hand over as you go: finish one step, yield that step’s undo. The difference shows up only when something fails halfway. If a multi-step setup fails in the middle (or a dependency changes, or startup is aborted), the undos already yielded have been collected step by step, so completed steps can roll back precisely. A “return at the end” form has nothing to hand over on a mid-setup failure, so the work already done leaks. The generator can also be an async generator: steps may await (async setup) and undos are still collected incrementally. This example has only one setup step, so both forms are equivalent. The generator is DSH’s uniform habit.
A real multi-step example: SessionStore creates a session in two steps, persist then broadcast session/created (packages/core/session/src/index.ts:833-839; the comments are in the source):
1 | // announcing so a throwing `session/created` listener rolls the attach back |
Watch the order: the undo is handed over before the broadcast. The comment states the reason. If a listener throws, the already-yielded undo rolls the persist back, instead of leaving a dirty store entry with hooks attached. That is “hand over as you go” in production form. It is not a fix after failure. The code is written in the order “the next step might explode.”
Async and composition have a real example too. settings-file‘s [Service.init] is an async generator (packages/settings/settings-file/src/index.ts:232-269). Steps await (wait for path resolution, then start a file watcher) and yield* super[Service.init]() collects the base class’s undos into the same chain: the base class’s “load and publish settings” undo and the subclass’s “close the watcher” undo share one reclaim path.
The rule: every registration goes through ctx.effect
Actions like store.set that “put an object into a table another component holds, so the rest of the system can find it” (register a provider, an event listener, a tool, a service) are what DSH calls registration. It has a hard rule: every registration goes through ctx.effect(). At the framework layer that rule is literal. Look at the full provide implementation (vendor/cordis/src/reflect.ts:277-305, property-declaration bookkeeping omitted):
1 | provide(name: string, value?: any, check?: () => boolean) { |
The “do” side: write { name, value, fiber } into the store. If the name is taken, throw immediately. That is the exclusive name occupancy from section 3. If this fiber is already active, notify wakes plugins waiting on the service. The “undo” side is worth reading line by line: drop the name first, notify dependents, then await until they have all stopped, and only then clear the record on this instance. “Consumers stop first, provider withdraws second” is written here. So ctx.provide('dshHomePath', ...) in the section 2 boot, and provide in the section 3 Service constructor, all go through the same register-and-reclaim channel. Unload the provider plugin and the service name disappears. Plugins that declared a dependency on it stop. This rule is the foundation of two advanced DSH capabilities:
- Config hot-reload: change a patch file, Loader unloads the old subtree and mounts a new one. The effect mechanism guarantees that services, tools, and listeners registered by the old subtree are all reclaimed. No middle state.
- Plugin scope isolation: when a subtree mounted by a preset (section 7) unloads, every tool, prompt segment, and event listener it registered disappears automatically. No manual reconciliation.
Event listeners are not an exception. ctx.on (vendor/cordis/src/events.ts:288-301) calls register, and register is ctx.effect (254-259). A listener’s lifetime follows the plugin that registered it. Unload the plugin and the listener is removed. The waterfall extension points DSH builds on top of events (llm/stream, agent/pre-step, and so on) are DSH usage, not a new Cordis mechanism. This post does not unpack them.
6. How to Write a Plugin: Cordis Hides the Complexity
The first five sections took the mechanisms apart. This section asks what writing a plugin actually requires, and what a plugin’s lifecycle looks like.
Below is all the Cordis-related code a service plugin has to write. SandboxBashExecutor is the main class of the bash-sandbox package, the implementation that occupies ctx.shell in the base bundle (packages/shell/bash-sandbox/src/index.ts:44-45,67-72,182, sandbox business logic omitted):
1 | import { Context } from '@deepseek-ai/cordis' |
Count what Cordis requires: inherit the contract (extends LocalBashExecutor, the chain reaches ShellExecutor), declare dependencies (static inject, two more services than the parent: sandbox and sandboxPolicy), and take (ctx, config) in the constructor, where ctx.sandboxPolicy is used directly because inject guarantees it is there. That is all. No register call, no unload code, no lifecycle hooks. resolve / run / start are pure domain logic. The trailing export default lets Loader find the class.
One question remains: after this class is mounted, what does the runtime do with it? That is the timeline below.
Step 0: the contract comes first. @deepseek-ai/dsh-shell defines the abstract class ShellExecutor (section 3): it declares the type of ctx.shell and three abstract methods. It provides no implementation.
Step 1: mount. The orchestration config has one entry (packages/bundle/base/cordis.patch.yml:178-182):
1 | - id: bash-sandbox |
Loader reads it: import the package, create the plugin instance (vendor/loader/src/config/entry.ts:277-285, Entry._init: first this.parent.tree.import(this.options.name, ...), then _start(plugin) to build the instance). Mount is config-driven. At boot, Loader mounts when it expands to this row. Later config changes (hot-reload, patch, preset) also trigger it. The plugin never mounts itself. Notice the disabled expression: on Windows this row is not mounted at all. Its twin pwsh-sandbox (lines 184-186, opposite condition) takes its place.
Step 2: wait. The implementation class declares static inject = ['subprocess', 'sandbox', 'sandboxPolicy'] (the second line of the skeleton above). Until all three services are in place, the instance stays waiting. The constructor does not run.
Step 3: start. The three dependencies are ready. Cordis runs new (the class is the startup callback, sections 2 and 3). static Config validates config first: timeoutMs: 60000 goes through the schema here. The constructor chain reaches super(ctx, 'shell'), provide occupies the name (the implementation at the end of section 5), the instance activates, and notify wakes every plugin that declared it needs shell.
Step 4: service. A consumer (for example the tool-bash plugin) calls it through ctx.shell.run(...). Each call goes through the wrapper layer. this.ctx inside the method belongs to the caller. Registrations the consumer makes through it hang on the consumer’s own instance (section 5).
Step 5: unload. Config disables or deletes the row, or hot-reload rebuilds it. Loader calls fiber.dispose(). The disposal list runs LIFO. Still-running background processes are reclaimed (the “killed and joined” promise in the section 3 doc comment). The provide undo drops the name shell, notifies dependents, and waits for them to stop first.
Step 6: replace (if it happens). Change the entry’s name to another implementation package. The old instance takes step 5 and the name is vacated. The new instance takes steps 2-3 and occupies the name. Plugins that declared they need shell are reloaded onto the new implementation (the “continuous re-evaluation” in section 2).
flowchart TB
S0["0. Contract first
ShellExecutor defines ctx.shell"] --> S1["1. Mount
Loader reads the YAML entry"]
S1 --> S2["2. Wait
inject services are not all ready"]
S2 --> S3["3. Start
new + provide occupies the name"]
S3 --> S4["4. Serve
consumers call ctx.shell.run"]
S4 --> S5["5. Unload
fiber.dispose, LIFO undo"]
S5 --> S6["6. Replace
new provider, dependents reload"]
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class S0 blueClass
class S1 orangeClass
class S2 purpleClass
class S3 greenClass
class S4 tealClass
class S5 redClass
class S6 orangeClass
Composition complexity is collected into the framework in one place. Cordis owns registration, dependency decisions, and reclaim. DSH owns the organizational discipline of “one capability, three packages (Definition / Provider / Consumer)” and static checks on config. What is left for a module author is domain code. So “add a module” has two tiers. Adding another implementation of an existing capability (another search backend, for example) is a few dozen lines of code plus one YAML row.
The cost that remains is opening a new capability. You must prepare the Definition package, the Provider package, and the Consumer package that faces the model or the user at the same time. That is a design decision, not just writing domain code. Those three packages already appeared in section 1: dsh-shell (contract), dsh-bash-sandbox (implementation), dsh-tool-bash (consumer).
7. Composition Is Configuration: cordis.yml, Patch Layers, and Presets
Loader describes the whole system as data. A cordis.yml is a list of entries. Each entry commonly has four fields: id (row identity), name (package name), config (passed to the plugin), and disabled (on/off). A real example (examples/headless-agent/cordis.yml:9,23,44):
1 | - id: settings |
The file’s only “programming” feature is !!js expressions, and they are allowed in only two fields: config (evaluated after that plugin’s dependencies activate, so it can read environment state) and disabled (evaluated on every mount decision, so a whole row can switch by platform). Every other field is a literal. Static checks (scripts/verify-cordis-config.ts) enforce that at commit time.
The tree that actually runs is not a single file. A patch is not a complete tree. It is a modification list addressed by id: each item locates an existing row and replaces that row’s config wholesale (no deep merge). The tree that takes effect is stacked from five patch layers, in order: each bundle’s cordis.patch.yml (in declaration order) → the profile’s own patch → machine-level $DSH_HOME/cordis.patch.yml (applies to every profile, so it overrides the per-profile layer) → CLI --patch → the telemetry switch. When the same row is written by more than one layer, the later write wins.
A real example. The base bundle mounts the HMR plugin (packages/bundle/base/cordis.patch.yml:19-22):
1 | - id: hmr |
The headless bundle writes the same id: hmr and only disabled: true (packages/bundle/headless/cordis.patch.yml:12-15; the comment is in the file):
1 | # The shared module-reload HMR row stays off; the launcher's watch-only |
In the profile’s bundle order, headless comes after base. Patches apply in that order, so HMR is off at headless runtime and the base row is not edited. To see which tree actually runs: dsh --profile web --dump-config.
Up to here, composition is process-level. cordis.yml is the base (layer one). Patch layers modify it (layer two). The whole process has one tree. The third layer is the preset, and it is session-level (apps/cli/config/agent-presets/*/agent.cordis.yml). A preset is an entry list like cordis.yml. The difference is scope. The cordis.yml tree is shared by the whole process, and its services are process-wide singletons. A preset hangs on one session. Different sessions in the same process can hang different presets: one runs “minimal,” one runs “create,” and they do not interfere.
That produces a hard constraint: service rows in a preset must go into a cordis:group with isolate. Otherwise two sessions collide on the same process-level singleton (the persistent-shell row in minimal mode below is this pattern). The CLI’s four modes (standard / PTC / minimal / create) are not a mode field and not an if/else branch. They are four shipped preset directories. Each directory has one composition YAML and one display-metadata file. The entire difference among the four modes is the difference among YAML rows:
| Mode | Preset directory | What the YAML changes |
|---|---|---|
| Minimal | minimal |
Persona occupies the whole system prompt (complete: true); few tools; per-session services sit in an isolate group |
| Standard | standard |
Full coding agent; shell tool is bash or pwsh by platform |
| PTC | code |
Standard plus one tool-presentation row with mode: code |
| Create | cordis |
Standard plus self-modification tools and a composition skill |
- Minimal mode (
minimal/agent.cordis.yml:8,18): persona usescomplete: trueto occupy the entire system prompt and mounts only a few tools. Per-session services go into acordis:groupwithisolate, so two sessions do not collide on one process-level singleton:
1 | - id: persona |
isolate: { terminals: true } means: open a resolution space private to this group for the service name terminals. The pty plugin inside it will provide the terminals service (the PTY registry).
When the same preset is mounted once by each of two sessions, without this isolation the second mount hits the exclusive-name duplicate error at provide (section 5). With it, provide and read inside the group land in the private space. The outside world and the same kind of group in another session cannot see it. Each session gets its own PTY registry. (The true here means this entry is private. Loader also supports a string so several entries share one isolation space. DSH’s shipped config does not use that form.)
- Standard mode (
standard): a full coding agent. The shell tool is chosen by platform (standard/agent.cordis.yml:44-50; the same rows exist in thecodepreset):
1 | - id: tool-bash |
- PTC mode (
code): a complete copy of standard plus one row (code/agent.cordis.yml:259-262). Tool presentation becomes “onerun_codetool plus a generated TypeScript SDK.” The model writes programs that compose multi-step operations:
1 | - id: tool-presentation |
- Create mode (
cordis): a copy of standard, plus self-modification (cordis/agent.cordis.yml:245,255): a toolset that reads, writes, mounts, and unmounts runtime plugins, plus a skill that teaches composition:
1 | - id: tool-cordis |
This last preset’s file header has a trust-boundary statement (lines 9-12): cordis_mount executes model-written JavaScript inside the runtime. A session running in create mode should be treated with the same caution as shell permissions.
flowchart TB
subgraph Process["Process-level tree"]
direction TB
Y["cordis.yml base"] --> P1["bundle cordis.patch.yml"]
P1 --> P2["profile patch"]
P2 --> P3["DSH_HOME/cordis.patch.yml"]
P3 --> P4["CLI --patch"]
P4 --> P5["telemetry switch"]
end
subgraph Session["Session-level presets"]
direction LR
M["minimal"]
S["standard"]
C["PTC / code"]
R["create / cordis"]
end
Process --> Session
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class Y blueClass
class P1 orangeClass
class P2 greenClass
class P3 purpleClass
class P4 tealClass
class P5 redClass
class M blueClass
class S orangeClass
class C greenClass
class R purpleClass
The mechanical result: adding a fifth mode does not require changing any TypeScript. Copy a preset directory and edit YAML. That is “composition is configuration” in the literal sense.
8. Mapping: Which Cordis Feature Each Usage Eats
Fold the first seven sections back onto Cordis features:
| Cordis feature | Usage in DSH |
|---|---|
Context tree + service registration (provide) |
Everything is a plugin. ctx.shell / ctx.llm / ctx.web are all registered by packages (section 3) |
inject dependency declaration + start when available |
Composing plugins does not care about order. agent-spine mounts about twenty children at once (section 4) |
Reversible ctx.effect registration |
Provider registration and event listeners are reclaimed when the plugin unloads. Config hot-reload leaves no middle state (section 5) |
| Loader declarative composition | cordis.yml + stacked patches (process-level), presets (session-level). Four modes are four YAML files (section 7) |
isolate |
Per-session services in a preset go into an isolate group. Multiple sessions in one process do not interfere (section 7) |
Paper section 1.2.2, “Self-Evolving Agent Harnesses,” lists this as a motivating example: the harness generates and replaces its own components while it keeps serving. DSH’s create mode is a productized slice of that scenario. The model edits its own runtime plugin tree through tools such as cordis_mount. It can do that safely because of every row above: dependency declarations decide when to start, effects decide how to unload cleanly, and Loader turns “the system shape I want” into readable, writable data.
9. Developer View: Why DSH Is a Meta Harness
The end of section 8 said create mode lets the model edit the runtime plugin tree directly. Next: why that capability holds, and what it means once it does.
A harness’s job is assembling the model’s context: system prompt, tool list, environment information. In the earlier sections, each of those fragments has a clear owner. persona owns prompt text (minimal mode in section 7 uses it to occupy the entire system prompt). systemPrompt owns prompt segments. tools owns the tool registry. Those fragments are all “effect registrations” from section 5: reversible, replaceable, hot-reloadable.
Changing harness behavior then becomes a local act. The article already has a ready example: swap the shell implementation from bash-local to bash-sandbox (the lead of section 6) and execution policy goes from unrestricted to sandboxed. The consumer tool-bash does not change a line (the mutual non-import in section 1). Hot-reload applies the change. No process restart (section 5). Want different file-access permission logic, a different search backend, or a different prompt organization? Same motion: write an implementation package, change one YAML row. You do not hunt for scattered handling points in glue code.
That workflow is isomorphic for humans and for agents. Each plugin’s inputs and outputs are verifiable: config goes in, registration comes out, behavior is measurable. So “debug how this piece of context works” can be handed to the agent itself: change a plugin, hot-swap, verify, close the loop. Create mode productizes that loop and attaches an explicit trust-boundary statement.
That is enough to say what a meta harness means. DSH does not prescribe what an agent looks like, because the agent loop itself is just one of the twenty-odd lines in the agent-spine list (section 4). What it provides is a composition mechanism. The shape of the harness is decided by configuration. What is fixed first is context structure: which system-prompt segments, which tools, how environment information enters. Code (plugins) fills that structure. You do not write the code first and let context structure emerge from it. Extension points are no longer hook callbacks scattered through the code. Everything is a plugin.
That leaves a lot of room to explore. I will keep watching what gets built on DeepSeek Harness next. (Yes, that last line is a small ad.)
This is Part 1 of the DeepSeek Harness series.
Comments