docs: unwrap hard-wrapped Markdown to one line per paragraph
Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. Reflow all tracked non-vendor Markdown (plus vendor/AGENTS.md) so each prose paragraph is a single line; soft-wrapping is the editor's job. Fenced code, tables, and list structure are preserved (wrapped list items fold to one line per bullet). Documents the convention in AGENTS.md.
This commit is contained in:
@@ -4,31 +4,17 @@ 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.
|
||||
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.
|
||||
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.
|
||||
`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).
|
||||
- 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).
|
||||
|
||||
@@ -4,35 +4,21 @@ 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.
|
||||
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:
|
||||
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.
|
||||
- **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.
|
||||
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).
|
||||
- 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).
|
||||
- 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).
|
||||
|
||||
@@ -4,35 +4,19 @@ 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.
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
- 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.
|
||||
|
||||
@@ -4,34 +4,16 @@ 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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
@@ -4,32 +4,16 @@ 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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
@@ -4,28 +4,14 @@ 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.
|
||||
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.
|
||||
`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.
|
||||
- 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.
|
||||
|
||||
@@ -4,33 +4,20 @@ 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.
|
||||
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:
|
||||
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.
|
||||
- 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).
|
||||
- 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).
|
||||
|
||||
@@ -4,49 +4,21 @@ Status: accepted (2026-06-11)
|
||||
|
||||
## Context
|
||||
|
||||
The initial build used **dumble**, the cordiverse zero-config esbuild wrapper
|
||||
that upstream Cordis itself builds with — maximum alignment with the vendored
|
||||
packages' conventions (it reads each package.json and infers entries/formats
|
||||
from the `exports` field). But dumble is a liability as a load-bearing tool in
|
||||
this repo: v0.2.x, ~530 npm downloads/week, effectively one maintainer, and we
|
||||
were invoking it through a custom orchestration script (`scripts/build.ts`)
|
||||
because it has no workspace mode.
|
||||
The initial build used **dumble**, the cordiverse zero-config esbuild wrapper that upstream Cordis itself builds with — maximum alignment with the vendored packages' conventions (it reads each package.json and infers entries/formats from the `exports` field). But dumble is a liability as a load-bearing tool in this repo: v0.2.x, ~530 npm downloads/week, effectively one maintainer, and we were invoking it through a custom orchestration script (`scripts/build.ts`) because it has no workspace mode.
|
||||
|
||||
Build output currently matters only for `yarn build` + publint (nothing
|
||||
publishes yet; dev/test/demo run unbuilt via tsx), so the switching cost is at
|
||||
its lowest now and only grows once packages publish.
|
||||
Build output currently matters only for `yarn build` + publint (nothing publishes yet; dev/test/demo run unbuilt via tsx), so the switching cost is at its lowest now and only grows once packages publish.
|
||||
|
||||
## Decision
|
||||
|
||||
Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week,
|
||||
VoidZero-backed, actively released):
|
||||
Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-backed, actively released):
|
||||
|
||||
- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']`
|
||||
(explicit globs, not `workspace: true`, which would also pick up
|
||||
`examples/*` — they have package.json files but are not yarn workspaces).
|
||||
- Shared shape: entry `src/index.ts`, `outDir: 'lib'`, ESM, `platform: node`,
|
||||
`target: es2024`, `fixedExtension: false` (keeps `.js` for
|
||||
`"type": "module"` packages), `dts: false` (tsc -b owns declarations),
|
||||
`clean: false` (lib/ holds tsc's .d.ts output).
|
||||
- Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs;
|
||||
logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via
|
||||
`outExtensions`), logger-console (two single-entry passes so the shared
|
||||
base class is inlined into each entry instead of a hash-named chunk,
|
||||
matching upstream's published shape).
|
||||
- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']` (explicit globs, not `workspace: true`, which would also pick up `examples/*` — they have package.json files but are not yarn workspaces).
|
||||
- Shared shape: entry `src/index.ts`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ holds tsc's .d.ts output).
|
||||
- Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape).
|
||||
- `scripts/build.ts` deleted; `yarn build` = `tsc -b && tsdown`.
|
||||
|
||||
Alternatives considered: **direct esbuild script** (most established engine,
|
||||
zero wrapper risk, but hand-maintains the per-package spec table tsdown's
|
||||
workspace mode gives us); **pkgroll** (closest drop-in philosophically, but
|
||||
78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown);
|
||||
**keep dumble** (perfect upstream alignment, unacceptable bus factor).
|
||||
Alternatives considered: **direct esbuild script** (most established engine, zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us); **pkgroll** (closest drop-in philosophically, but 78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown); **keep dumble** (perfect upstream alignment, unacceptable bus factor).
|
||||
|
||||
## Consequences
|
||||
|
||||
Output file lists are byte-for-byte-list identical to dumble's (verified by
|
||||
snapshot diff at migration time); externals still come from each package's
|
||||
dependencies/peerDependencies. We give up dumble's exports-field inference —
|
||||
new packages with non-default shapes need a per-package `tsdown.config.ts`
|
||||
instead of just package.json fields. Future option: tsdown could also absorb
|
||||
declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the
|
||||
bottleneck; that would be a new ADR.
|
||||
Output file lists are byte-for-byte-list identical to dumble's (verified by snapshot diff at migration time); externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new ADR.
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# 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.
|
||||
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.
|
||||
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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
# DeepSeek Harness Architecture
|
||||
|
||||
This document describes the phase-1 architecture of the DeepSeek Harness — the
|
||||
foundation of **DeepSeek Code**. The governing principle, from the
|
||||
[microkernel design discussion][microkernel-doc], is:
|
||||
This document describes the phase-1 architecture of the DeepSeek Harness — the foundation of **DeepSeek Code**. The governing principle, from the [microkernel design discussion][microkernel-doc], is:
|
||||
|
||||
> **Microkernel approach. Everything is a plugin.**
|
||||
|
||||
The harness core is deliberately tiny: a handful of abstract services plus one
|
||||
concrete plugin (the agent loop). Every product feature — tools, hooks,
|
||||
compaction, sandboxing, UI, persistence, sub-agents, MCP, skills — is meant to
|
||||
be written as a plugin against the extension surface described here, without
|
||||
modifying the loop.
|
||||
The harness core is deliberately tiny: a handful of abstract services plus one concrete plugin (the agent loop). Every product feature — tools, hooks, compaction, sandboxing, UI, persistence, sub-agents, MCP, skills — is meant to be written as a plugin against the extension surface described here, without modifying the loop.
|
||||
|
||||
Requirement context: [Coding Harness MVP 需求分析][mvp-doc].
|
||||
|
||||
@@ -39,9 +33,7 @@ Requirement context: [Coding Harness MVP 需求分析][mvp-doc].
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Dependency rule: plugins depend on interface packages, never on
|
||||
`dsh-agent-loop`. The loop itself is swappable — UI/hook/tool plugins keep
|
||||
working against the `dsh-agent` vocabulary if the loop is replaced.
|
||||
Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop`. The loop itself is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced.
|
||||
|
||||
## Service map
|
||||
|
||||
@@ -55,34 +47,17 @@ working against the `dsh-agent` vocabulary if the loop is replaced.
|
||||
| `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `LoopAgent`s and drives their loops |
|
||||
| `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
|
||||
|
||||
All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go
|
||||
through `ctx.effect()` and return disposers, so plugin hot-reload (vendored
|
||||
HMR) and fiber disposal clean up automatically.
|
||||
All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically.
|
||||
|
||||
## Capability seams: interface / implementation / consumer
|
||||
|
||||
Swappable capabilities are split into **three packages** so each part evolves
|
||||
independently. The bash capability is the template:
|
||||
Swappable capabilities are split into **three packages** so each part evolves independently. The bash capability is the template:
|
||||
|
||||
1. **Interface** (`dsh-bash`) — an abstract service plus the vocabulary types
|
||||
(`BashExecutor`, `BashRunResult`, `BashTask`, …). Defines the contract,
|
||||
owns the `ctx.bash` key, depends only on cordis.
|
||||
2. **Implementation** (`dsh-bash-local`) — a concrete subclass loaded as a
|
||||
plugin (local subprocesses, process-group kills, spill-file truncation).
|
||||
Sandboxed, containerized, or remote backends are sibling packages
|
||||
implementing the same interface.
|
||||
3. **Consumer** (`dsh-tool-bash`) — what the model and other plugins program
|
||||
against (the `bash`/`bash_output`/`bash_kill` tool schemas). Consumers
|
||||
`inject` the interface's ctx key and never import implementation types.
|
||||
1. **Interface** (`dsh-bash`) — an abstract service plus the vocabulary types (`BashExecutor`, `BashRunResult`, `BashTask`, …). Defines the contract, owns the `ctx.bash` key, depends only on cordis.
|
||||
2. **Implementation** (`dsh-bash-local`) — a concrete subclass loaded as a plugin (local subprocesses, process-group kills, spill-file truncation). Sandboxed, containerized, or remote backends are sibling packages implementing the same interface.
|
||||
3. **Consumer** (`dsh-tool-bash`) — what the model and other plugins program against (the `bash`/`bash_output`/`bash_kill` tool schemas). Consumers `inject` the interface's ctx key and never import implementation types.
|
||||
|
||||
The LLM seam has the same topology folded differently: `dsh-llm` carries the
|
||||
interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with
|
||||
adapters as implementation packages — there the consumer is the loop itself,
|
||||
not a swappable schema surface. Use the full three-package split when the
|
||||
consumer is independently replaceable; keep interface + consumer together
|
||||
when they are one concern. Don't split preemptively: a capability with one
|
||||
conceivable implementation and one consumer stays one package until proven
|
||||
otherwise.
|
||||
The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise.
|
||||
|
||||
> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above
|
||||
> ("one plugin provides a capability, another needs it") is realized by
|
||||
@@ -98,103 +73,55 @@ otherwise.
|
||||
|
||||
## The vocabulary (dsh-llm)
|
||||
|
||||
Messages are arrays of typed **content blocks** (`text`, `reasoning`,
|
||||
`tool-call`, `tool-result`, `image`); the union is derived from the
|
||||
merge-extensible `ContentBlockMap`, so plugins can add block types via
|
||||
declaration merging. The same merge-extensible-map pattern is used for
|
||||
`MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed
|
||||
sum types instead of strings.
|
||||
Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`, `image`); the union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed sum types instead of strings.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`,
|
||||
`reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`).
|
||||
`BlockAssembler` is the single shared implementation that assembles chunks
|
||||
into blocks/messages; the loop logs raw chunks (replay fidelity) while feeding
|
||||
the same chunks through an assembler.
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages; the loop logs raw chunks (replay fidelity) while feeding the same chunks through an assembler.
|
||||
|
||||
`LlmAdapter` is the provider seam: subclass, implement `stream()`, call
|
||||
`ctx.llm.registerAdapter(models, adapter)`. Two real adapters implement it —
|
||||
`dsh-llm-deepseek` (hand-rolled fetch/SSE against the DeepSeek API) and
|
||||
`dsh-llm-pi-ai` (the same endpoint through the `@earendil-works/pi-ai`
|
||||
library). They exist as a pair deliberately: two independent internals over
|
||||
one contract verified the StreamChunk protocol, which is now documented (in
|
||||
`dsh-llm/src/types.ts`) with the conventions that review pinned down — usage
|
||||
before finish, nothing after finish, raw-string tool arguments, and the two
|
||||
sanctioned error paths (thrown vs `finish {kind:'error'}`).
|
||||
`LlmAdapter` is the provider seam: subclass, implement `stream()`, call `ctx.llm.registerAdapter(models, adapter)`. Two real adapters implement it — `dsh-llm-deepseek` (hand-rolled fetch/SSE against the DeepSeek API) and `dsh-llm-pi-ai` (the same endpoint through the `@earendil-works/pi-ai` library). They exist as a pair deliberately: two independent internals over one contract verified the StreamChunk protocol, which is now documented (in `dsh-llm/src/types.ts`) with the conventions that review pinned down — usage before finish, nothing after finish, raw-string tool arguments, and the two sanctioned error paths (thrown vs `finish {kind:'error'}`).
|
||||
|
||||
## Event-sourced sessions (dsh-session)
|
||||
|
||||
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()`):
|
||||
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()`):
|
||||
|
||||
- `user/message` → user message
|
||||
- `assistant/message` → assistant message (raw `assistant/chunk` events are
|
||||
replay/UI data and are skipped in derivation)
|
||||
- `assistant/message` → assistant message (raw `assistant/chunk` events are replay/UI data and are skipped in derivation)
|
||||
- `tool/result` → user message carrying a `tool-result` block
|
||||
- `context/message`, `steering/message` → user-role messages wrapped in a
|
||||
tagged envelope (`<context source="…">…</context>`) at their chronological
|
||||
position — the "system-reminder" pattern; models distinguish them from real
|
||||
user prompts by the envelope. **TODO(review)**: revisit the envelope once a
|
||||
real adapter exists.
|
||||
- `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (`<context source="…">…</context>`) at their chronological position — the "system-reminder" pattern; models distinguish them from real user prompts by the envelope. **TODO(review)**: revisit the envelope once a real adapter exists.
|
||||
|
||||
Replay/fork = `ctx.sessions.create(id, seedEvents)`. Trace/telemetry = listen
|
||||
to `session/event`.
|
||||
Replay/fork = `ctx.sessions.create(id, seedEvents)`. Trace/telemetry = listen to `session/event`.
|
||||
|
||||
**Durability seam**: `session/event` is a synchronous notification;
|
||||
persistence plugins buffer (write-behind) and drain at the awaited
|
||||
`session/flush` checkpoint the loop fires at every turn end (see
|
||||
`examples/echo-agent/src/session-jsonl.ts` for the pattern).
|
||||
**TODO**: real persistence backends (JSONL per session dir, sqlite) are a
|
||||
future phase.
|
||||
**Durability seam**: `session/event` is a synchronous notification; persistence plugins buffer (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end (see `examples/echo-agent/src/session-jsonl.ts` for the pattern). **TODO**: real persistence backends (JSONL per session dir, sqlite) are a future phase.
|
||||
|
||||
## Prompt assembly (dsh-system-prompt)
|
||||
|
||||
Plugins contribute `PromptSection`s (named, ordered, static or computed) and
|
||||
tool-schema providers. `assemble()` returns a `PromptAssembly { sections,
|
||||
tools }` through the `system-prompt/assemble` waterfall.
|
||||
Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers. `assemble()` returns a `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 managed here, even though adapters
|
||||
transmit schemas as the wire-level `tools` field rather than prompt text.
|
||||
Tool schemas are deliberately **part of the assembly**: "what the model is told it can do" is one coherent thing managed here, even though adapters transmit schemas as the wire-level `tools` field rather than prompt text.
|
||||
|
||||
## Tool pipeline (dsh-tools)
|
||||
|
||||
`ToolRegistry.register()` takes schema + `execute()`. The registry feeds its
|
||||
schemas into the system-prompt assembly automatically.
|
||||
`ToolRegistry.register()` takes schema + `execute()`. The registry feeds its schemas into the system-prompt assembly automatically.
|
||||
|
||||
`execute()` runs through the **`tools/execute` waterfall** — the single seam
|
||||
where sandbox, permission, hooks, and plan-mode plugins wrap or veto a call.
|
||||
This collapses Claude Code's validate → PreToolUse → permission → execute →
|
||||
PostToolUse pipeline into ordered waterfall listeners.
|
||||
`execute()` runs through the **`tools/execute` waterfall** — the single seam where sandbox, permission, hooks, and plan-mode plugins wrap or veto a call. This collapses Claude Code's validate → PreToolUse → permission → execute → PostToolUse pipeline into ordered waterfall listeners.
|
||||
|
||||
**TODO**: tool shapes get revisited when real tools land (e.g. a
|
||||
concurrency-safety hint for parallel execution; phase 1 executes tool calls
|
||||
sequentially).
|
||||
**TODO**: tool shapes get revisited when real tools land (e.g. a concurrency-safety hint for parallel execution; phase 1 executes tool calls sequentially).
|
||||
|
||||
## Agents (dsh-agent) and the loop (dsh-agent-loop)
|
||||
|
||||
`Agent` is the handle every plugin programs against:
|
||||
|
||||
- `send(content)` — queued message; starts a turn when idle, else next turn
|
||||
- `steer(content)` — mid-turn injection, drained **between steps**; behaves
|
||||
like `send` when idle
|
||||
- `inject(content)` — in-session context (`context/message` event) without
|
||||
triggering a turn; the next request sees it (Claude Code attachment /
|
||||
system-reminder analog)
|
||||
- `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle
|
||||
- `inject(content)` — in-session context (`context/message` event) without triggering a turn; the next request sees it (Claude Code attachment / system-reminder analog)
|
||||
- `abort(reason)` — aborts the in-flight step via `AbortSignal`
|
||||
- `session`, `status`, `options`
|
||||
|
||||
**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds
|
||||
the child Session with the parent's event log, spawn starts fresh; children
|
||||
are ordinary `Agent` handles so `steer()` and event subscription work
|
||||
uniformly. Inter-agent channels beyond these primitives are deliberately
|
||||
deferred.
|
||||
**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred.
|
||||
|
||||
### Loop lifecycle (session / turn / step)
|
||||
|
||||
- **Session**: the whole event log of one agent.
|
||||
- **Turn**: triggered by ≥1 queued message; runs steps until the model stops
|
||||
requesting tools and no plugin requests continuation.
|
||||
- **Turn**: triggered by ≥1 queued message; runs steps until the model stops requesting tools and no plugin requests continuation.
|
||||
- **Step**: one model request + its tool executions.
|
||||
|
||||
```
|
||||
@@ -231,14 +158,7 @@ forever:
|
||||
emit agent/status(idle) unless more queued
|
||||
```
|
||||
|
||||
Error containment: a throwing `agent/turn-continuation` listener or a
|
||||
rejecting `session/flush` ends the **turn** with an `error` event — never the
|
||||
driver loop. An adapter that ends its stream with a `finish {kind:'error'}`
|
||||
or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't
|
||||
throw mid-stream) is likewise translated into a step error, so the turn ends
|
||||
`error`/`aborted` instead of logging a normal `completed` assistant message.
|
||||
`abort()` is honored mid-stream **and** between tool calls; disposal mid-turn
|
||||
ends the turn with reason `disposed` and emits `agent/status('disposed')`.
|
||||
Error containment: a throwing `agent/turn-continuation` listener or a rejecting `session/flush` ends the **turn** with an `error` event — never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. `abort()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`.
|
||||
|
||||
### Event taxonomy
|
||||
|
||||
@@ -262,24 +182,17 @@ Declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package).
|
||||
|
||||
### Cordis waterfall semantics (important)
|
||||
|
||||
`ctx.waterfall` is **around-middleware**, not a value reducer. Each listener
|
||||
receives `(...args, next)`:
|
||||
`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;
|
||||
- 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: true` jumps the queue.
|
||||
|
||||
Composition caveat: values propagate through `next()`'s **return value**.
|
||||
Mutating the passed-in object works when later listeners receive the same
|
||||
reference, but a listener that returns a *new* object makes earlier mutations
|
||||
invisible downstream. Prefer mutate-then-`next()` for cooperative middleware;
|
||||
return a replacement only when you mean to take over the result.
|
||||
Composition caveat: values propagate through `next()`'s **return value**. Mutating the passed-in object works when later listeners receive the same reference, but a listener that returns a *new* object makes earlier mutations invisible downstream. Prefer mutate-then-`next()` for cooperative middleware; return a replacement only when you mean to take over the result.
|
||||
|
||||
## Plugin sanity checklist
|
||||
|
||||
Every MVP feature (including the TODO-marked ones), with the mechanism that
|
||||
implements it **without modifying the loop**:
|
||||
Every MVP feature (including the TODO-marked ones), with the mechanism that implements it **without modifying the loop**:
|
||||
|
||||
| MVP feature | Plugin mechanism |
|
||||
|---|---|
|
||||
@@ -334,9 +247,7 @@ export function apply(ctx: Context) {
|
||||
}
|
||||
```
|
||||
|
||||
(Raw JSON-Schema `ToolDefinition`s are still accepted by
|
||||
`ctx.tools.register()` directly — that's how MCP-sourced tools arrive.
|
||||
`defineTool` is the typed sugar for first-party tools.)
|
||||
(Raw JSON-Schema `ToolDefinition`s are still accepted by `ctx.tools.register()` directly — that's how MCP-sourced tools arrive. `defineTool` is the typed sugar for first-party tools.)
|
||||
|
||||
### A hook plugin (permission gate)
|
||||
|
||||
@@ -371,31 +282,18 @@ export function apply(ctx: Context) {
|
||||
}
|
||||
```
|
||||
|
||||
Two complete runnable wirings exist: [`examples/echo-agent`](../examples/echo-agent)
|
||||
(mock model + echo tool — the all-mock skeleton check) and
|
||||
[`examples/coding-agent`](../examples/coding-agent) (DeepSeek V4 + the bash
|
||||
tool suite — the real thing; `yarn demo:coding`). Both load from `cordis.yml`
|
||||
with HMR.
|
||||
Two complete runnable wirings exist: [`examples/echo-agent`](../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check) and [`examples/coding-agent`](../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing; `yarn demo:coding`). Both load from `cordis.yml` with HMR.
|
||||
|
||||
Step-by-step guides live in [`docs/cookbook`](./cookbook): adding a package,
|
||||
adding a tool, adding an LLM adapter.
|
||||
Step-by-step guides live in [`docs/cookbook`](./cookbook): adding a package, adding a tool, adding an LLM adapter.
|
||||
|
||||
## Deferred work (TODO)
|
||||
|
||||
Tracked here deliberately — each is designed-for but not implemented:
|
||||
|
||||
- **Restructure this document** — it has grown long; split it into focused
|
||||
sections (or per-area files) so readers can navigate it without scrolling
|
||||
the whole thing.
|
||||
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent
|
||||
channels beyond `send`/`steer`/events.
|
||||
- **Persistence backends** (JSONL session dirs, sqlite) on the
|
||||
`session/event` + `session/flush` seam.
|
||||
- **Compaction implementation** (auto thresholds, summarization prompts) on
|
||||
the `agent/request` seam, with its session-event types added by declaration
|
||||
merging.
|
||||
- **Restructure this document** — it has grown long; split it into focused sections (or per-area files) so readers can navigate it without scrolling the whole thing.
|
||||
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events.
|
||||
- **Persistence backends** (JSONL session dirs, sqlite) on the `session/event` + `session/flush` seam.
|
||||
- **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging.
|
||||
- **Parallel tool execution** (concurrency-safety hints on ToolDefinition).
|
||||
- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based
|
||||
forking.
|
||||
- **Session event vocabulary review** once the loop and a persistence plugin
|
||||
coexist (`TODO(review)` in dsh-session).
|
||||
- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking.
|
||||
- **Session event vocabulary review** once the loop and a persistence plugin coexist (`TODO(review)` in dsh-session).
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Cookbook: adding a workspace package
|
||||
|
||||
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package.
|
||||
(Verified by the bash and adapter packages; if it drifts, fix it here.)
|
||||
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package. (Verified by the bash and adapter packages; if it drifts, fix it here.)
|
||||
|
||||
## 1. Create the package
|
||||
|
||||
@@ -16,11 +15,7 @@ packages/<name>/
|
||||
README.md # service API, events, extension points, design notes
|
||||
```
|
||||
|
||||
package.json invariants (enforced by `yarn constraints` / yarn.config.cjs):
|
||||
`private: true`, `version: 0.0.1`, `type: module`, `cordis` in BOTH
|
||||
peerDependencies and devDependencies (same range). Mirror every dsh peer
|
||||
dependency in devDependencies. `schemastery` goes in `dependencies` (it is a
|
||||
runtime validator), matching agent-loop.
|
||||
package.json invariants (enforced by `yarn constraints` / yarn.config.cjs): `private: true`, `version: 0.0.1`, `type: module`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop.
|
||||
|
||||
## 2. Register it in the root configs
|
||||
|
||||
@@ -32,14 +27,11 @@ runtime validator), matching agent-loop.
|
||||
| `scripts/publint-all.ts` | add `'packages/<name>'` to the array |
|
||||
| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm-deepseek`) |
|
||||
|
||||
Covered automatically by globs — no edits needed: root `package.json`
|
||||
workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`.
|
||||
Covered automatically by globs — no edits needed: root `package.json` workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`.
|
||||
|
||||
## 3. Decide the package topology
|
||||
|
||||
For a swappable capability, split interface / implementation / consumer into
|
||||
separate packages (see docs/architecture.md § "Capability seams" — the bash
|
||||
trio is the template). A single-purpose plugin stays one package.
|
||||
For a swappable capability, split interface / implementation / consumer into separate packages (see docs/architecture.md § "Capability seams" — the bash trio is the template). A single-purpose plugin stays one package.
|
||||
|
||||
## 4. Verify
|
||||
|
||||
@@ -50,6 +42,4 @@ yarn test:coverage # 100% per-file over src (types.ts exempt)
|
||||
yarn build && yarn knip && yarn publint
|
||||
```
|
||||
|
||||
Test expectations: every registry/registration needs an HMR-safety test
|
||||
(register from a child fiber, dispose it, assert cleanup). Excessive tests
|
||||
are welcome — see AGENTS.md.
|
||||
Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see AGENTS.md.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# Cookbook: adding a tool
|
||||
|
||||
How to give the model a new capability. Reference implementations:
|
||||
`examples/echo-agent/src/echo-tool.ts` (minimal) and
|
||||
`packages/tool-bash` (production-grade, three-package seam).
|
||||
How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/tool-bash` (production-grade, three-package seam).
|
||||
|
||||
## The minimal shape
|
||||
|
||||
@@ -30,33 +28,18 @@ export function apply(ctx: Context) {
|
||||
}
|
||||
```
|
||||
|
||||
Registration is effect-based: disposing the plugin fiber unregisters the
|
||||
tool (write the HMR test). Schemas flow into the system-prompt assembly
|
||||
automatically.
|
||||
Registration is effect-based: disposing the plugin fiber unregisters the tool (write the HMR test). Schemas flow into the system-prompt assembly automatically.
|
||||
|
||||
## Rules of the execute() contract
|
||||
|
||||
- **Validate args at runtime.** `defineTool`'s `InferArgs` typing is
|
||||
compile-time only; at runtime `arguments` is whatever JSON the model
|
||||
emitted. Check every field; throw a descriptive Error for bad input.
|
||||
- **Throwing means isError.** The registry catches anything `execute()`
|
||||
throws and returns `{isError: true}` to the model. Use that for
|
||||
infrastructure failures (bad input, spawn errors, aborts) — but REPORT
|
||||
domain failures in the result text instead (e.g. tool-bash returns
|
||||
`[exit code: 9]` with `isError: false`: the model decides what a failing
|
||||
command means).
|
||||
- **Validate args at runtime.** `defineTool`'s `InferArgs` typing is compile-time only; at runtime `arguments` is whatever JSON the model emitted. Check every field; throw a descriptive Error for bad input.
|
||||
- **Throwing means isError.** The registry catches anything `execute()` throws and returns `{isError: true}` to the model. Use that for infrastructure failures (bad input, spawn errors, aborts) — but REPORT domain failures in the result text instead (e.g. tool-bash returns `[exit code: 9]` with `isError: false`: the model decides what a failing command means).
|
||||
- **Honor `exec.signal`.** Cancel in-flight work when it fires.
|
||||
- **Use `exec.agent` for async notifications.** `agent.inject(content,
|
||||
{source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the
|
||||
NEXT model request sees — it is not a wake-up (an idle agent stays idle).
|
||||
Guard against disposed agents (try/catch).
|
||||
- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
|
||||
|
||||
## Long-running work
|
||||
|
||||
Follow tool-bash's background pattern: a `run_in_background` flag returns a
|
||||
task id immediately; companion tools poll incrementally and kill; completion
|
||||
notices arrive via `agent.inject()`. Bound buffers and spill full output to
|
||||
disk so nothing is silently lost.
|
||||
Follow tool-bash's background pattern: a `run_in_background` flag returns a task id immediately; companion tools poll incrementally and kill; completion notices arrive via `agent.inject()`. Bound buffers and spill full output to disk so nothing is silently lost.
|
||||
|
||||
> TODO: each tool reimplements this background pattern by hand today. At some
|
||||
> point we need a generic long-running-tool layer that handles task ids,
|
||||
@@ -64,14 +47,8 @@ disk so nothing is silently lost.
|
||||
|
||||
## Permissions / sandboxing
|
||||
|
||||
Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall
|
||||
(veto or wrap — see the permission-gate example in docs/architecture.md), or
|
||||
a sandboxing implementation behind the tool's executor seam.
|
||||
Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall (veto or wrap — see the permission-gate example in docs/architecture.md), or a sandboxing implementation behind the tool's executor seam.
|
||||
|
||||
## Tests every tool needs
|
||||
|
||||
Arg-validation rejections, result shaping for every outcome, the HMR
|
||||
disposal test, and — for tools with side effects — an integration spec that
|
||||
drives the tool through the agent loop with a scripted `MockAdapter`
|
||||
(`packages/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` /
|
||||
`tool/result` session events.
|
||||
Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events.
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# Cookbook: adding an LLM adapter
|
||||
|
||||
How to connect a new model provider. Reference implementations:
|
||||
`packages/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm-pi-ai`
|
||||
(wrapping an LLM library). Read the `StreamChunk` doc in
|
||||
`packages/llm/src/types.ts` first — it records the protocol conventions both
|
||||
adapters were verified against.
|
||||
How to connect a new model provider. Reference implementations: `packages/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
|
||||
|
||||
## The shape
|
||||
|
||||
@@ -22,54 +18,26 @@ export function apply(ctx: Context, config: Config) {
|
||||
}
|
||||
```
|
||||
|
||||
Registration is effect-based (HMR-safe); one adapter per model name —
|
||||
duplicates throw. Secrets are cordis-native: schemastery Config with env
|
||||
fallbacks, fed from cordis.yml via `!!js process.env.MY_KEY`. Never read
|
||||
ad-hoc key files in code.
|
||||
Registration is effect-based (HMR-safe); one adapter per model name — duplicates throw. Secrets are cordis-native: schemastery Config with env fallbacks, fed from cordis.yml via `!!js process.env.MY_KEY`. Never read ad-hoc key files in code.
|
||||
|
||||
## Protocol obligations (the contract two implementations verified)
|
||||
|
||||
- Emit `usage` BEFORE `finish`; emit NOTHING after `finish`. The robust way:
|
||||
buffer finish/usage until the provider's end-of-stream marker, then flush
|
||||
(handles providers that send trailing usage-only chunks).
|
||||
- Tool-call `arguments` are RAW JSON strings end-to-end; stream fragments as
|
||||
`argumentsDelta`. If your provider hands back parsed objects, re-stringify
|
||||
at `block-end`.
|
||||
- Allocate block `index`es in first-seen stream order; reuse the index for
|
||||
every delta of the same block.
|
||||
- Errors have exactly two sanctioned paths: THROW from `stream()` (transport
|
||||
and protocol failures — use `LlmError` with a stable code), or end the
|
||||
stream with `finish {kind: 'error' | 'aborted'}` (provider in-band
|
||||
failures). Consumers handle both; pick per failure class and document it.
|
||||
- Emit `usage` BEFORE `finish`; emit NOTHING after `finish`. The robust way: buffer finish/usage until the provider's end-of-stream marker, then flush (handles providers that send trailing usage-only chunks).
|
||||
- Tool-call `arguments` are RAW JSON strings end-to-end; stream fragments as `argumentsDelta`. If your provider hands back parsed objects, re-stringify at `block-end`.
|
||||
- Allocate block `index`es in first-seen stream order; reuse the index for every delta of the same block.
|
||||
- Errors have exactly two sanctioned paths: THROW from `stream()` (transport and protocol failures — use `LlmError` with a stable code), or end the stream with `finish {kind: 'error' | 'aborted'}` (provider in-band failures). Consumers handle both; pick per failure class and document it.
|
||||
- Honor `options.signal` (pass it to fetch / your SDK).
|
||||
- `prefill` and other unsupported `GenerateOptions` fields: throw
|
||||
`LlmError(..., 'UNSUPPORTED')` rather than silently dropping.
|
||||
- `prefill` and other unsupported `GenerateOptions` fields: throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping.
|
||||
|
||||
Provider-specific request knobs (thinking modes, effort levels) belong in
|
||||
the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays
|
||||
provider-neutral.
|
||||
Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral.
|
||||
|
||||
## Structure that worked
|
||||
|
||||
Split the adapter into testable stages (llm-deepseek's layout): wire types
|
||||
(`types.ts`, coverage-exempt) → request serializer → SSE/transport parser →
|
||||
chunk-translation state machine → a thin adapter class wiring them. Each
|
||||
stage gets its own unit suite.
|
||||
Split the adapter into testable stages (llm-deepseek's layout): wire types (`types.ts`, coverage-exempt) → request serializer → SSE/transport parser → chunk-translation state machine → a thin adapter class wiring them. Each stage gets its own unit suite.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit: mock the provider, not the harness.** A scripted `node:http`
|
||||
server speaking the provider's wire format covers happy paths, every error
|
||||
status, malformed payloads, premature closes, and aborts — no network, and
|
||||
it drives the 100% per-file coverage gate. Works for SDK-backed adapters
|
||||
too (point the SDK's baseURL at the mock).
|
||||
- **Hostile framing tests.** Split stream payloads at arbitrary byte
|
||||
positions (including mid-UTF-8) — real networks do.
|
||||
- **E2E: `tests/*.e2e.ts`** under `yarn test:e2e`, gated with
|
||||
`describe.skipIf(!process.env.MY_KEY)` so CI (no secrets) stays green.
|
||||
Cover each model × each provider mode you map (thinking on/off, effort
|
||||
levels), a tool-call round trip INCLUDING the follow-up turn with results
|
||||
in history, and loose assertions only (substring/structure, bounded
|
||||
maxTokens — real models are nondeterministic).
|
||||
- Register the e2e file pattern in `knip.json` (per-workspace `entry`
|
||||
override) or knip flags it unused.
|
||||
- **Unit: mock the provider, not the harness.** A scripted `node:http` server speaking the provider's wire format covers happy paths, every error status, malformed payloads, premature closes, and aborts — no network, and it drives the 100% per-file coverage gate. Works for SDK-backed adapters too (point the SDK's baseURL at the mock).
|
||||
- **Hostile framing tests.** Split stream payloads at arbitrary byte positions (including mid-UTF-8) — real networks do.
|
||||
- **E2E: `tests/*.e2e.ts`** under `yarn test:e2e`, gated with `describe.skipIf(!process.env.MY_KEY)` so CI (no secrets) stays green. Cover each model × each provider mode you map (thinking on/off, effort levels), a tool-call round trip INCLUDING the follow-up turn with results in history, and loose assertions only (substring/structure, bounded maxTokens — real models are nondeterministic).
|
||||
- Register the e2e file pattern in `knip.json` (per-workspace `entry` override) or knip flags it unused.
|
||||
|
||||
@@ -4,44 +4,21 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Example-based tests pin the cases we thought of. The harness's core is
|
||||
protocol-shaped — chunk streams, event logs, schema conversion — where the
|
||||
input space is combinatorial and the interesting bugs live in interleavings
|
||||
nobody wrote an example for (the `streamBlocks` ordering bug survived 100%
|
||||
line coverage of the happy paths).
|
||||
Example-based tests pin the cases we thought of. The harness's core is protocol-shaped — chunk streams, event logs, schema conversion — where the input space is combinatorial and the interesting bugs live in interleavings nobody wrote an example for (the `streamBlocks` ordering bug survived 100% line coverage of the happy paths).
|
||||
|
||||
## Proposal
|
||||
|
||||
Adopt fast-check (vitest integration) with generators for our vocabulary:
|
||||
|
||||
- **BlockAssembler**: arbitrary chunk sequences (valid and malformed —
|
||||
duplicate indices, stragglers after block-end, missing block-start).
|
||||
Invariants: `flushReady() + flushRemaining() ≡ blocks()` in order;
|
||||
`streamBlocks ≡ generate().message.content`; memory bounded (partials map
|
||||
size ≤ distinct indices); idempotent re-assembly.
|
||||
- **Session**: arbitrary event logs (seeded generators over SessionEventMap).
|
||||
Invariants: `deriveMessages` deterministic; replay-from-seed produces
|
||||
identical derivation; seq strictly monotonic; derived history unaffected by
|
||||
non-message events.
|
||||
- **Schema DSL**: arbitrary SchemaSpecs. Invariants: generated JSON Schema's
|
||||
`required` array equals the `required: true` keys at every nesting level;
|
||||
conversion is total (never throws); generated args satisfying `InferArgs`
|
||||
validate against the generated schema (once RFC 005's validator exists —
|
||||
the two RFCs compose).
|
||||
- **Inbox/loop**: arbitrary send/steer/abort schedules against a scripted
|
||||
adapter. Invariants: no message lost (every send/steer appears in the log
|
||||
exactly once), turn numbers strictly increase, status transitions follow
|
||||
idle→running→idle/disposed.
|
||||
- **BlockAssembler**: arbitrary chunk sequences (valid and malformed — duplicate indices, stragglers after block-end, missing block-start). Invariants: `flushReady() + flushRemaining() ≡ blocks()` in order; `streamBlocks ≡ generate().message.content`; memory bounded (partials map size ≤ distinct indices); idempotent re-assembly.
|
||||
- **Session**: arbitrary event logs (seeded generators over SessionEventMap). Invariants: `deriveMessages` deterministic; replay-from-seed produces identical derivation; seq strictly monotonic; derived history unaffected by non-message events.
|
||||
- **Schema DSL**: arbitrary SchemaSpecs. Invariants: generated JSON Schema's `required` array equals the `required: true` keys at every nesting level; conversion is total (never throws); generated args satisfying `InferArgs` validate against the generated schema (once RFC 005's validator exists — the two RFCs compose).
|
||||
- **Inbox/loop**: arbitrary send/steer/abort schedules against a scripted adapter. Invariants: no message lost (every send/steer appears in the log exactly once), turn numbers strictly increase, status transitions follow idle→running→idle/disposed.
|
||||
|
||||
## Plan
|
||||
|
||||
One `tests/properties.spec.ts` per package; fast-check as devDependency;
|
||||
numRuns tuned so the suite stays under ~10s locally, with a nightly CI job
|
||||
running 100× the iterations. Failures persist their seed in the report so
|
||||
agents can reproduce deterministically.
|
||||
One `tests/properties.spec.ts` per package; fast-check as devDependency; numRuns tuned so the suite stays under ~10s locally, with a nightly CI job running 100× the iterations. Failures persist their seed in the report so agents can reproduce deterministically.
|
||||
|
||||
## Risks
|
||||
|
||||
Generator quality determines value — invest in generators that produce
|
||||
*realistic-but-adversarial* streams, not uniform noise. Property flake from
|
||||
timeouts must be treated as a finding, not retried away.
|
||||
Generator quality determines value — invest in generators that produce *realistic-but-adversarial* streams, not uniform noise. Property flake from timeouts must be treated as a finding, not retried away.
|
||||
|
||||
@@ -4,35 +4,23 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
The per-file 100% coverage gate (ADR 0007) proves every line *executes* under
|
||||
test — not that any assertion would notice if the line were wrong. Under
|
||||
agent-written tests, coverage pressure can produce execution-without-assertion.
|
||||
Mutation testing measures what coverage cannot: whether the suite *kills*
|
||||
deliberately injected bugs.
|
||||
The per-file 100% coverage gate (ADR 0007) proves every line *executes* under test — not that any assertion would notice if the line were wrong. Under agent-written tests, coverage pressure can produce execution-without-assertion. Mutation testing measures what coverage cannot: whether the suite *kills* deliberately injected bugs.
|
||||
|
||||
## Proposal
|
||||
|
||||
Stryker (`@stryker-mutator/vitest-runner`) over `packages/*/src`:
|
||||
|
||||
- **PR-scoped incremental runs** (changed files only) as a CI job — fast
|
||||
enough to gate merges once tuned.
|
||||
- **Nightly full runs** with a tracked mutation score; start by recording,
|
||||
then set the threshold at the observed baseline and ratchet upward (same
|
||||
policy as coverage: thresholds only ever tighten).
|
||||
- Surviving mutants are work items: an agent picks a survivor, writes the
|
||||
killing test, repeats — a well-shaped autonomous loop.
|
||||
- Equivalent mutants (provably behavior-preserving) get annotated exclusions
|
||||
with reasons, mirroring the `/* v8 ignore */` policy.
|
||||
- **PR-scoped incremental runs** (changed files only) as a CI job — fast enough to gate merges once tuned.
|
||||
- **Nightly full runs** with a tracked mutation score; start by recording, then set the threshold at the observed baseline and ratchet upward (same policy as coverage: thresholds only ever tighten).
|
||||
- Surviving mutants are work items: an agent picks a survivor, writes the killing test, repeats — a well-shaped autonomous loop.
|
||||
- Equivalent mutants (provably behavior-preserving) get annotated exclusions with reasons, mirroring the `/* v8 ignore */` policy.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add Stryker config scoped to one package (llm — smallest, most algorithmic)
|
||||
and measure runtime.
|
||||
1. Add Stryker config scoped to one package (llm — smallest, most algorithmic) and measure runtime.
|
||||
2. Expand to all packages; record baseline scores in the config.
|
||||
3. Wire the nightly job; add the incremental PR job once runtime is acceptable.
|
||||
|
||||
## Risks
|
||||
|
||||
Runtime: mutation testing is expensive; per-file 100% coverage helps (every
|
||||
mutant is at least reached). If PR-scoped runs stay too slow, keep them
|
||||
nightly-only and rely on the score ratchet.
|
||||
Runtime: mutation testing is expensive; per-file 100% coverage helps (every mutant is at least reached). If PR-scoped runs stay too slow, keep them nightly-only and rely on the score ratchet.
|
||||
|
||||
@@ -4,37 +4,20 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt
|
||||
that wastes agent cycles on retries and can mask ordering bugs. Separately,
|
||||
our core architectural promise (any session log replays to identical derived
|
||||
history) is asserted in two tests but is cheap to assert *everywhere*. And
|
||||
the inbox wakeup race was verified by hand exactly once; nothing re-verifies
|
||||
it continuously.
|
||||
Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt that wastes agent cycles on retries and can mask ordering bugs. Separately, our core architectural promise (any session log replays to identical derived history) is asserted in two tests but is cheap to assert *everywhere*. And the inbox wakeup race was verified by hand exactly once; nothing re-verifies it continuously.
|
||||
|
||||
## Proposal
|
||||
|
||||
Three measures:
|
||||
|
||||
1. **No wall-clock sleeps in tests.** Replace `setTimeout(N)` waits with
|
||||
event-driven waits (the existing `waitForIdle` pattern, extended to
|
||||
`waitForStatus`, `waitForEvent(n)`) or vitest fake timers where time
|
||||
itself is under test. Enforce with a lint rule banning `setTimeout` in
|
||||
`packages/*/tests` outside an allowlisted helper module.
|
||||
2. **Universal replay fixture.** A shared test helper wraps the loop harness
|
||||
so that after every test, the agent's session log is replayed into a fresh
|
||||
Session and `deriveMessages()` equality is asserted automatically. The
|
||||
invariant then gets checked hundreds of times per CI run across every
|
||||
scenario the suite produces, not twice.
|
||||
3. **Nightly race stress.** A CI job running the agent-loop and inbox suites
|
||||
with `vitest --repeat=200` (and `--shuffle`) to flush scheduling-dependent
|
||||
failures; any flake found is a bug to fix, never a retry.
|
||||
1. **No wall-clock sleeps in tests.** Replace `setTimeout(N)` waits with event-driven waits (the existing `waitForIdle` pattern, extended to `waitForStatus`, `waitForEvent(n)`) or vitest fake timers where time itself is under test. Enforce with a lint rule banning `setTimeout` in `packages/*/tests` outside an allowlisted helper module.
|
||||
2. **Universal replay fixture.** A shared test helper wraps the loop harness so that after every test, the agent's session log is replayed into a fresh Session and `deriveMessages()` equality is asserted automatically. The invariant then gets checked hundreds of times per CI run across every scenario the suite produces, not twice.
|
||||
3. **Nightly race stress.** A CI job running the agent-loop and inbox suites with `vitest --repeat=200` (and `--shuffle`) to flush scheduling-dependent failures; any flake found is a bug to fix, never a retry.
|
||||
|
||||
## Plan
|
||||
|
||||
Land 1 and 2 together (they touch the same helpers); add the nightly job
|
||||
after the suite is sleep-free so repeats are fast.
|
||||
Land 1 and 2 together (they touch the same helpers); add the nightly job after the suite is sleep-free so repeats are fast.
|
||||
|
||||
## Risks
|
||||
|
||||
Fake timers interact subtly with Promise scheduling in the loop — prefer
|
||||
event-driven waits; reserve fake timers for timer-service behavior itself.
|
||||
Fake timers interact subtly with Promise scheduling in the loop — prefer event-driven waits; reserve fake timers for timer-service behavior itself.
|
||||
|
||||
@@ -4,40 +4,24 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Two architectural guarantees currently live only in prose: (1) nothing
|
||||
depends on the concrete loop package (the microkernel promise, ADR 0002), and
|
||||
(2) every LlmAdapter speaks the chunk protocol correctly. Both should be
|
||||
mechanical (ADR 0007).
|
||||
Two architectural guarantees currently live only in prose: (1) nothing depends on the concrete loop package (the microkernel promise, ADR 0002), and (2) every LlmAdapter speaks the chunk protocol correctly. Both should be mechanical (ADR 0007).
|
||||
|
||||
## Proposal
|
||||
|
||||
**dependency-cruiser** with rules:
|
||||
|
||||
- `packages/*` (except agent-loop's own tests and examples/) must not import
|
||||
`@deepseek-ai/dsh-agent-loop`.
|
||||
- No cross-package deep imports (`@deepseek-ai/dsh-*/src/...` paths) — public
|
||||
entry points only.
|
||||
- `packages/*` (except agent-loop's own tests and examples/) must not import `@deepseek-ai/dsh-agent-loop`.
|
||||
- No cross-package deep imports (`@deepseek-ai/dsh-*/src/...` paths) — public entry points only.
|
||||
- No import cycles anywhere in packages/.
|
||||
- `vendor/*` must not import from `packages/*`.
|
||||
- Layering: dsh-llm imports nothing from other dsh packages; dsh-session only
|
||||
dsh-llm; etc. (the dependency table in packages/README.md, enforced).
|
||||
- Layering: dsh-llm imports nothing from other dsh packages; dsh-session only dsh-llm; etc. (the dependency table in packages/README.md, enforced).
|
||||
|
||||
**Adapter conformance kit** in dsh-llm (`@deepseek-ai/dsh-llm/conformance`):
|
||||
a reusable vitest suite parameterized by an adapter factory, asserting the
|
||||
chunk-protocol contract — index monotonicity per block, no deltas after
|
||||
`block-end` for an index, exactly one `finish`, usage at most once, every
|
||||
`tool-call-delta` carries the call id, abort honored promptly. Run it against
|
||||
the mocks now; the DeepSeek V4 adapter inherits it on day one. Optionally a
|
||||
dev-mode `strictAdapter()` wrapper enforcing the same at runtime behind a
|
||||
debug flag (pairs with RFC 005's invariants).
|
||||
**Adapter conformance kit** in dsh-llm (`@deepseek-ai/dsh-llm/conformance`): a reusable vitest suite parameterized by an adapter factory, asserting the chunk-protocol contract — index monotonicity per block, no deltas after `block-end` for an index, exactly one `finish`, usage at most once, every `tool-call-delta` carries the call id, abort honored promptly. Run it against the mocks now; the DeepSeek V4 adapter inherits it on day one. Optionally a dev-mode `strictAdapter()` wrapper enforcing the same at runtime behind a debug flag (pairs with RFC 005's invariants).
|
||||
|
||||
## Plan
|
||||
|
||||
dependency-cruiser config + CI step first (an hour of work, permanent
|
||||
guarantee); the conformance kit lands with its first consumer test against
|
||||
MockAdapter, and is a prerequisite for the V4 adapter phase.
|
||||
dependency-cruiser config + CI step first (an hour of work, permanent guarantee); the conformance kit lands with its first consumer test against MockAdapter, and is a prerequisite for the V4 adapter phase.
|
||||
|
||||
## Risks
|
||||
|
||||
Dep-cruiser rule maintenance as packages are added — keep rules pattern-based
|
||||
(`dsh-*`) rather than enumerated.
|
||||
Dep-cruiser rule maintenance as packages are added — keep rules pattern-based (`dsh-*`) rather than enumerated.
|
||||
|
||||
@@ -6,44 +6,20 @@ Status: proposed
|
||||
|
||||
Three gaps where compile-time guarantees stop:
|
||||
|
||||
1. Tool args are model-generated JSON — `defineTool`'s `InferArgs<S>` claim
|
||||
is only as true as the model's output. Today a malformed call reaches
|
||||
`execute` untyped-in-practice.
|
||||
2. Tool errors flatten to a text block; name/code/stack are lost, so future
|
||||
sandbox/retry plugins can't distinguish ENOENT from EACCES, and the model
|
||||
gets less actionable feedback than it could.
|
||||
3. Loop ordering invariants (seq monotonicity, step/turn event nesting,
|
||||
turn-number continuity) are asserted only where tests look.
|
||||
1. Tool args are model-generated JSON — `defineTool`'s `InferArgs<S>` claim is only as true as the model's output. Today a malformed call reaches `execute` untyped-in-practice.
|
||||
2. Tool errors flatten to a text block; name/code/stack are lost, so future sandbox/retry plugins can't distinguish ENOENT from EACCES, and the model gets less actionable feedback than it could.
|
||||
3. Loop ordering invariants (seq monotonicity, step/turn event nesting, turn-number continuity) are asserted only where tests look.
|
||||
|
||||
## Proposal
|
||||
|
||||
1. **Schema validation in defineTool**: before `execute`, validate parsed
|
||||
args against the SchemaSpec (the converter already encodes the structure —
|
||||
a small interpreter walks it: presence of required keys, primitive type
|
||||
checks, enum membership, recursion into objects/arrays). On mismatch,
|
||||
return an `isError` ToolExecutionResult describing the violation — the
|
||||
model can self-correct. Raw-registered tools (MCP) keep validating their
|
||||
own input.
|
||||
2. **Structured error taxonomy**: per-package error classes extending a
|
||||
common `HarnessError` (name, `code`, `cause` chaining).
|
||||
`ToolExecutionResult` gains optional `error: { name, code }` alongside the
|
||||
model-facing text. The loop's `errorData` consumes it; session `error`
|
||||
events carry the code. This also properly fixes the non-Error-throw
|
||||
message degradation found in review.
|
||||
3. **Dev-mode invariants**: a `dsh-invariants` debug plugin (everything is a
|
||||
plugin — it's just listeners) asserting, when enabled: session seq strictly
|
||||
increases; `step/start` precedes its chunks; `turn/start`/`turn/end` pair
|
||||
and nest; tool/call has a matching tool/result; status transitions are
|
||||
legal. Enabled in tests and the demo; off in production. Doubles as
|
||||
executable documentation of the event contract.
|
||||
1. **Schema validation in defineTool**: before `execute`, validate parsed args against the SchemaSpec (the converter already encodes the structure — a small interpreter walks it: presence of required keys, primitive type checks, enum membership, recursion into objects/arrays). On mismatch, return an `isError` ToolExecutionResult describing the violation — the model can self-correct. Raw-registered tools (MCP) keep validating their own input.
|
||||
2. **Structured error taxonomy**: per-package error classes extending a common `HarnessError` (name, `code`, `cause` chaining). `ToolExecutionResult` gains optional `error: { name, code }` alongside the model-facing text. The loop's `errorData` consumes it; session `error` events carry the code. This also properly fixes the non-Error-throw message degradation found in review.
|
||||
3. **Dev-mode invariants**: a `dsh-invariants` debug plugin (everything is a plugin — it's just listeners) asserting, when enabled: session seq strictly increases; `step/start` precedes its chunks; `turn/start`/`turn/end` pair and nest; tool/call has a matching tool/result; status transitions are legal. Enabled in tests and the demo; off in production. Doubles as executable documentation of the event contract.
|
||||
|
||||
## Plan
|
||||
|
||||
2 first (taxonomy is a dependency of 1's error shape), then 1, then 3.
|
||||
Property tests (RFC 001) then close the loop: generated args ↔ validator ↔
|
||||
InferArgs agreement.
|
||||
2 first (taxonomy is a dependency of 1's error shape), then 1, then 3. Property tests (RFC 001) then close the loop: generated args ↔ validator ↔ InferArgs agreement.
|
||||
|
||||
## Risks
|
||||
|
||||
Validator/InferArgs drift — covered by the RFC 001 composition property.
|
||||
Validation cost per call is negligible next to a model call.
|
||||
Validator/InferArgs drift — covered by the RFC 001 composition property. Validation cost per call is negligible next to a model call.
|
||||
|
||||
@@ -4,36 +4,18 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
AGENTS.md policy says docs and code must stay strictly in sync, but sync is
|
||||
verified by eyeball. Review has already caught drift twice (a cookbook
|
||||
example contradicting the type policy; a README citing the wrong
|
||||
registerAdapter call). Public API changes are similarly invisible — nothing
|
||||
makes "this commit changed the public surface" an explicit, reviewable fact.
|
||||
AGENTS.md policy says docs and code must stay strictly in sync, but sync is verified by eyeball. Review has already caught drift twice (a cookbook example contradicting the type policy; a README citing the wrong registerAdapter call). Public API changes are similarly invisible — nothing makes "this commit changed the public surface" an explicit, reviewable fact.
|
||||
|
||||
## Proposal
|
||||
|
||||
1. **Typecheck documentation code blocks.** A script extracts fenced ```ts
|
||||
blocks from README.md / docs/architecture.md / packages/*/README.md into a
|
||||
temp project resolving workspace packages, and runs tsc. Blocks that are
|
||||
intentionally elided get an explicit `ts ignore-check` info string —
|
||||
opt-out is visible in the source. (twoslash is the fancier alternative;
|
||||
start with plain extraction.)
|
||||
2. **Generate or verify the event-taxonomy table.** The table in
|
||||
docs/architecture.md duplicates the `Events` declarations. Either generate
|
||||
it from source (ts-morph walk over the `declare module 'cordis'` blocks)
|
||||
or CI-assert that every declared event name appears in the table and vice
|
||||
versa.
|
||||
3. **API reports.** api-extractor (or `tsc --emitDeclarationOnly` + a
|
||||
normalized public-surface dump) producing a checked-in `etc/<pkg>.api.md`
|
||||
per package; CI fails if regeneration differs. Every public-API change
|
||||
becomes a diff line a reviewer (or review agent) must see.
|
||||
1. **Typecheck documentation code blocks.** A script extracts fenced ```ts blocks from README.md / docs/architecture.md / packages/*/README.md into a temp project resolving workspace packages, and runs tsc. Blocks that are intentionally elided get an explicit `ts ignore-check` info string — opt-out is visible in the source. (twoslash is the fancier alternative; start with plain extraction.)
|
||||
2. **Generate or verify the event-taxonomy table.** The table in docs/architecture.md duplicates the `Events` declarations. Either generate it from source (ts-morph walk over the `declare module 'cordis'` blocks) or CI-assert that every declared event name appears in the table and vice versa.
|
||||
3. **API reports.** api-extractor (or `tsc --emitDeclarationOnly` + a normalized public-surface dump) producing a checked-in `etc/<pkg>.api.md` per package; CI fails if regeneration differs. Every public-API change becomes a diff line a reviewer (or review agent) must see.
|
||||
|
||||
## Plan
|
||||
|
||||
1 is a standalone script + CI step. 3 next (it also documents the surface for
|
||||
plugin authors). 2 last — verify-don't-generate is likely sufficient.
|
||||
1 is a standalone script + CI step. 3 next (it also documents the surface for plugin authors). 2 last — verify-don't-generate is likely sufficient.
|
||||
|
||||
## Risks
|
||||
|
||||
Doc blocks often show fragments; the ignore-check escape hatch must stay rare
|
||||
or the gate is theater — lint the ratio if needed.
|
||||
Doc blocks often show fragments; the ignore-check escape hatch must stay rare or the gate is theater — lint the ratio if needed.
|
||||
|
||||
@@ -4,38 +4,19 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
The vendor manifest (ADR 0001) is enforced at commit time in the *forward*
|
||||
direction (vendored change ⇒ manifest update) but nothing verifies the
|
||||
manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus
|
||||
exactly the logged modifications. And the handful of true npm dependencies
|
||||
have no advisory monitoring or update cadence.
|
||||
The vendor manifest (ADR 0001) is enforced at commit time in the *forward* direction (vendored change ⇒ manifest update) but nothing verifies the manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus exactly the logged modifications. And the handful of true npm dependencies have no advisory monitoring or update cadence.
|
||||
|
||||
## Proposal
|
||||
|
||||
1. **Vendor drift check** (nightly CI): clone the upstream repos at the
|
||||
manifest SHAs (shallow), copy the corresponding package sources, and diff
|
||||
against `vendor/*/src`. The job fails unless the diff matches the logged
|
||||
local modifications (kept as a checked-in patch file per modification —
|
||||
the log entries become verifiable artifacts rather than prose).
|
||||
2. **Dependency advisories**: osv-scanner (or `yarn npm audit`) job on the
|
||||
lockfile, scheduled + on lockfile-touching PRs.
|
||||
3. **License inventory**: a script asserting every vendored package carries
|
||||
its LICENSE and that package.json `license` fields match the inventory in
|
||||
vendor/README.md (we mix vendored MIT with our BSD-3) — CI step.
|
||||
4. **Renovate** (or a scheduled agent task) proposing npm dependency updates
|
||||
in small PRs that ride the full gate suite; vendored packages are excluded
|
||||
(their updates follow the manifest sync procedure, ideally as a
|
||||
semi-automated agent workflow: fetch upstream, re-apply patches, run
|
||||
gates, open PR with the manifest table updated).
|
||||
1. **Vendor drift check** (nightly CI): clone the upstream repos at the manifest SHAs (shallow), copy the corresponding package sources, and diff against `vendor/*/src`. The job fails unless the diff matches the logged local modifications (kept as a checked-in patch file per modification — the log entries become verifiable artifacts rather than prose).
|
||||
2. **Dependency advisories**: osv-scanner (or `yarn npm audit`) job on the lockfile, scheduled + on lockfile-touching PRs.
|
||||
3. **License inventory**: a script asserting every vendored package carries its LICENSE and that package.json `license` fields match the inventory in vendor/README.md (we mix vendored MIT with our BSD-3) — CI step.
|
||||
4. **Renovate** (or a scheduled agent task) proposing npm dependency updates in small PRs that ride the full gate suite; vendored packages are excluded (their updates follow the manifest sync procedure, ideally as a semi-automated agent workflow: fetch upstream, re-apply patches, run gates, open PR with the manifest table updated).
|
||||
|
||||
## Plan
|
||||
|
||||
3 is trivial — do first. 1 requires network access from CI to the upstream
|
||||
repos (private — needs a token) and converting the two existing logged
|
||||
modifications into patch files. 2 and 4 are config.
|
||||
3 is trivial — do first. 1 requires network access from CI to the upstream repos (private — needs a token) and converting the two existing logged modifications into patch files. 2 and 4 are config.
|
||||
|
||||
## Risks
|
||||
|
||||
Upstream repos are private mirrors; CI credentials and availability are the
|
||||
main friction for the drift check. If blocked, run it as a local scheduled
|
||||
agent task instead of CI.
|
||||
Upstream repos are private mirrors; CI credentials and availability are the main friction for the drift check. If blocked, run it as a local scheduled agent task instead of CI.
|
||||
|
||||
@@ -4,41 +4,21 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
The session log is append-only by contract, but `session.events` returns
|
||||
`readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in
|
||||
and rewrite history (`events[0].data.content.push(...)`), silently breaking
|
||||
replay equivalence and the derived-history guarantee. The same applies to
|
||||
derived messages and prompt assemblies passed through waterfalls — mutation
|
||||
is sometimes the intended idiom (waterfall middleware mutates the request)
|
||||
and sometimes corruption (mutating a *logged* event), and the types don't
|
||||
distinguish.
|
||||
The session log is append-only by contract, but `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in and rewrite history (`events[0].data.content.push(...)`), silently breaking replay equivalence and the derived-history guarantee. The same applies to derived messages and prompt assemblies passed through waterfalls — mutation is sometimes the intended idiom (waterfall middleware mutates the request) and sometimes corruption (mutating a *logged* event), and the types don't distinguish.
|
||||
|
||||
## Proposal
|
||||
|
||||
Make immutability part of the type where mutation is corruption:
|
||||
|
||||
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session
|
||||
(`events`, `session/event` listeners); `append()` keeps taking plain
|
||||
mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to
|
||||
the brand/never helpers.
|
||||
- `deriveMessages()` returns deep-readonly messages; the loop clones before
|
||||
handing a mutable request to the `agent/request` waterfall (mutation there
|
||||
is sanctioned — the clone makes the boundary explicit and cheap, once per
|
||||
step).
|
||||
- `PromptAssembly` stays mutable through its waterfall (sanctioned) but the
|
||||
registry's internal section list is cloned per assembly (already true).
|
||||
- Optionally, dev-mode `Object.freeze` of event data behind the RFC 005
|
||||
invariants flag, so sanctioned-mutation violations throw in tests rather
|
||||
than corrupting silently.
|
||||
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to the brand/never helpers.
|
||||
- `deriveMessages()` returns deep-readonly messages; the loop clones before handing a mutable request to the `agent/request` waterfall (mutation there is sanctioned — the clone makes the boundary explicit and cheap, once per step).
|
||||
- `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true).
|
||||
- Optionally, dev-mode `Object.freeze` of event data behind the RFC 005 invariants flag, so sanctioned-mutation violations throw in tests rather than corrupting silently.
|
||||
|
||||
## Plan
|
||||
|
||||
Introduce `DeepReadonly`, flip the session read paths, fix resulting
|
||||
compile errors in consumers (expected: a handful in tests), add the
|
||||
freeze-in-dev option alongside RFC 005's invariants plugin.
|
||||
Introduce `DeepReadonly`, flip the session read paths, fix resulting compile errors in consumers (expected: a handful in tests), add the freeze-in-dev option alongside RFC 005's invariants plugin.
|
||||
|
||||
## Risks
|
||||
|
||||
`DeepReadonly` types can produce noisy errors at waterfall boundaries where
|
||||
mutation IS the API — keep the mutable/readonly boundary exactly at "logged
|
||||
vs in-flight" and document it in the session README.
|
||||
`DeepReadonly` types can produce noisy errors at waterfall boundaries where mutation IS the API — keep the mutable/readonly boundary exactly at "logged vs in-flight" and document it in the session README.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# RFCs
|
||||
|
||||
Proposals for substantial future work — reviewed before implementation,
|
||||
unlike ADRs (which record decisions already made). Each RFC groups a related
|
||||
set of ideas from the quality/robustness proposal (2026-06-11); statuses
|
||||
move proposed → accepted → implemented (then usually graduate to an ADR).
|
||||
Proposals for substantial future work — reviewed before implementation, unlike ADRs (which record decisions already made). Each RFC groups a related set of ideas from the quality/robustness proposal (2026-06-11); statuses move proposed → accepted → implemented (then usually graduate to an ADR).
|
||||
|
||||
| # | Title | Status |
|
||||
|---|---|---|
|
||||
|
||||
Reference in New Issue
Block a user