Architecture: the plugin model, the turn loop, and the session log
"Everything is a plugin" — what it concretely means
The claim (docs/architecture.md:11-13: "Every part of the product is a
plugin, including the model adapter, the tool registry, the session log, and
the agent loop itself") rests on three Cordis mechanisms:
- Services claim a
ctx.<key>and unregister with their fiber. A service provides itself into the context tree (vendor/cordis/src/service.ts:57:self.ctx.reflect.provide(name, self, ...)), and harness packages declare their keys through TypeScript declaration merging (packages/core/tools/src/index.ts:137-140:declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRuntime } }). injectexpresses load order as service requirements, not boot sequencing (packages/core/agent-loop/src/index.ts:297:static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']).- Registrations are reversible effects that unwind when the plugin
unloads (
packages/core/agent-loop/src/index.ts:349-350:ctx.effect(() => ctx.agents.setFactory(this), ...)).
The claim is real, not aspirational. The agent loop is literally a row in a
YAML file (packages/bundle/base/cordis.patch.yml:436-437), and the loop
interface (dsh-agent) is a separate package from the driver
(dsh-agent-loop), connected by a swappable factory
(packages/core/agent/src/index.ts:372-379). You could mount a different
agent loop from configuration.
The tradeoff: everything-is-a-plugin means everything-is-composition.
Understanding what a given dsh web process contains requires mentally
merging four YAML patch layers plus platform-conditional disabled: !!js
expressions. Upstream knows this — their own postmortem 0002 documents the
filesystem tools being silently disabled for a while because a !!js
expression appeared in a field the loader does not interpolate
(docs/postmortem/0002-js-expression-disabled-filesystem-tools.md:9, :32).
Composition errors are configuration-shaped, invisible to the type system, and
they fail silently unless a gate catches them.
Scopes: how one agent gets its own world
dsh-scope (~200 lines) tags a Cordis context with an opaque key.
Registrations inherit down the scope chain; events flow up it
(packages/core/scope/src/index.ts:172-181). Each agent mints one scope and
extends it with itself (packages/core/agent-loop/src/agent.ts:94-95). This
single small primitive is what makes per-session tool sets, per-session prompt
sections, and per-session listeners possible — it is the mechanism behind the
web surface's per-session agent presets.
A fragility to know about: scope bookkeeping lives in module-level
WeakMaps (packages/core/scope/src/index.ts:30,39). If two copies of
dsh-scope ever load in one process, scoping silently splits — which is
exactly the duplicate-instance hazard the boot symlink farm exists to prevent
(packages/boot/app-boot/src/profile.ts:133-137).
The session log is the single source of truth — and it is enforced
Every model-visible thing is a durable event in an append-only session log.
The inbox is not in-memory state; every mutation is an agent/inbox/spliced
event, and the in-memory projection is replayed from the log on construction
(packages/core/agent/src/inbox.ts:186, :32-35). LLM request messages are
never accumulated in variables; they are derived from the log at dispatch time
(packages/core/agent-loop/src/agent.ts:341:
this.session.deriveMessages()), with an incremental cached fold invalidated
by compaction (packages/core/session/src/index.ts:730-733).
The remarkable part is that this is machine-checked at runtime, not just
documented. A test-mounted invariant re-derives the messages from the log at
every LLM dispatch and fails the run if they diverge
(packages/core/agent-loop/src/invariant.ts:39-42):
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request ... diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}
It registers with prepend: true so a replay listener cannot silence it
(invariant.ts:20-21). This is the single best design decision in the
codebase: replay, resume, and UI rendering all read the same substrate, and
drift between "what the model saw" and "what the log says" is a hard failure
instead of a heisenbug.
Two caveats. The invariant compares by JSON.stringify, which is
key-order-sensitive — a benign object-construction reorder produces a false
failure (invariant.ts:40, :48-49). And persistence refuses old formats
rather than migrating them (packages/session/session-persistence/src/coordinator.ts:278
throws on legacy events; SESSION_FORMAT_VERSION = 0,
docs/persistence-catalog.md:10) — correct for a pre-release, but it means
stored sessions have no future.
The turn loop, end to end
What happens between a user message and a response, with the real file:line trail:
- Prompt arrives. The web
promptRPC builds a durableUserMessageand callsagent.followup(...)oragent.steer(...)(packages/host/apiproxy/src/api-proxy.ts:2497).followup,steer, andinjectare one inbox with three targets — next-turn vs next-step, wake vs not (packages/core/agent-loop/src/agent.ts:122). - Turn opens durably before input is claimed
(
agent.ts:255:this.session.append('turn/start', { turn })). - Step preparation. Claim inbox messages, assemble the system prompt and
tool schemas, then run the
agent/pre-stepwaterfall (agent.ts:229-239). Thirteen packages listen here (docs/event-producer-consumer.md:20) — this is the main policy seam, and it is where compaction applies pressure (packages/compaction/compaction-basic/src/index.ts:153). A synthetic runtime-context message (time, cwd, instructions) is appended only when it differs from the last snapshot (packages/core/agent-loop/src/runtime-context.ts:64-67). - Step executes.
step/startand each claimeduser/messageare appended (agent.ts:279-283); request config passes theagent/requestwaterfall (agent.ts:438); the call dispatches throughLlmRuntime.stream→ thellm/streamwaterfall → the adapter (packages/llm/llm/src/index.ts:921-926). Every stream chunk is appended durably before assembly (agent.ts:349-350), so a crash mid-stream loses nothing. Failures re-enter through theagent/request-errorwaterfall;retryloops the same step (agent.ts:355-370). - Tool calls execute. Calls are classified exclusive-or-parallel with a
fail-closed default (
packages/core/tools/src/index.ts:1278-1284: unknown or throwing classifier means exclusive).tool/callis logged before the pipeline runs (packages/core/agent-loop/src/tool-calls.ts:167-169); the three documented waterfalls fire in order —tools/pre-execute(tools/src/index.ts:1475),tools/execute(:1573),tools/post-execute(:1744) — and results commit in model order even though dispatch overlaps (tool-calls.ts:146-158). Tool-produced context is staged into the next-step inbox rather than concatenated into the request (agent.ts:397). - Turn closes.
agent/turn-stoppingruns serially when the inbox is empty, thenturn/endwith a reason (agent.ts:295-299,:319). - The UI hears about it. Every append is broadcast synchronously and
converted to a
session/eventwire frame (packages/host/apiproxy/src/api-proxy.ts:3493).
The loop's rough edges
- The driver swallows every failure at its boundary
(
agent.ts:212-215:catch (_error) { /* contained */ }). The error is emitted onagent/errorfirst (:206) and recorded inturn/end.reason(:309-314), but if no listener is mounted and nobody reads the log, a turn can fail with zero operator-visible output — there is no logger call on this path. - The step retry loop is unbounded in core (
agent.ts:339while (true), exit only when a listener declines to retry). All bounding lives in listeners (packages/llm/llm-retry/src/index.ts:190,packages/compaction/compaction-basic/src/index.ts:189); a third-partyagent/request-errorlistener that always returns{kind:'retry'}spins forever, andllm-retry'smode: 'always'is unbounded by design. - A non-null assertion guards the durable log's integrity
(
agent.ts:318-319:turn/end { reason: turnEnds! }) — a future exit path that misses the assignment writesreason: undefinedinto the log instead of failing. - Invalid tool-call JSON degrades silently to a raw string
(
tool-calls.ts:104-110:catch { return raw }), so the failure surfaces later as a schema violation instead of at the parse site.
Host vs client split
The host half (packages/host/*) owns a transport-independent API proxy plus
a plain HTTP server (packages/host/README.md:16); the client half
(packages/client/*) is a three-layer stack with declared one-way knowledge —
React-free data objects, shell-only render glue, pure-props presentation
components (packages/client/AGENTS.md:46-48). The split is enforced
structurally: two disjoint TypeScript programs whose Context merges never meet
(tsconfig.json:6).
Two blemishes. First, packages/host/apiproxy/src/api-proxy.ts is a
3,744-line god file holding the entire host API surface — bigger than
core/agent-loop + core/agent + core/scope combined (2,841 lines).
packages/core/tools/src/index.ts is the other god file at 1,946 lines.
Second, there is exactly one manifest-level dependency cycle in the workspace,
and it crosses the host/client line:
dsh-api-gateway → dsh-client-connection → dsh-host-apiproxy →
dsh-api-remotes → dsh-api-gateway (edges at
packages/api/gateway/package.json:62,
packages/client/connection/package.json:42,
packages/host/apiproxy/package.json:51,
packages/api/remotes/package.json:63). Three of the four edges are type-only,
but client-connection → host-apiproxy is a value import
(packages/client/connection/src/index.ts:7:
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy') — a client
package depending on a host package, which is the layering smell the split's
own framing denies. The generated docs/module-graph.md hides this because it
only draws peer-dependency edges (docs/module-graph.md:6), omitting six real
dependencies on client-connection alone
(packages/client/connection/package.json:40-47).
The vendored framework
Cordis is not consumed; it is owned. Nine framework packages live in
vendor/, renamed to the @deepseek-ai scope for registry hygiene
(docs/rescope.md:5 — note this doc is a namespace rename, not a product
pivot), with a maintained ledger of 18 local modifications, each with
rationale and a covering test (vendor/README.md:29-51). Several are deep
behavioral rewrites (fiber-lifecycle hardening, transactional loader
reconciliation), and half the vendored set already comes from DeepSeek's own
fork of the upstream (vendor/README.md:17-23 cites both
cordiverse/cordis and deepseek-harness/cordis as sources). This is a fork
that will never go back.
The oddest artifact of ownership: vendored Cordis's emit starves later
listeners when one throws synchronously and discards returned promise
rejections (vendor/cordis/src/events.ts:194-196), and instead of fixing it
in the framework they control, harness code hand-rolls the same containment
workaround five times (packages/core/agent/src/dispatch.ts:125-136,
packages/core/agent/src/index.ts:529-539 and :561-571,
packages/core/agent-loop/src/index.ts:393-403,
packages/core/session/src/index.ts:382-397). The duplication detector
misses it because the identifiers differ.
Boot
app-boot loads .env (refusing entries that try to set process/network
bootstrap variables — packages/boot/app-boot/src/index.ts:157-160), resolves
the profile, applies patch layers, and drives the loader to settlement,
failing loudly if any plugin cannot resolve (index.ts:662). The design is
sound; the wart is the write-on-every-boot behavior described in
what-is-dsh.html.