DeepSeek Harness Turns the Agent Loop Itself Into a Plugin
This article is based on the public source code of deepseek-ai/deepseek-harness (developer preview stage). Code references are annotated with package paths and key locations. All
packages/...paths in the text are relative to the repository root.
1. Why This Codebase Is Worth Reading
Most agent frameworks on the market are "one main loop + a bunch of hardcoded capabilities (tools, model adapters, memory)." To add a capability, you often have to modify the main loop, change prompt concatenation, or alter tool dispatching—the core has "privileges," and extensions are patches. The cost of modification is high, and changing a step with multiple dependencies is often difficult.
In engineering design, the plugin concept is not new. In early Android app design, we would put each activity into a module, which led to severe coupling between business components and extremely high governance costs later on. With the spread of componentization thinking, developers began solving this problem with routing, and this idea is exactly consistent with DeepSeek's approach.
DeepSeek Harness (hereafter dsh) does the opposite: it makes the main loop itself a plugin. Its architectural creed is a single sentence—Everything is a Plugin. The model adapter is a plugin, the tool registry is a plugin, the session log is a plugin, even the agent loop is a plugin. There is no "core that must be patched"; the way you extend dsh is always "hang another plugin beside it," not "modify existing things."
This capability is built on a plugin framework called Cordis (dsh has vendored it into the vendor/ directory). The entire repository is a pnpm monorepo with 200+ workspace packages under packages/, all named @deepseek-ai/dsh-<name>.
From Cordis's five core concepts, to the agent loop's turn/step state machine, to capability seams, event sourcing, tool pipelines, Typert RPC, and finally back to its engineering design—after reading, you will understand what a "fully replaceable" agent framework looks like at the code level.
2. Architecture in One Sentence
First, a global diagram; the following chapters fill in the details for each part of this diagram.
Three keywords form the three keys to understanding dsh:
- Cordis Plugin Tree—All capabilities are services mounted on a shared
ctx(context), discovering each other through keys. - Capability Seam—Each replaceable capability is split into three roles: "definition / implementation / consumer." Swapping one implementation can change the entire product behavior.
- Event-sourced Session—The single source of truth for agent interaction is an append-only event log. The conversation history the model sees is derived from the log, not stored separately.
3. Cordis: Five Core Concepts
Before reading dsh, you must understand Cordis. It has only five concepts (from docs/cordis-primer.md):
| Concept | One-liner |
|---|---|
| Plugin | An object implementing Service: can be a function with inject/apply(ctx), or a Service subclass |
| Context | A repository of services. Services occupy stable ctx.<key> (e.g., ctx.tools, ctx.llm); others find them by key, not by importing a specific implementation |
| inject | A plugin declares which services it needs; Cordis activates it only after these services are ready—loading order is expressed by dependencies, not a hand-written boot sequence |
| Typed Event | Services register event names via TS declaration merging, then dispatch them in four modes: emit / waterfall / parallel / serial |
| Effect | All registrations (prompt segments, tools, adapters, listeners) are done via ctx.effect() / ctx.on(), and are rolled back in order upon unload |
3.1 Two Ways to Write a Plugin
Both styles coexist in dsh. Functional plugin (exports name, inject, apply):
// packages/todo/tool-todo/src/index.ts
export const name = 'tool-todo'
export const inject = ['tools']
export function apply(ctx: Context, config: Config): void {
// Optional seam "on-demand activation": only runs if sessionProjections is assembled
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register({ key: 'todos', init, apply, ... })
})
ctx.tools.register(defineTool({ name: 'todo_write', description, parameters, output, execute }))
}
Service subclass (declares hard dependencies via static inject):
// packages/goal/goal/src/index.ts
export class GoalService extends TypertRemoteService {
static inject = ['agents']
static Config = z.object({ defaultMaxGoalRounds: z.number().default(256) })
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'goals') // occupies ctx.goals
ctx.on('agent/session-start', ({ agent }) => { ... }) // subscribes to events
}
@Remote('edit') /* ... */ // method exposed as RPC
}
There is an important convention throughout the repository: ctx.inject(['x'], cb) indicates an optional dependency—this sub-fiber activates only if x exists; static inject = ['x'] indicates a hard dependency—the plugin won't even be constructed if x doesn't exist. The optionality of a capability is expressed through dependency declarations.
3.2 Four Event Dispatch Modes
The dispatch mode of an event is part of its public contract; new events must be annotated with @mode:
| Mode | Awaits | Order | Has Return Value |
|---|---|---|---|
emit |
No | Registration order, pure observation | No |
waterfall |
No | Registration order | Yes (wrapping middleware) |
parallel |
Yes | Parallel | No |
serial |
Yes | Registration order | Yes |
Among these, waterfall is the most critical extension mechanism. It is "wrapping middleware": the listener signature is (...args, next). Calling next() passes the (potentially modified) result to the next service; not calling next() short-circuits. This is the unified pattern for "interception / rewriting / policy decisions" in dsh—for example, intercepting model requests, vetoing tool calls, injecting prompts, all go through waterfall.
3.3 All Registrations Are Reversible Effects
// Typical: register a tool, return a disposer
return this.layers.effect(this.ctx, layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' })
Because registration is an effect and effects come with disposers, dsh supports Hot Module Replacement (HMR): modify a plugin's code, and it, along with the tools/prompts/listeners it registered, is cleanly unloaded and reloaded without leaks.
4. Profile and Bundle: How the Plugin Tree Is Assembled at Startup
Since everything is a plugin, "a runnable dsh" is essentially a plugin tree assembled at startup. dsh describes this assembly with a two-level structure (packages/boot/app-boot/src/profile.ts):
- Bundle: An npm package that declares
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }in itspackage.json. It is the distribution format for "a batch of Cordis config lines + the code that mounts them." - Profile: Exists at
$DSH_HOME/profiles/<name>/. Thedsh.profile.bundlesin itspackage.jsonlists the bundles to stack in order, plus the user's owncordis.patch.yml.
At startup, dsh starts from an empty entry list and stacks in order: each bundle's patch listed in the profile → profile's cordis.patch.yml → home-level patch → --patch command-line overlay.
A patch locates a specific line by id and wholly replaces its config (not a merge), or inserts new lines. "Last write wins."
dsh-base is the first layer of every profile (model adapters, tools, persistence, sandbox & approval policies, settings, credentials, telemetry). web profile = [dsh-base, dsh-web-app], headless profile = [dsh-base, dsh-headless].
Let's look at a real base bundle patch (packages/bundle/base/cordis.patch.yml):
- insert:
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: session
name: '@deepseek-ai/dsh-session'
- id: agent-default-model
name: '@deepseek-ai/dsh-agent-default-model'
config: { provider: deepseek-official, model: deepseek-v4-flash }
- id: settings
name: '@deepseek-ai/dsh-settings-file'
Now see how the headless bundle overrides a base line + disables a line + appends its own lines (packages/bundle/headless/cordis.patch.yml):
- id: system-prompt # Overrides config of the line with the same id in base
config:
persona: >-
You are a coding agent powered by the {{model}} model.
- id: hmr
disabled: true # Disables a line
- insert:
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.headlessStartup.task # !!js expressions are allowed in patches
Note !!js: Cordis's loader parses it into an expression node and evaluates it against the plugin context after dependencies are ready. This enables "environment-driven conditional assembly" (e.g., !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash').
Want to see the actual tree your machine boots?
dsh --profile web --dump-config. Every line can be replaced by your own patch—this is what "no privileged core" looks like at the usage level.
5. Capability Seam: The Three-Role Model
This is dsh's most core composability mechanism and the key to understanding how "Everything is a Plugin" is implemented in practice.
A seam is a "replaceable capability," composed of three roles (docs/glossary.md):
- Service Definition: An abstract class declaring the interface, occupying a certain
ctx.<key>and related vocabulary types. It must be an abstract class or a concrete registry. - Service Provider: A concrete class implementing it; there can be multiple.
- Consumer: The party that injects and uses this service, typically a model-facing tool.
One role does not constitute a seam; all three roles together do. Below, we illustrate with the classic packages/shell trio.
Definition (Service Definition)
// packages/shell/shell/src/index.ts
declare module '@deepseek-ai/cordis' {
interface Context { shell: ShellExecutor } // Declaration merging: attaches type to ctx.shell
}
export abstract class ShellExecutor extends Service {
constructor(ctx: Context) { super(ctx, 'shell') }
get sandboxMode(): SandboxMode | undefined { return undefined }
abstract resolve(request: ShellExecRequest): ShellExecSpec // Request → Spec
abstract run(spec: ShellExecSpec): Promise<ShellRunResult> // One-shot execution
abstract start(spec: ShellExecSpec): ShellProcess // Background process
}
Implementation (Service Provider)
// packages/shell/bash-local/src/index.ts
export class LocalBashExecutor extends ShellExecutor {
static inject = ['subprocess']
static Config = z.object({ timeoutMs: z.number().default(120_000) })
resolve(request) { /* completes workdir, clamps timeout, passes sandboxPolicy as-is */ }
async run(spec) { return this.runArgv(spec, ['bash', '-c', spec.command]) }
start(spec) { return this.startArgv(spec, ['bash', '-c', spec.command]) }
}
Consumption (Consumer)
// packages/shell/tool-bash/src/index.ts
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
export function apply(ctx: Context): void {
const defaultMode = ctx.shell.sandboxMode // Only depends on the abstract key
ctx.tools.register(defineTool({ name: 'bash',
async execute(args, exec) {
return await ctx.shell.run(ctx.shell.resolve({ ...request }))
}}))
}
The key point is: tool-bash only depends on the abstract ctx.shell, never knowing whether bash-local, bash-sandbox, or pwsh-local is mounted behind it.
From the official docs: The filesystem and subprocess providers share the same "execution world," so pointing them to a remote sandbox moves Bash, PTY, and LSP along with them as a whole, without forking a single provider. Swapping one implementation changes the entire product's capability boundary—this is "why a single provider replacement can change the entire product" via seams.
Seams are everywhere in dsh: ctx.fs (filesystem), ctx.web (search/fetch), ctx.lsp (language service), ctx.sandbox (process sandbox), ctx.compaction (context compression), ctx.subagents (sub-agents). Some seams allow only one provider (like shell), while others allow multiple providers to coexist by name (like subagent, llm adapters).
6. Session Event Log: Event Sourcing Is the Foundation of the Entire System
Before discussing the agent loop, we must first discuss the session log, because the loop itself holds almost no state; everything is derived from the log.
A Session is an append-only SessionEvent log (packages/core/session), the single source of truth for all agent interaction history. SessionEventMap is a mergeable and extensible interface (plugins add events to it via declaration merging), with core members:
// packages/core/session/src/types.ts
interface SessionEventMap {
'turn/start': { turn: number }
'turn/end': { turn: number; reason: TurnEndReason }
'step/start': { turn: number; step: number }
'step/end': { turn: number; step: number }
'user/message': UserMessage
'assistant/chunk': { turn; step; chunk: StreamChunk } // Raw stream fragments
'assistant/message':{ turn; step; message: AssistantMessage; usage?; interrupted?: true }
'tool/call': { turn; step; callId; name; arguments: string } // Raw unparsed JSON
'tool/result': { turn; step; message: ToolResultMessage; error?; meta? }
'todo/write': { todos: TodoItem[] }
// compaction/*, hook/*, etc., are appended by respective plugins via declaration merging
}
There is an ironclad rule written into AGENTS.md that runs through the entire repository:
Model-visible ⟺ logged: Anything that reaches the model request must be reconstructable from the log. A new "model-visible input" necessarily requires a new session event.
7. Agent Loop: Turn / Step State Machine Breakdown
Now to the main loop. First, let's clarify the vocabulary at three levels (docs/glossary.md):
- step: One model request + the tool executions it triggers.
- turn: One drain of "accepted inputs," containing 0 or more steps. Opens before input is claimed, closes after debts are paid.
- round: An outer strategy iteration (e.g., goal round, Ralph round).
The agent loop is carried by two files (packages/core/agent-loop/src/): index.ts is the factory plugin (AgentLoop extends Service), and agent.ts is the per-session driver (ReactLoopAgent).
The factory registers itself as an agent factory:
// packages/core/agent-loop/src/index.ts
export class AgentLoop extends Service {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
// ...
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
}
Note this line static inject: the agent loop itself is just an ordinary plugin declaring 5 dependencies. Want to replace the entire loop? Implement the Agent interface, swap ctx.agents.setFactory with your factory—this is the literal meaning of "even the agent loop is a plugin."
7.1 Loop Driver
// packages/core/agent-loop/src/agent.ts —— Phase = idle | maintenance | running
private async kick(): Promise<void> {
try { while (await this.turn()) {} } // Keeps driving turn until it returns false
catch (_error) { /* caught at driver boundary */ }
finally { /* falls back to idle; if inbox still has pending items, wakes up again */ }
}
The skeleton of turn():
append turn/start
loop:
preStep() // Claim input + assemble prompt + agent/pre-step waterfall
append step/start
append each claimed user/message
step() // Request model + dispatch tools
append step/end
if completed/max-tokens and next-step inbox is empty → break
append turn/end
7.2 preStep: Claim Input + Assemble Prompt
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
const sections = renderContextSections(assembly)
const decision = await this.dispatch.waterfall('agent/pre-step', {...}, ...) // ★Extension point
agent/pre-step is a waterfall that decides what the model will see: listeners can rewrite claimed messages or directly veto them. The compaction plugin dsh-compaction-basic hooks here, checking context pressure before request dispatch and triggering compaction if necessary. A vetoed/empty first claim still closes a persistent turn that "spent no steps"—the log records this attempt.
7.3 step: Request Model + Stream-to-Log + Dispatch Tools
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal) // ★Derives history from log
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
for await (const chunk of stream) {
this.session.append('assistant/chunk', { turn, step, chunk }) // Every fragment logged (replay fidelity)
assembler.push(chunk)
}
// Assembles into assistant/message and logs it
const toolCalls = message.content.filter(b => b.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
await executeToolCalls(this.loopCtx, turn, step, toolCalls, signal, ...) // Dispatch tools
buildRequest() runs the agent/request waterfall (allowing plugins to reroute model selection), resolves the adapter via llm.prepareCall, logs a request/header event if request headers change, and finally freezes GenerateOptions.
7.4 How Input Enters: Inbox's Four Delivery Semantics
The driver has only one inbox (packages/core/agent/src/inbox.ts), internally holding two queues next-turn / next-step, and can be reconstructed from persisted agent/inbox/spliced events. The agent exposes four delivery methods (agent.ts): send / followup (queue for next turn), steer (cut in line for the current step), inject (inject context, quietly waiting for the next claim). Some messages immediately wake the driver; injected context waits in the inbox until another message takes it along.
7.5 Tool Dispatch: Model-Order Submission, Bounded Concurrent Execution
executeToolCalls() (tool-calls.ts) iterates through calls in the order given by the model, asking the registry ctx.tools.executionMode(exec) for each: parallel calls enter a bounded rolling pool for concurrent execution, exclusive calls form a barrier. But results and context are strictly committed in model order (commitReady()), ensuring replay determinism. maxParallelToolCalls is read from config; on abort, skipped calls get a synthetic error result, keeping log replay valid.
7.6 Complete Sequence
sequenceDiagram
participant User
participant Agent
participant Driver
participant Hooks as hook listeners
participant LLM as ctx.llm
participant Tools as ctx.tools
participant Session
User->>Agent: followup(content)
Agent->>Driver: Queued work wakes driver
Driver->>Session: turn/start
Note over Driver: Claim next-step input + one queued message
Driver->>Hooks: agent/pre-step (waterfall) → reject | enter(messages)
Driver->>Session: step/start + user/message*
Driver->>Hooks: agent/request (waterfall, can change model routing)
Driver->>LLM: llm/stream (waterfall)
LLM-->>Driver: StreamChunk*
Driver->>Session: assistant/chunk* → assistant/message
loop Each tool call
Driver->>Session: tool/call
Driver->>Tools: pre-execute → execute → post-execute
Driver->>Session: tool/result
end
Driver->>Session: step/end
opt Natural stop and inbox empty
Driver->>Hooks: agent/turn-stopping (serial termination checkpoint)
end
Driver->>Session: turn/end
In the diagram, turn/*, step/*, user/message, assistant/*, tool/* are persistent session events; agent/pre-step, agent/request, llm/stream, and the three tools/* are waterfall extension points (listeners must call next() to continue the chain); agent/turn-stopping is serial, without next().
8. Tool Registry and Guarded Execution Pipeline
Tools are the model's hands. dsh completely separates "registration" and "guarded execution" (packages/core/tools).
8.1 Registration: Scoped + Reversible
// register() validates definition (requires output {schema, render}, rejects reserved name run_code), returns disposer
return this.layers.effect(this.ctx, layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' })
Tools are scoped: there are global tools and tools attached to a specific agent scope. restrict() can block the global tool set for a specific agent—filtered-out tools are invisible in the prompt and rejected at execution, indistinguishable from "non-existent." This is the underlying mechanism for "per-agent persona / tool variants" (most-specific-wins shadowing).
8.2 Guarded Pipeline
A tool call doesn't directly run execute; it passes through a pipeline:
tools/pre-execute(waterfall): Hooks, permissions, sandbox policies are here. Followed byctx.approvalapproval resolution, then monotonic guards—guards can only "reject or abstain," never "force allow," which is a security invariant.tools/execute(wrapping waterfall): Cross-cutting concerns like timeout, retry, metrics wrap here, inside which is the tool bodyexecute(args, exec)(which fuses the caller's and wrapper's abort signals).tools/post-execute(waterfall): Can accept, block, replace results, or append context. Followed by the definition's ownfinalizeContent(final content invariant), and finallytools/resultobserves that frozen, lossless-JSON authoritative result.
The value of this design: hooks can add policies across the entire tool family (bash, fs, web…) without coupling any tool to a specific policy service. The filesystem's "read-before-write" check is an independent plugin (dsh-fs-observation-policy) hooked on fs/* events, modifying no tool's schema.
8.3 Built-in Tools
Judging from the generated tool catalog, dsh's built-in model-visible tools have fairly complete coverage:
| Category | Tools | Backing seam |
|---|---|---|
| Shell | bash / pwsh (one-shot + persistent PTY versions) |
ctx.shell / ctx.terminals |
| File | read / write / edit / read_image / str_replace_editor |
ctx.fs |
| Search | glob / grep (packaged ripgrep via ctx.subprocess) |
ctx.subprocess |
| Web | web_search / web_fetch |
ctx.web |
| Code Intelligence | lsp |
ctx.lsp |
| Delegation | subagent / subagent_fork / send_message / list_agents |
ctx.subagents |
| Orchestration | workflow / ralph (fresh-agent loop) |
ctx.workflowEngine |
| Background | job_list / job_output / job_kill |
ctx.jobs |
| Session State | todo_write / create_goal / schedule_create |
Respective seams |
| Interaction | ask_user_question / exit_plan_mode |
ctx.userQuestions |
| Code Mode | run_code (reserved transport) |
ctx.codeRuntime |
Summary: What Makes This Architecture Good, and What Can Be Borrowed
Everything is a Plugin.
No privileged core. Even the agent loop, model adaptation, and tool registry are plugins; registration is a reversible effect. This makes "extension" always "add a plugin" rather than "modify a core part," and makes hot reload a natural result.
Capability seam three-role model. Each replaceable capability is split into "abstract definition / concrete implementation / consumer," with consumers depending only on the abstract key. Thus, a major change like "point shell/fs to a remote sandbox" degrades into "swap a provider," and Bash/PTY/LSP follow as a whole.
Event sourcing as the source of truth. The loop holds almost no state; model history is derived on the spot from the append-only log via
deriveMessages(). Fork, resume, replay, telemetry, and compaction are all unified on a single event stream; the invariant "model-visible means logged" makes the entire system naturally auditable and replayable.Waterfall extension points. The set of wrapping middleware
agent/pre-step,agent/request,tools/pre|execute|postunifies "interception / rewriting / policy" into a single pattern—permissions, approval, sandboxing, compaction, timeout, and retry are all independent plugins hooked on the same set of waterfalls, decoupled from each other.
References
- Repository: https://github.com/deepseek-ai/deepseek-harness
- Architecture docs:
docs/architecture.md,docs/cordis-primer.md,docs/glossary.md,docs/capability-seams.md,docs/tool-execution-pipeline.md,docs/agent-lifecycle.md,docs/api-gateway.md - Underlying framework Cordis: https://github.com/cordiverse/cordis