gen-cordis-catalog.ts now emits docs/cordis-catalog/events.md and docs/cordis-catalog/services.md instead of the combined events-and-services.md: a reader is either finding what to listen to or what to call, and each axis now scans and deep-links as its own page. Headings promote one level (scopes and ctx.<key> entries become H2), the dispatch-mode legend lives on the events page, and the inherited tier splits accordingly. --check verifies both files and names whichever is stale. Every reference updated in the same change (no compat redirects, pre-release stance): architecture.md, AGENTS.md, docs/AGENTS.md tier row, filesystem/subagent core-data-structures pages (the ctx.fs anchor survives — slugs are heading-level-independent), fs README, four RFCs, the tool-catalog and persistence-catalog generator intros (both regenerated), and the bilingual development.md pair (re-recorded).
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 events / services catalogs, 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 services catalog).
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); the union derives from the merge-extensible ContentBlockMap; the same pattern types MessageSource, FinishReason, TurnTrigger, TurnEndReason. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction (the drop-image RFC). 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')
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.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.