跪拜 Guibai
← Back to the summary

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.

image.png

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.

image.png

Three keywords form the three keys to understanding dsh:

  1. Cordis Plugin Tree—All capabilities are services mounted on a shared ctx (context), discovering each other through keys.
  2. Capability Seam—Each replaceable capability is split into three roles: "definition / implementation / consumer." Swapping one implementation can change the entire product behavior.
  3. 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):

At startup, dsh starts from an empty entry list and stacks in order: each bundle's patch listed in the profileprofile's cordis.patch.ymlhome-level patch--patch command-line overlay.

image.png

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):

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):

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:

image.png

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.

  1. 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.

  2. 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.

  3. 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.

  4. Waterfall extension points. The set of wrapping middleware agent/pre-step, agent/request, tools/pre|execute|post unifies "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