Backfill architecture decision records

Seven ADRs capturing the why behind decisions already made: vendoring
Cordis as source with a guarded manifest; the microkernel event
taxonomy with one swappable concrete loop; event-sourced sessions
with derived history and the append-before-emit ordering contract;
the provider-neutral content-block vocabulary (and why not
OpenAI/Anthropic shapes); the custom tool-schema DSL over schemastery;
tool schemas living in the prompt assembly; and mechanical quality
gates over prose guidelines (the agents-write-the-code rationale).
This commit is contained in:
Tianyi Cui
2026-06-11 15:24:14 +08:00
parent 370b5d3aab
commit 9b8fccc6f9
8 changed files with 268 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# ADR 0001: Vendor Cordis as source, not npm dependencies
Status: accepted (2026-06-11)
## Context
DeepSeek Code is built on the Cordis framework. Cordis core was at 4.0.0-rc.6
(a release candidate) when this repo started; the harness depends on framework
internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact
behavior matters to the agent loop's correctness guarantees.
## Decision
Copy the needed Cordis packages (core, loader, include, group, timer, hmr,
logger-console) and the cordiverse foundation libraries (cosmokit,
schemastery) into `vendor/` as source, flattened, keeping their original npm
names so workspace resolution is transparent. Truly third-party dependencies
(js-yaml, chokidar, @standard-schema/spec, …) stay on npm.
`vendor/README.md` is the manifest: upstream repo + commit SHA per package and
an exhaustive local-modification log. A pre-commit guard
(`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that
don't update the manifest in the same commit.
## Consequences
- The harness fully owns its framework layer: auditable, patchable, pinned —
an RC upstream can't break us, and we can fix framework bugs in-tree.
- Upstream sync is manual (documented procedure in the manifest). The
modification log keeps the diff surface known.
- Vendored packages keep upstream code style; lint/strictness gates exclude
them (their tsconfigs relax our newer compiler flags locally).
- One local patch exists from day one: hmr's locale-YAML imports removed (the
runtime YAML import hook isn't vendored).
@@ -0,0 +1,38 @@
# ADR 0002: Microkernel — extension via Cordis event taxonomy, one concrete loop
Status: accepted (2026-06-11)
## Context
The product principle (see the 微内核Harness实现思路 design doc) is
"everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction,
sandboxing, permissions, UI, persistence, MCP, skills must all be writable as
plugins without modifying the core. Candidate mechanisms considered: a
purpose-built middleware stack (koa-compose style), an explicit phase state
machine plugins can insert into, or Cordis's native event system.
## Decision
Pure Cordis event taxonomy. The loop's extension seams are typed events with
deliberate dispatch modes:
- **waterfall** (around-middleware) where plugins mutate or veto:
`agent/request`, `agent/step-result`, `agent/turn-continuation`,
`tools/execute`, `llm/stream`, `llm/generate`, `system-prompt/assemble`.
- **emit** (sync fire-and-forget) for notifications: turn/step boundaries,
stream chunks, lifecycle, errors.
- **parallel** (awaited) for the one durability checkpoint: `session/flush`.
The event vocabulary lives in interface packages (dsh-agent declares the
agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete plugin and
is itself swappable — nothing outside it may depend on it.
## Consequences
- Every MVP feature maps to a listener (the "plugin sanity checklist" in
docs/architecture.md is the proof obligation, kept current).
- HMR and disposal come free: listeners and registrations are Cordis effects.
- Waterfall semantics (call `next()` or short-circuit) are non-obvious and
must be taught — documented in AGENTS.md and covered by composition tests.
- The loop must be defensive: plugin exceptions are contained at turn level,
steering from any seam is never stranded (regression-tested).
+38
View File
@@ -0,0 +1,38 @@
# ADR 0003: Event-sourced sessions with derived message history
Status: accepted (2026-06-11)
## Context
The MVP requires strict event-based tracing with fully replayable sessions
(严格的基于事件的trace、logging系统,session完全可回放). Two models were
considered: a mutable message array with events fired as notifications
(simpler, but state and log can diverge), or event-sourcing where the log IS
the state.
## Decision
A `Session` is an append-only log of typed `SessionEvent`s — the single
source of truth. The LLM message history is *derived* from the log
(`deriveMessages()`); raw stream chunks are logged for token-level replay
fidelity while the assembled `assistant/message` event is authoritative for
derivation. Replay/fork = seed a new session with an existing log.
Appends are synchronous (the hot path never blocks on I/O); `session/event`
is a sync notification; persistence plugins buffer write-behind and drain at
the awaited `session/flush` checkpoint fired at every turn end.
Ordering contract: the loop appends to the session *before* emitting the
corresponding Cordis event, and the `agent/step-result` waterfall runs before
the `assistant/message` append so the log records what tool dispatch actually
used (post-review fix; regression-tested).
## Consequences
- Replay, trace, and telemetry are structurally guaranteed, not bolted on.
- Persistence stays a plugin concern; the in-memory store ships in dsh-session.
- The event vocabulary is merge-extensible (plugins add e.g. compaction
events); it carries a TODO(review) marker until the first persistence
plugin and real adapter exercise it.
- Derivation cost grows with log length — compaction (future plugin) is the
intended mitigation, not log mutation.
@@ -0,0 +1,37 @@
# ADR 0004: Provider-neutral content-block vocabulary owned by dsh-llm
Status: accepted (2026-06-11)
## Context
The harness needs one internal language for messages that the loop, session
log, and all plugins speak. Options: mirror the DeepSeek/OpenAI
chat-completions shape (zero mapping for the first provider, awkward for rich
content), adopt Anthropic's Messages block structure verbatim (battle-tested,
but our canonical types would mirror a third-party API we don't target
first), or own a vocabulary.
## Decision
Own it: messages are arrays of typed content blocks (`text`, `reasoning`,
`tool-call`, `tool-result`, `image`), with the union derived from the
merge-extensible `ContentBlockMap` so plugins add block types via declaration
merging. The same merge-extensible-map pattern types every "stringly" field
(`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming
is a raw chunk protocol; `BlockAssembler` is the single shared assembly
implementation. Adapters translate to provider wire formats — mapping cost
lives in adapters, where it belongs.
In-session context injection (`context/message`, `steering/message`) renders
as tagged user-role envelopes (the system-reminder pattern) rather than a new
role, so adapters carry zero burden. TODO(review): revisit once the DeepSeek
V4 adapter exists.
## Consequences
- Reasoning, prefill, cache hints, and multimodal content all have a home
without provider contortions.
- Every adapter pays a translation cost; the streaming protocol carries a
TODO(review) marker until the first real adapter validates it.
- IDs that cross package boundaries are branded (`CallId`, `SessionId`,
`AgentId`) — nominal typing at zero runtime cost.
@@ -0,0 +1,35 @@
# ADR 0005: Custom typed tool-schema DSL instead of schemastery
Status: accepted (2026-06-11)
## Context
Tool parameters must reach the model as standard JSON Schema (the wire
format), and tool authors deserve typed `execute(args)` without casts. The
repo already vendors schemastery (used for plugin Config), so reusing it was
the obvious candidate. The user also explicitly preferred per-property
`required: true` booleans over JSON Schema's separate `required` array.
## Decision
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with
`required: true` booleans), type-level `InferArgs<S>` mapping a spec to the
argument type (required keys non-optional, others genuinely optional via `?`),
a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them
together. Raw JSON-Schema `ToolDefinition`s remain accepted by
`ToolRegistry.register()` — that's how MCP-sourced tools arrive.
Schemastery was evaluated and rejected for this use: it targets validation /
transformation against StandardSchema, not JSON Schema *generation*, so it
would add indirection without producing the wire format cleanly.
## Consequences
- First-party tool authors get zero-cast typed args; the type gymnastics cost
stays inside the core package (sanctioned by the AGENTS.md type-safety
policy).
- The DSL is deliberately small (string/number/boolean/object/array, enum,
default, nested properties/items). Gaps vs full JSON Schema (unions,
formats, constraints) are accepted until real tools demand them.
- The InferArgs mapping is regression-tested at the type level (expectTypeOf)
after an early optionality bug shipped and was caught by review.
@@ -0,0 +1,31 @@
# ADR 0006: Tool schemas are part of the system-prompt assembly
Status: accepted (2026-06-11)
## Context
On the wire, tool schemas travel in a dedicated `tools` field of the model
request, not in prompt text. Architecturally, though, "what the model is told
it can do" is one coherent concern: prompt sections and the tool list are
assembled from the same plugin contributions and consumed at the same moment.
The alternative — the loop querying the tool registry separately from the
prompt service — splits one concern across two seams.
## Decision
`PromptAssembly { sections, tools }`: the system-prompt service collects
ordered text sections AND tool schemas (the tool registry auto-contributes a
provider). The loop consumes one assembly per step; adapters map `sections`
to the provider's system slot and `tools` to the wire `tools` field. The
`system-prompt/assemble` waterfall is therefore a single interception point
for everything the model is told up front — tool filtering (ToolSearch /
progressive disclosure) is an assembly rewrite, same as prompt edits.
## Consequences
- One waterfall governs the model's standing context; plugins like plan mode
can swap prompt text and visible tools in one listener.
- The assembly interface is merge-extensible for future slots (no untyped
`extras` bag — extension is declaration merging).
- Slight conceptual surprise (schemas in a "prompt" service) is documented
here and in the package README.
+36
View File
@@ -0,0 +1,36 @@
# ADR 0007: Mechanical quality gates over prose guidelines
Status: accepted (2026-06-11)
## Context
This codebase is developed primarily by coding agents. Agents follow enforced
gates far more reliably than prose conventions, and "a lot of work" is not a
cost argument when agents do the labor. Early evidence: tests that didn't
typecheck shipped (vitest doesn't typecheck) and were only caught by a review.
## Decision
Every AGENTS.md promise gets a command that exits non-zero, wired into git
hooks and CI both calling the same package.json scripts:
- Max-strict TypeScript (`noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, …); tests and examples typecheck in CI via
`tsconfig.typecheck.json` (vendored packages resolve as built declarations).
- ESLint strict-type-checked + @stylistic (the house style, enforced);
vendored code excluded.
- Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive
guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
- knip (dead code/deps), publint (package correctness), yarn constraints
(workspace rules: private, cordis peer+dev, uniform version, ESM).
- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and
pre-push (tests, hygiene); CI runs the full matrix on node 24/26 plus a
demo smoke test driving the echo-agent end to end.
## Consequences
- Conventions survive agent turnover; violations fail fast and locally.
- The gates themselves are code to maintain; config changes are reviewed like
any change.
- 100%-coverage pressure can produce assertion-free tests — mutation testing
is the planned counterweight (see RFC 002).
+19
View File
@@ -0,0 +1,19 @@
# Architecture Decision Records
Short, immutable records of the *why* behind decisions that shape this
codebase. Code and docs say what the system does; ADRs say why it does it
that way and what we gave up.
Format: one file per decision, numbered, with Status / Context / Decision /
Consequences. An ADR is never edited into a different decision — supersede it
with a new one and cross-link.
| # | Title | Status |
|---|---|---|
| [0001](0001-vendor-cordis-as-source.md) | Vendor Cordis as source, not npm dependencies | accepted |
| [0002](0002-microkernel-event-taxonomy.md) | Microkernel: extension via Cordis event taxonomy, one concrete loop | accepted |
| [0003](0003-event-sourced-sessions.md) | Event-sourced sessions with derived message history | accepted |
| [0004](0004-own-content-block-vocabulary.md) | Provider-neutral content-block vocabulary owned by dsh-llm | accepted |
| [0005](0005-custom-schema-dsl-over-schemastery.md) | Custom typed tool-schema DSL instead of schemastery | accepted |
| [0006](0006-tool-schemas-in-prompt-assembly.md) | Tool schemas are part of the system-prompt assembly | accepted |
| [0007](0007-quality-gates.md) | Mechanical quality gates over prose guidelines | accepted |