DeepSeek Harness Developer Preview: Running, Configuring, and Lessons Learned
DeepSeek recently open‑sourced its Agent Harness framework dsh – a plugin‑driven AI agent framework built on top of Cordis. I spent a couple of days getting it up and running, and also went through the research paper that underpins Cordis. This post walks through the installation, the core concepts you actually need to understand, and a few sharp edges I hit along the way.
What Problem Does It Actually Solve?
AI agent harnesses tend to become monolithic over time: tool calling, memory, sub‑agent orchestration, permission controls, session state – they all live in one process and depend on each other. If one module misbehaves, or you want to swap out a tool implementation at runtime, the conventional answer is to restart the entire process.
dsh takes a different route: every feature is a plugin, and every side‑effect a plugin produces during loading (registering routes, creating timers, injecting services into the context) must be automatically rolled back when that plugin is unloaded. In other words, the system should return to exactly the state it was in before the plugin was ever loaded.
This is in direct contrast to how VSCode extensions work. The Cordis paper explicitly calls out VSCode: among the top 100 extensions, 87% contain executable code, and disabling or uninstalling any of them requires a full restart of the extension host process. dsh brings that granularity down to the individual plugin level.
Installation and First Run
The quickest way to start is the official one‑liner:
npx @deepseek-ai/dsh web
This launches a Web UI, which by default listens on http://127.0.0.1:3080.
If you prefer running from source, the steps are slightly longer:
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web
I used Node.js 20.x and pnpm 8.x. If pnpm install stalls, it’s almost always a network issue – switching to a mirror or setting a proxy helps:
pnpm config set registry https://registry.npmmirror.com
If pnpm dsh web complains about a missing command, it’s because pnpm doesn’t automatically expose local binaries to your PATH. Use pnpm run dsh web instead, or install @deepseek-ai/dsh globally first.
Once the server starts, open http://127.0.0.1:3080 in your browser. The UI itself is another Cordis application – every UI component is also a plugin that communicates with the backend through the ctx object.
Everything Is a Plugin: The Cordis Context Model
dsh’s architecture is entirely built on Cordis. Understanding the context model is more important than memorising CLI flags.
At the heart of Cordis is a context object called ctx. Every plugin receives its own dedicated ctx when loaded. Through this context, a plugin does two things:
-
Declare what it needs (dependency injection): ctx.injectlists the service keys it depends on. -
Declare what it provides (service registration): ctx.providebinds a key to an implementation.
When a plugin activates, Cordis checks whether all the keys listed in inject are currently satisfied in the environment. If they are, it executes the plugin’s apply function; if not, the plugin stays inactive until all dependencies become available.
This is similar to OSGi Declarative Services, but Cordis adds a critical twist: when a dependency disappears, every plugin that depends on it is automatically unloaded, and the unload process first notifies all dependents, creating an ordered teardown chain.
A Concrete Example
Suppose you have a database plugin and a logging plugin. The logger depends on the database service provided by the database plugin. Here’s how that looks in code:
// database plugin
export default {
name: 'database',
provide: {
database: true
},
apply(ctx) {
ctx.set('database', new DatabaseConnection())
// When unloaded, ctx.get('database').close() is called automatically
}
}
// logger plugin
export default {
name: 'logger',
inject: ['database'],
apply(ctx) {
const db = ctx.get('database')
// work with db
}
}
When the database plugin is disabled, Cordis first tells the logger: “the database you depend on is gone”. The logger enters the UNLOADING state, runs its own cleanup, and only then does the database plugin actually unload.
The paper formalises this ordering as Theorem 63 (Ordering): a provider can only execute its own unload after every consumer that depends on it has already finished unloading.
Lifecycle and State Machine
Each plugin instance in Cordis is called a fiber. Its lifecycle is roughly Figure 2 from the paper:
INACTIVE → [activation] → LOADING → (iterations) → ACTIVE
↓
[dependency change]
↓
UNLOADING → (wait for dependents) → INACTIVE
One real issue I ran into: a plugin might be mid‑activation when a service it depends on gets unloaded. For example, your apply function does three ctx.set calls, but the third one fails – or a dependency disappears during an iteration.
Cordis handles this by recording an inverse operation for every step. The paper’s Algorithm 1 shows an effect function: every ctx.set returns a dispose function, and these dispose functions are composed in LIFO order into a single accumulator. When the plugin needs to be unloaded, that accumulator is invoked, rolling back all modifications in reverse order.
In the paper’s terms, at any point during a plugin’s execution, calling the accumulated inverse restores the context to exactly the state before the plugin started. Theorem 7 calls this recovery exactness.
Configuration and Hot Module Replacement
Configuration is declared via YAML or JSON files. Each plugin entry looks like this:
- id: my-logger
url: ./plugins/logger.js
config:
level: info
disabled: false
The disabled field toggles the plugin. Flip it from true to false, and the loader performs a hot replacement: unloads the old plugin, loads the new one, all without restarting the process.
The @cordisjs/hmr component drives hot replacement by detecting file changes, marking which modules are “affected” (accepted) and which entries are “stale”, then performing the swap inside a transaction – if any module fails to load, the entire operation rolls back to the previous state.
The exact algorithm is detailed in the paper’s Algorithms 8‑10. In essence: hot replacement is not a restart‑and‑replay; it applies a differential update on the current state. If the update fails, the state rolls back to where it was before the update, not to some intermediate broken state.
// This transaction logic lives inside @cordisjs/hmr
try {
for (entry of stale_entries) {
entry.fiber.dispose() // rollback old plugin
entry.fiber = ctx.use(newComponent) // load new one
}
} catch (error) {
restore_caches(backup) // full rollback
throw error
}
Common Pitfalls and Sharp Edges
1. Circular dependencies between plugins
Cordis does not resolve cycles for you. If A depends on B and B depends on A, both stay INACTIVE forever. You’ll see this in the logs – the target view never becomes satisfiable, but the plugin won’t error out either. The fix is to split bidirectional interactions into two unidirectional dependencies and add an integration plugin that pulls both together.
2. Key collisions
Dependency keys are plain strings. If two independently developed plugins use the same key name but mean completely different things, you’ll get silent runtime mis‑wiring. The recommended practice is to namespace your keys, e.g., @myorg/database instead of just database.
3. Calling ctx.get before the plugin is active
If a plugin declares inject: ['service'] but tries to call ctx.get('service') outside apply – say, at the top level – it throws an INACTIVE_ACCESS error. Dependencies are only available inside apply or functions called from it. Keep all dependency access within the activation boundary.
4. Asynchronous cleanup
When a plugin unloads, its dispose functions may be asynchronous. Cordis waits for them – but the paper’s Algorithm 5 shows that unload calls await all(notify(...).map(f => f.await())), waiting for all dependents to finish unloading first. If your dispose does heavy work, it blocks the whole unload chain. Keep cleanup operations light, or offload long tasks to background processes.
5. Plugin load order
The loader resolves activation order based on inject relationships, not on the order in the configuration file. If you need one plugin to start before another, declare a dependency. If you don’t want a dependency, don’t rely on ordering. If you absolutely must sequence unrelated plugins, use a “ready” key as a manual synchronisation point.
Final Thoughts
dsh is still in developer preview, but after running it for a while, the biggest takeaway is that plugin lifecycle management feels clean – unload a plugin and no leftover side‑effects remain. The trade‑off is that you need to buy into the Cordis dependency model and provide a proper inverse for every set operation.
To decide if it’s worth your time, ask two questions:
-
Does your system need to add or remove components at runtime? -
Are you willing to structure your code around dependency injection to gain that flexibility?
If both are yes, dsh is definitely worth a spin.
Quick Checklist
-
[ ] Install Node.js 20+ and pnpm -
[ ] Run npx @deepseek-ai/dsh webfor a quick start, or build from source withpnpm install && pnpm run build && pnpm dsh web -
[ ] Open http://127.0.0.1:3080to confirm the Web UI is up -
[ ] Write a minimal plugin: declare injectand usectx.setto register a service -
[ ] Toggle disabled: truetofalsein the config and observe hot replacement -
[ ] Verify that after unload, the service is no longer reachable via ctx.get
FAQ
Q: What does dsh give me that a plain Node.js script doesn’t?
A: Built‑in plugin lifecycle management, dependency injection, hot replacement, and state rollback. If you need those, you don’t have to implement them yourself.
Q: Do plugins have to communicate via injection only?
A: It’s the recommended way. You can use global variables or direct module imports, but those bypass the lifecycle system and won’t be tracked for cleanup.
Q: Does hot replacement lose in‑memory state?
A: The plugin’s own internal state is rolled back, but data stored in external services (like a database) persists. If you need to preserve state, keep it in a long‑lived dependency.
Q: I get Module not found – what do I do?
A: Check the url path. The loader uses standard Node.js import(), and relative paths are resolved against the configuration file’s directory.
Q: Where are plugin logs?
A: By default, they go to the console. You can configure a logger plugin to write to files.
Q: What happens with a circular dependency?
A: Both plugins stay inactive forever. You need to refactor the bidirectional dependency into two unidirectional ones.
Q: Can I run multiple dsh instances?
A: Yes, each on a different port. But watch out for file locks if they share the same configuration directory.
