17 KiB
DeepSeek Harness Architecture
This document describes the architecture of the DeepSeek Harness — the foundation of DeepSeek Code. The governing principle, from the microkernel design discussion: everything is a plugin. The core is deliberately tiny — a handful of abstract services plus one concrete loop plugin (dsh-agent-loop) — and every product feature is a plugin against the extension surface described here, without modifying the loop.
This document covers behavior; type shapes live in core-data-structures/, the per-event/service reference in the generated catalog, visual relationship maps in the documentation graph index, and per-package contracts in the package READMEs (map). Requirement context: Coding Harness MVP 需求分析.
Layering
┌────────────────────────────────────────────────────────────────┐
│ extension + implementation plugins │
│ dsh-agent-loop — THE concrete loop plugin │
│ LLM adapters · executors/backends · model-facing tools │
│ subagent providers · hook bridges · UI bridges │
├────────────────────────────────────────────────────────────────┤
│ interface/service packages (each owns a ctx key + vocabulary) │
│ dsh-agent · dsh-tools · dsh-system-prompt · dsh-session │
│ dsh-llm · dsh-bash · dsh-fs · dsh-web · dsh-compact │
│ dsh-subagent · dsh-session-persistence │
├────────────────────────────────────────────────────────────────┤
│ vendor/: pinned Cordis framework source (cordis, loader, …) │
└────────────────────────────────────────────────────────────────┘
Dependency rule: extension plugins depend on interfaces, never on dsh-agent-loop (the loop is swappable); the sanctioned exception is the composition bundle dsh-agent-core, whose job is assembling the concrete spine (full rule + generated graph).
Service map
| ctx key | Package | Role |
|---|---|---|
ctx.llm |
dsh-llm | adapter registry; stream() |
ctx.sessions |
dsh-session | creates/holds event-sourced Sessions |
ctx.sessionPersistence |
dsh-session-persistence | durable persistence: create/append/load/list |
ctx.systemPrompt |
dsh-system-prompt | ordered sections + tool schemas → assemble() |
ctx.tools |
dsh-tools | tool definitions; execute() through waterfall |
ctx.agents |
dsh-agent | live Agent handles + create/resume factory (returns AgentHandle { agent, dispose() }) |
ctx.agentLoop |
dsh-agent-loop | creates and drives ReactLoopAgents |
ctx.bash |
dsh-bash | bash execution: foreground runs + background tasks |
ctx.fs |
dsh-fs | filesystem provider: read/stream, atomic writes/edits; owns the fs/* policy events |
ctx.compact |
dsh-compact | compaction: detect pressure, summarize an older range |
ctx.web |
dsh-web | search/fetch provider registries + WebError taxonomy |
ctx.subagents |
dsh-subagent | named provider registry for delegating to child agents |
All registrations go through ctx.effect() and return disposers, so hot-reload and fiber disposal clean up automatically (full service interfaces: the generated catalog ## Services section).
Capability seams: interface / implementation / consumer
Swappable capabilities split into three packages — interface (abstract service + vocabulary, owns the ctx key), implementation (a concrete subclass loaded as a plugin), consumer (what the model and plugins program against) — so each evolves independently; the bash trio is the template (capability seams RFC). Keep interface + consumer together when they are one concern (the LLM seam: dsh-llm carries both, adapters implement); don't split preemptively.
Two seams bend the template deliberately:
- Filesystem adds a policy layer as an event gate, not a method service:
dsh-tool-fs(theread/write/edittools AND executor) dispatchesfs/*intent events thatdsh-fs-policydecides, so dropping the policy plugin degrades to the bare provider instead of breaking an injection (event-gate RFC). Paths resolve against the caller's session cwd, matching bash (per-session cwd RFC). - Web folds search and fetch onto one seam:
ctx.webis a provider REGISTRY (registerSearchProvider/registerFetchProvider, registration-order-independent selection); providers register like LLM adapters, anddsh-tool-webis the single consumer owning the tool schemas (web seam RFC).
The seam pattern is plain Cordis services +
inject(a consumer's fiber stays pending until the service exists). Despite the name,@cordisjs/plugin-capabilityis unrelated — a permission-security service (a candidate for the deferred permissions work), not a mechanism for swapping implementations.
The vocabulary (dsh-llm)
Messages are arrays of typed content blocks (text, reasoning, tool-call, tool-result, image); the union derives from the merge-extensible ContentBlockMap; the same pattern types MessageSource, FinishReason, TurnTrigger, TurnEndReason. Streaming is a raw chunk protocol (block-start … finish) with BlockAssembler as the single shared chunk→block assembler; the loop logs raw chunks (replay fidelity) while assembling them. LlmAdapter is the provider seam: subclass, implement stream(), register via ctx.llm.registerAdapter(models, adapter); dsh-llm-deepseek and dsh-llm-pi-ai implement the one contract as deliberate design twins (twin RFC). The StreamChunk conventions (usage/finish ordering, raw-string tool arguments, the two sanctioned error paths) are pinned in dsh-llm/src/types.ts and llm-streaming.md.
Event-sourced sessions (dsh-session)
A Session is an append-only log of typed SessionEvents — the single source of truth. The LLM message history is derived (deriveMessages()): user/assistant messages, tool results, and envelope-tagged context/steering messages come from their events in chronological order (raw assistant/chunk events are replay/UI data, skipped; the per-event mapping is in session.md). Replay/fork = ctx.sessions.create(id, { seed }); trace/telemetry = listen to session/event (event-sourcing RFC).
Durability: session/event is a synchronous notification; persistence backends buffer write-behind and drain at the awaited session/flush checkpoint at every turn end. The abstract SessionPersistence seam defines create/append/load/list over SessionEvent (no parallel persisted type); metadata travels as SessionHeader; crash recovery preserves an interrupted turn by closing it with a synthetic turn/end {interrupted}. Two backends (JSONL, SQLite) pass one shared contract suite (persistence RFC, write coordinator RFC). Resume = ctx.agents.resume({ resumeSessionId }).
Prompt assembly (dsh-system-prompt)
Plugins contribute PromptSections (named, ordered, static or computed) and tool-schema providers; assemble() returns PromptAssembly { sections, tools } through the system-prompt/assemble waterfall. Tool schemas are deliberately part of the assembly — "what the model is told it can do" is one coherent thing — though adapters transmit them as the wire-level tools field (RFC).
Tool pipeline (dsh-tools)
ToolRegistry.register() takes schema + execute(); schemas flow into the assembly automatically. execute() runs through a two-waterfall pipeline — tools/pre-execute (a PreToolDecision: allow/deny/ask) → core dispatch → tools/post-execute (a PostToolDecision: accept/block, replace content, attach context) — the seams where sandbox, permission, hook, and plan-mode plugins live. A thrown tool still reaches post-execute as an isError result.
Agents (dsh-agent) and the loop (dsh-agent-loop)
Agent is the handle every plugin programs against: send() (queued), steer() (mid-turn injection, drained between steps), inject() (in-session context; a one-shot injection turn when idle), cancel() (the single public stop primitive: clears queued + steering work, aborts the in-flight step, drops a turn about to start), whenIdle() (quiescence observation, not teardown), plus session/status/options. A lifecycle owner tears down via await AgentHandle.dispose() — stop, await exit, unregister. Full semantics: core.md, lifecycle RFC.
Subagents are a seam, not a method on Agent: ctx.subagents is a named-provider registry (spawn starts fresh, fork seeds the child with the parent's completed-turn prefix, ACP drives an out-of-process child); children are ordinary Agents. See subagent.md, subagent RFC.
Loop lifecycle (session / turn / step)
- Session: the whole event log of one agent.
- Turn: ≥1 queued message; steps run until the model stops requesting tools and no plugin requests continuation.
- Step: one model request + its tool executions.
create agent → emit agent/session-start(source) ⟵ once, before turn 1 (startup|resume)
forever:
wait for queued messages (idle)
emit agent/status(running)
TURN (error-contained — a throwing plugin ends the turn, never the loop):
'turn/start' ⟵ durable turn boundary (no agent/* mirror)
each queued msg: waterfall agent/prompt-submit ⟵ allow (rewrite/+context) | block
allow → session('user/message'…); inject additionalContext
every prompt blocked → 'turn/end'(rejected), 0 steps ⟵ zero-step turn, model never called
STEP loop:
drain steering (late steering from previous step's listeners)
assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
session('step/start') ⟵ durable step boundary (no agent/* mirror)
req = {model, system, tools, messages: session.deriveMessages(), signal}
req = waterfall agent/request ⟵ hooks, model switch
stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
session('assistant/chunk')
if assembler.finish is error/aborted: throw ⟵ adapter's in-band error path →
step error (turn ends error/aborted,
not a normal completed message)
msg = waterfall agent/step-result ⟵ runs BEFORE the log append, so the
session('assistant/message' {content, usage?}) log records what tool dispatch uses
each tool-call (sequential, abort-checked between calls):
session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/pre-execute (allow/
deny/ask gate) → dispatch → tools/post-execute (accept/block, replace, +context)
tool execution may append tool-owned session events, e.g. `todo/write`
session('tool/result')
append buffered post-execute additionalContext → session('context/message')(s)
⟵ after ALL tool/results (adjacency)
drain steering → session('steering/message'); emit agent/steering
session('step/end') ⟵ durable step boundary (no agent/* mirror)
cont = waterfall agent/turn-continuation(default = {action: hadToolCalls||steered
? 'continue' : 'stop'}) → ContinuationDecision
a continue's reason is recorded as next-step steering (same turn); steering pending
also forces continue (continuation OR step/end listeners — the /goal pattern)
if action==stop: break
session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure
reported via agent/error, not fatal)
leftover steering re-enqueued as queued messages ⟵ steering is never stranded
emit agent/status(idle) unless more queued
Error containment: a throwing listener or broken step ends the turn (turn/end { reason: { kind: 'error', step, … } }), never the driver loop; live diagnostics fire via agent/error; an adapter's in-band error/aborted finish chunk becomes a step error. cancel() is honored mid-stream and between tool calls; disposal mid-turn ends the turn disposed. A post-turn/end failure (a rejecting session/flush) is reported via agent/error only — the turn stays balanced, the backend keeps its buffer.
A turn ends with one TurnEndReason — completed, aborted, error, disposed, max-tokens, rejected, or interrupted; per-variant semantics (and the max-tokens-wins rule) are in session.md § TurnEndReasonMap.
Turn-enclosure invariant: every session event lives inside a turn, making the turn the single durability/replay boundary — anything after the last turn/end is an interrupted-crash tail. dsh-invariants enforces it in dev (invariant RFC).
Event taxonomy
The agent/* events are declared in dsh-agent (so nothing depends on the loop package); each other service declares its own (tools/*, llm/*, system-prompt/*, session/*). The full catalog — signatures, dispatch modes, prose — is generated from source and freshness-gated: cordis-catalog/events-and-services.md. Domain semantics (session = the fact log, agent = the live surface): the event-domain RFC.
Cordis waterfall semantics (important)
ctx.waterfall is around-middleware, not a value reducer. Each listener receives (...args, next):
- call
next()to delegate to later listeners (and ultimately the core behavior), possibly wrapping it; - return a value without calling
next()to short-circuit (veto); - listeners run in registration order;
prepend: truejumps the queue.
Composition caveat: values propagate through next()'s return value — a listener that returns a new object makes earlier listeners' mutations invisible downstream. Prefer mutate-then-next() for cooperative middleware; return a replacement only to take over the result.
Extension guide
Plugin skeletons (tool, hook/permission gate, UI, protocol bridge) and the feature→mechanism map — which extension seam implements each product feature — live in the extension cookbook; step-by-step guides: adding a package, a tool, an LLM adapter, a vendored package.
Deferred work (TODO)
Designed-for but not implemented: inter-agent channels beyond delegation (shared state, streaming output); the model-facing /compact consumer tool over ctx.compact (compaction RFC); parallel tool execution (concurrency-safety hints on ToolDefinition); session branching/tree if seed-based forking proves insufficient.