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:
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user