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:
Tianyi Cui 2026-06-13 18:39:20 +08:00
parent e98c1c5d42
commit 066f94c7e0
39 changed files with 348 additions and 1206 deletions

205
AGENTS.md
View file

@ -63,200 +63,59 @@ yarn demo:coding # run examples/coding-agent — the real agent (needs
## Secrets / .env ## Secrets / .env
Real-API e2e tests (`yarn test:e2e`) read `DEEPSEEK_API_KEY` (and optionally Real-API e2e tests (`yarn test:e2e`) read `DEEPSEEK_API_KEY` (and optionally `DEEPSEEK_BASE_URL`) from the environment, or from a gitignored `.env` at the repo root loaded via Node's native `process.loadEnvFile()`:
`DEEPSEEK_BASE_URL`) from the environment, or from a gitignored `.env` at the
repo root loaded via Node's native `process.loadEnvFile()`:
``` ```
DEEPSEEK_API_KEY=sk-… DEEPSEEK_API_KEY=sk-…
DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API
``` ```
cordis.yml configs reference env vars with the `!!js` tag: cordis.yml configs reference env vars with the `!!js` tag: `apiKey: !!js process.env.DEEPSEEK_API_KEY`. Never commit real credentials; CI has no secrets and e2e suites must self-skip without them.
`apiKey: !!js process.env.DEEPSEEK_API_KEY`. Never commit real credentials;
CI has no secrets and e2e suites must self-skip without them.
Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.json` (`vitest` resolves through `tsconfig.test.json`). Building is only needed for publishing/consumption outside the repo — with one exception: `yarn lint`'s type-aware rules resolve vendor packages through their built declarations (`tsconfig.typecheck.json` → `vendor/*/lib`), so run `yarn typecheck` once after a fresh clone (CI does the same) or lint reports unresolved-type `no-unsafe-*` errors.
`tsconfig.json` (`vitest` resolves through `tsconfig.test.json`). Building is
only needed for publishing/consumption outside the repo — with one exception:
`yarn lint`'s type-aware rules resolve vendor packages through their built
declarations (`tsconfig.typecheck.json` → `vendor/*/lib`), so run
`yarn typecheck` once after a fresh clone (CI does the same) or lint reports
unresolved-type `no-unsafe-*` errors.
## Conventions ## Conventions
- **Package naming**: every npm package in this repo is `@deepseek-ai/dsh-<name>` - **Package naming**: every npm package in this repo is `@deepseek-ai/dsh-<name>` (vendored packages keep their upstream names and are `private: true`).
(vendored packages keep their upstream names and are `private: true`). - **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package imports use explicit `.ts` extensions (allowImportingTsExtensions).
- **ESM everywhere** (`"type": "module"`); imports between workspace packages - **`cordis` is a peerDependency** (+ devDependency) of every harness package, mirroring upstream convention.
use package names, never relative paths across package boundaries. - **Registrations are effects**: anything a plugin contributes (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()` so disposal and HMR work. If you write a registry, `register()` must return the disposer.
In-package imports use explicit `.ts` extensions (allowImportingTsExtensions). - **Typed events via declaration merging**: services declare their events in `declare module 'cordis' { interface Events { … } }`, and their ctx key in `interface Context`. Extensible unions use the merge-extensible-map pattern (see `ContentBlockMap`, `MessageSourceMap`).
- **`cordis` is a peerDependency** (+ devDependency) of every harness package, - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits. This is the veto mechanism — use deliberately.
mirroring upstream convention. - **Discriminated unions: match, don't chain**: branch on a tagged union (`StreamChunk`, `FinishReason`, `SessionEvent`, …) with a `switch` on the tag, not a chain of `if (x.kind === '…')`. The switch narrows each arm so member-only fields (`finish.message`, `finish.code`) are reachable in the right case and a typo'd tag fails to compile. Prefer extracting a small typed helper (`finishError(finish: FinishReason)`) over inlining the branches at the call site.
- **Registrations are effects**: anything a plugin contributes (adapter, tool, - **Switch exhaustiveness**: switches over CLOSED unions (e.g. `StreamChunk`) end with `default: assertNever(value, 'context')` (from dsh-llm) so adding a variant breaks compilation at every switch that must handle it. Switches over MERGE-EXTENSIBLE unions (`SessionEventMap`, `ContentBlockMap`, `FinishReason`, …) must NOT use assertNever — plugin-added variants are valid unknown values; handle known cases and fall through `default` with a comment (the lint rule `switch-exhaustiveness-check` makes the choice explicit either way; a redundant disable directive is itself a lint error).
section, agent, event listener) goes through `ctx.effect()` / `ctx.on()` so - **Plugins, not loop changes**: new behavior goes into a plugin on the documented extension seams (see the plugin sanity checklist in docs/architecture.md). Changing `agent-loop` requires updating that doc.
disposal and HMR work. If you write a registry, `register()` must return the - **Capability seams are three packages**: when adding a swappable capability (an execution backend, a provider integration, …), split it into *interface* (abstract service + vocabulary types, e.g. `bash/`), *implementation* (a concrete subclass, e.g. `bash-local/`), and *consumer* (what the model/plugins see, e.g. `tool-bash/`). Implementations and consumers then evolve independently — a sandboxed executor replaces `bash-local` without touching tool schemas. The LLM seam follows the same shape (`llm/` is interface + consumer surface; adapters are implementations). See docs/architecture.md § "Capability seams" for when NOT to split.
disposer. - **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from.
- **Typed events via declaration merging**: services declare their events in - **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error.
`declare module 'cordis' { interface Events { … } }`, and their ctx key in - **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction.
`interface Context`. Extensible unions use the merge-extensible-map pattern - **Tests**: vitest, colocated under `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`).
(see `ContentBlockMap`, `MessageSourceMap`).
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`
and MUST call `next()` to delegate; returning without it short-circuits.
This is the veto mechanism — use deliberately.
- **Discriminated unions: match, don't chain**: branch on a tagged union
(`StreamChunk`, `FinishReason`, `SessionEvent`, …) with a `switch` on the
tag, not a chain of `if (x.kind === '…')`. The switch narrows each arm so
member-only fields (`finish.message`, `finish.code`) are reachable in the
right case and a typo'd tag fails to compile. Prefer extracting a small
typed helper (`finishError(finish: FinishReason)`) over inlining the
branches at the call site.
- **Switch exhaustiveness**: switches over CLOSED unions (e.g. `StreamChunk`)
end with `default: assertNever(value, 'context')` (from dsh-llm) so adding a
variant breaks compilation at every switch that must handle it. Switches
over MERGE-EXTENSIBLE unions (`SessionEventMap`, `ContentBlockMap`,
`FinishReason`, …) must NOT use assertNever — plugin-added variants are
valid unknown values; handle known cases and fall through `default` with a
comment (the lint rule `switch-exhaustiveness-check` makes the choice
explicit either way; a redundant disable directive is itself a lint error).
- **Plugins, not loop changes**: new behavior goes into a plugin on the
documented extension seams (see the plugin sanity checklist in
docs/architecture.md). Changing `agent-loop` requires updating that doc.
- **Capability seams are three packages**: when adding a swappable capability
(an execution backend, a provider integration, …), split it into
*interface* (abstract service + vocabulary types, e.g. `bash/`),
*implementation* (a concrete subclass, e.g. `bash-local/`), and
*consumer* (what the model/plugins see, e.g. `tool-bash/`). Implementations
and consumers then evolve independently — a sandboxed executor replaces
`bash-local` without touching tool schemas. The LLM seam follows the same
shape (`llm/` is interface + consumer surface; adapters are implementations).
See docs/architecture.md § "Capability seams" for when NOT to split.
- **Explicit > implicit at package seams**: interface/vocabulary types spell
out every field a consumer must supply — no optional field that the
implementation silently fills with a hidden `?? default`. Put defaulting in
the owning implementation as an explicit step (a `resolve(request): Spec`
method that turns the optional-field request into the required-field spec),
not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits
`BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from
`BashExecSpec` (required, what `run`/`start` act on); the tool layer calls
`ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has
to wonder where the working directory came from.
- **An empty `catch` must name what it swallows and why nothing else can hit
it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the
comment must (a) name the single expected failure, (b) say why ignoring it is
correct — usually because the useful state was already captured *before* the
`try` — and (c) make clear nothing else of consequence can reach the catch
(ideally the `try` wraps a single statement). Example: the error-body
`response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP
`status` from the status line before the `try`, so a malformed provider body
can only cost a richer message, never the real error.
- **Symmetry is usually more correct**: when two related values play parallel
roles (a test fixture and its expected output, a request shape and its
response shape, a buggy input and the test that checks the fix), give them
parallel form — both named consts, or both inline, not one each way. Asymmetry
is a smell that usually points at a missed extraction.
- **Tests**: vitest, colocated under `packages/<name>/tests/*.spec.ts`. Every
registry needs an HMR-safety test (dispose the contributing fiber, assert
cleanup). **Excessive tests are welcome** — when in doubt, write the test;
err on the side of covering edge cases, error paths, event ordering, and
concurrency races even if they seem unlikely. Review findings get regression
tests (see `packages/agent-loop/tests/review-fixes.spec.ts`).
## Defensive patterns (hard-won) ## Defensive patterns (hard-won)
Each bullet is a bug class that bit us; the rule prevents the reoccurrence. Each bullet is a bug class that bit us; the rule prevents the reoccurrence.
- **Report orthogonal outcomes independently.** A result can be several - **Report orthogonal outcomes independently.** A result can be several things at once (a process can both time out AND exit 0 because it trapped the signal). Don't nest the report of one flag inside the branch of another. Surface each independent fact (`timedOut`, `signal`, `exitCode`) on its own so a caller never reads a cut-short run as a clean success.
things at once (a process can both time out AND exit 0 because it trapped - **Honor cross-seam contracts on BOTH sides.** When an interface documents two valid ways to signal something (e.g. an adapter may report a model failure by THROWING from `stream()` *or* by ending the stream with a `finish {kind:'error'|'aborted'}` chunk), the consumer must handle both — not just the one the first implementation happened to use. A library-backed adapter that can't throw mid-stream relies on the finish-chunk path; if the loop only catches throws, a provider 401 becomes a normal completed turn. Document the contract where the type is defined and exercise every branch through the real consumer in tests.
the signal). Don't nest the report of one flag inside the branch of - **Async state is not synchronous state.** `agent.send()` does not flip status to `running` before it returns; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only *just* requested. Drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and when "done" needs a settle signal, observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns — the loop batches queued messages into one turn. But a settle-signal guard cuts both ways: if the awaited transition can *never* occur (EOF with no work submitted → no turn ever starts → never `running`), it hangs forever. Always handle the "nothing to wait for" branch explicitly alongside the "wait for the work" branch.
another. Surface each independent fact (`timedOut`, `signal`, `exitCode`) - **Dispose must reach quiescence, not just request it.** A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup `async` and `await` the children's exit (kill → await `done`), and close listener/notification registries *before* killing so late completions stay silent. Tests must prove disposal *waited* (pid already gone right after `await fiber.dispose()`), not merely that the process eventually dies.
on its own so a caller never reads a cut-short run as a clean success. - **Contain callback exceptions at the boundary.** A user-supplied listener (`onTaskDone`, event handlers) that throws must not reject the promise it runs inside or starve the listeners after it. Wrap the dispatch loop in try/catch and log; never let one bad subscriber break core lifecycle.
- **Honor cross-seam contracts on BOTH sides.** When an interface documents - **Never hand untrusted/model output the ambient environment or predictable paths.** Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/ `*TOKEN*`) so the harness's own credentials can't leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only (`'wx'`, `0o600`) opens — predictable world-readable paths invite symlink races and disclosure.
two valid ways to signal something (e.g. an adapter may report a model - **e2e tests own their resources.** Real-API/integration tests must create the harness in the test and dispose it in `afterEach` (even on failure/retry/timeout), so a flaky run doesn't leak processes or contexts. Shared fixtures live in a plain `tests/harness.ts` module, NOT another `*.e2e.ts` file — importing a spec file re-registers its `describe` and duplicates real API calls. Verify the WORLD, not the agent's self-report: re-run the command/check externally and assert files are byte-identical where they should be unchanged (a keyword probe lets a cheating agent pass).
failure by THROWING from `stream()` *or* by ending the stream with a - **Tag spelling and EOF hygiene.** cordis.yml interpolates env via the `!!js` tag (js-yaml resolves custom tags under `tag:yaml.org,2002:js`), not `!js` — keep code, comments, and docs consistent. Files end with exactly one trailing newline; `git diff --check` (a pre-push gate) rejects new blank lines at EOF.
`finish {kind:'error'|'aborted'}` chunk), the consumer must handle both —
not just the one the first implementation happened to use. A library-backed
adapter that can't throw mid-stream relies on the finish-chunk path; if the
loop only catches throws, a provider 401 becomes a normal completed turn.
Document the contract where the type is defined and exercise every branch
through the real consumer in tests.
- **Async state is not synchronous state.** `agent.send()` does not flip
status to `running` before it returns; a background task's completion races
turn boundaries; `reader.close()` fires for both EOF and disposal. Never
gate control flow on a status you only *just* requested. Drive lifecycle off
the events/promises that actually fire (`agent/status`, `task.done`), and
when "done" needs a settle signal, observe the transition (saw `running`
THEN `idle`) rather than counting actions you assume map 1:1 to turns —
the loop batches queued messages into one turn. But a settle-signal guard
cuts both ways: if the awaited transition can *never* occur (EOF with no
work submitted → no turn ever starts → never `running`), it hangs forever.
Always handle the "nothing to wait for" branch explicitly alongside the
"wait for the work" branch.
- **Dispose must reach quiescence, not just request it.** A teardown that
issues kills/aborts but returns before the work stops leaves orphans. Make
cleanup `async` and `await` the children's exit (kill → await `done`), and
close listener/notification registries *before* killing so late completions
stay silent. Tests must prove disposal *waited* (pid already gone right
after `await fiber.dispose()`), not merely that the process eventually dies.
- **Contain callback exceptions at the boundary.** A user-supplied listener
(`onTaskDone`, event handlers) that throws must not reject the promise it
runs inside or starve the listeners after it. Wrap the dispatch loop in
try/catch and log; never let one bad subscriber break core lifecycle.
- **Never hand untrusted/model output the ambient environment or predictable
paths.** Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/
`*TOKEN*`) so the harness's own credentials can't leak into output, `env`,
or spill files. Temp/spill files use a private (0700) dir, random names,
and exclusive owner-only (`'wx'`, `0o600`) opens — predictable
world-readable paths invite symlink races and disclosure.
- **e2e tests own their resources.** Real-API/integration tests must create
the harness in the test and dispose it in `afterEach` (even on
failure/retry/timeout), so a flaky run doesn't leak processes or contexts.
Shared fixtures live in a plain `tests/harness.ts` module, NOT another
`*.e2e.ts` file — importing a spec file re-registers its `describe` and
duplicates real API calls. Verify the WORLD, not the agent's self-report:
re-run the command/check externally and assert files are byte-identical
where they should be unchanged (a keyword probe lets a cheating agent pass).
- **Tag spelling and EOF hygiene.** cordis.yml interpolates env via the
`!!js` tag (js-yaml resolves custom tags under `tag:yaml.org,2002:js`), not
`!js` — keep code, comments, and docs consistent. Files end with exactly
one trailing newline; `git diff --check` (a pre-push gate) rejects new
blank lines at EOF.
## Type Safety and Documentation ## Type Safety and Documentation
This codebase aims to be **very type-safe and well documented** for This codebase aims to be **very type-safe and well documented** for maintainability. Code that fails to compile under `strict: true` (with `noImplicitAny` enabled for all `packages/*` source) is not acceptable. Every `any` that remains must have a specific justification (a comment explaining why a narrower type is infeasible).
maintainability. Code that fails to compile under `strict: true` (with
`noImplicitAny` enabled for all `packages/*` source) is not acceptable. Every
`any` that remains must have a specific justification (a comment explaining why
a narrower type is infeasible).
In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs<S>` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package.
`packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type
gymnastics are acceptable when they improve the DX of plugin authors** for
common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the
canonical example: the `SchemaSpec` to `InferArgs<S>` type-level mapping gives
tool authors zero-cast typed `execute` args, and the cost of the conditional
types stays inside the core package.
Verbose documentation is fine **as long as docs and code stay strictly in Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI has no doc-sync gate, so this is on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices.
sync**. Out-of-sync docs are worse than no docs. **When you change code, update
its docs in the SAME change** — grep the package README and the module/JSDoc
comments for the old behavior (config keys, defaults, error codes, wire field
names, event names) and fix every hit. CI has no doc-sync gate, so this is on
the author. Every module has a module-level doc comment explaining its role.
Every exported class, interface, type, function, and non-obvious method has a
JSDoc that explains semantics (not just the name) — contracts (what events fire
when), disposal behavior, error behavior, and extension intent. Internal
helpers get docs only where non-obvious. Prefer one-liners when one line
suffices.
**Editing these instructions**: `AGENTS.md` is the real file; `CLAUDE.md` is a **Markdown is not hard-wrapped**: write one line per paragraph and let the editor soft-wrap. Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. This applies to prose only: leave fenced code blocks, tables, and list structure intact (a wrapped list item folds to one line per bullet). Code comments / JSDoc are exempt — they stay under the linter's column limit.
symlink to it (at the repo root and in `packages/`). Always edit `AGENTS.md` —
never write through the `CLAUDE.md` symlink or replace it with a regular file. **Editing these instructions**: `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (at the repo root and in `packages/`). Always edit `AGENTS.md` — never write through the `CLAUDE.md` symlink or replace it with a regular file.
## Vendoring Policy ## Vendoring Policy
`vendor/` packages are pinned source copies (manifest with upstream commit `vendor/` packages are pinned source copies (manifest with upstream commit SHAs in [vendor/README.md](vendor/README.md)). To update one, follow the sync procedure there; re-apply (or retire) the logged local modifications and rerun `yarn test && yarn build`.
SHAs in [vendor/README.md](vendor/README.md)). To update one, follow the sync
procedure there; re-apply (or retire) the logged local modifications and rerun
`yarn test && yarn build`.

View file

@ -4,31 +4,17 @@ Status: accepted (2026-06-11)
## Context ## Context
DeepSeek Code is built on the Cordis framework. Cordis core was at 4.0.0-rc.6 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.
(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 ## Decision
Copy the needed Cordis packages (core, loader, include, group, timer, hmr, 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.
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 `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.
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 ## Consequences
- The harness fully owns its framework layer: auditable, patchable, pinned — - 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.
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.
- Upstream sync is manual (documented procedure in the manifest). The - Vendored packages keep upstream code style; lint/strictness gates exclude them (their tsconfigs relax our newer compiler flags locally).
modification log keeps the diff surface known. - One local patch exists from day one: hmr's locale-YAML imports removed (the runtime YAML import hook isn't vendored).
- 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).

View file

@ -4,35 +4,21 @@ Status: accepted (2026-06-11)
## Context ## Context
The product principle (see the 微内核Harness实现思路 design doc) is 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.
"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 ## Decision
Pure Cordis event taxonomy. The loop's extension seams are typed events with Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes:
deliberate dispatch modes:
- **waterfall** (around-middleware) where plugins mutate or veto: - **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`.
`agent/request`, `agent/step-result`, `agent/turn-continuation`, - **emit** (sync fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors.
`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`. - **parallel** (awaited) for the one durability checkpoint: `session/flush`.
The event vocabulary lives in interface packages (dsh-agent declares the 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.
agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete plugin and
is itself swappable — nothing outside it may depend on it.
## Consequences ## Consequences
- Every MVP feature maps to a listener (the "plugin sanity checklist" in - Every MVP feature maps to a listener (the "plugin sanity checklist" in docs/architecture.md is the proof obligation, kept current).
docs/architecture.md is the proof obligation, kept current).
- HMR and disposal come free: listeners and registrations are Cordis effects. - HMR and disposal come free: listeners and registrations are Cordis effects.
- Waterfall semantics (call `next()` or short-circuit) are non-obvious and - Waterfall semantics (call `next()` or short-circuit) are non-obvious and must be taught — documented in AGENTS.md and covered by composition tests.
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).
- The loop must be defensive: plugin exceptions are contained at turn level,
steering from any seam is never stranded (regression-tested).

View file

@ -4,35 +4,19 @@ Status: accepted (2026-06-11)
## Context ## Context
The MVP requires strict event-based tracing with fully replayable sessions 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.
(严格的基于事件的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 ## Decision
A `Session` is an append-only log of typed `SessionEvent`s — the single 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.
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` 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.
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 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).
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 ## Consequences
- Replay, trace, and telemetry are structurally guaranteed, not bolted on. - Replay, trace, and telemetry are structurally guaranteed, not bolted on.
- Persistence stays a plugin concern; the in-memory store ships in dsh-session. - Persistence stays a plugin concern; the in-memory store ships in dsh-session.
- The event vocabulary is merge-extensible (plugins add e.g. compaction - 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.
events); it carries a TODO(review) marker until the first persistence - Derivation cost grows with log length — compaction (future plugin) is the intended mitigation, not log mutation.
plugin and real adapter exercise it.
- Derivation cost grows with log length — compaction (future plugin) is the
intended mitigation, not log mutation.

View file

@ -4,34 +4,16 @@ Status: accepted (2026-06-11)
## Context ## Context
The harness needs one internal language for messages that the loop, session 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.
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 ## Decision
Own it: messages are arrays of typed content blocks (`text`, `reasoning`, 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.
`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 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.
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 ## Consequences
- Reasoning, prefill, cache hints, and multimodal content all have a home - Reasoning, prefill, cache hints, and multimodal content all have a home without provider contortions.
without provider contortions. - Every adapter pays a translation cost; the streaming protocol carries a TODO(review) marker until the first real adapter validates it.
- Every adapter pays a translation cost; the streaming protocol carries a - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost.
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.

View file

@ -4,32 +4,16 @@ Status: accepted (2026-06-11)
## Context ## Context
Tool parameters must reach the model as standard JSON Schema (the wire 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.
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 ## Decision
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with 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.
`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 / 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.
transformation against StandardSchema, not JSON Schema *generation*, so it
would add indirection without producing the wire format cleanly.
## Consequences ## Consequences
- First-party tool authors get zero-cast typed args; the type gymnastics cost - 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).
stays inside the core package (sanctioned by the AGENTS.md type-safety - 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.
policy). - The InferArgs mapping is regression-tested at the type level (expectTypeOf) after an early optionality bug shipped and was caught by review.
- 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.

View file

@ -4,28 +4,14 @@ Status: accepted (2026-06-11)
## Context ## Context
On the wire, tool schemas travel in a dedicated `tools` field of the model 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.
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 ## Decision
`PromptAssembly { sections, tools }`: the system-prompt service collects `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.
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 ## Consequences
- One waterfall governs the model's standing context; plugins like plan mode - One waterfall governs the model's standing context; plugins like plan mode can swap prompt text and visible tools in one listener.
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).
- The assembly interface is merge-extensible for future slots (no untyped - Slight conceptual surprise (schemas in a "prompt" service) is documented here and in the package README.
`extras` bag — extension is declaration merging).
- Slight conceptual surprise (schemas in a "prompt" service) is documented
here and in the package README.

View file

@ -4,33 +4,20 @@ Status: accepted (2026-06-11)
## Context ## Context
This codebase is developed primarily by coding agents. Agents follow enforced 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.
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 ## Decision
Every AGENTS.md promise gets a command that exits non-zero, wired into git Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts:
hooks and CI both calling the same package.json scripts:
- Max-strict TypeScript (`noUncheckedIndexedAccess`, - Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); tests and examples typecheck in CI via `tsconfig.typecheck.json` (vendored packages resolve as built declarations).
`exactOptionalPropertyTypes`, …); tests and examples typecheck in CI via - ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded.
`tsconfig.typecheck.json` (vendored packages resolve as built declarations). - Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
- ESLint strict-type-checked + @stylistic (the house style, enforced); - knip (dead code/deps), publint (package correctness), yarn constraints (workspace rules: private, cordis peer+dev, uniform version, ESM).
vendored code excluded. - 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.
- 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 ## Consequences
- Conventions survive agent turnover; violations fail fast and locally. - Conventions survive agent turnover; violations fail fast and locally.
- The gates themselves are code to maintain; config changes are reviewed like - The gates themselves are code to maintain; config changes are reviewed like any change.
any change. - 100%-coverage pressure can produce assertion-free tests — mutation testing is the planned counterweight (see RFC 002).
- 100%-coverage pressure can produce assertion-free tests — mutation testing
is the planned counterweight (see RFC 002).

View file

@ -4,49 +4,21 @@ Status: accepted (2026-06-11)
## Context ## Context
The initial build used **dumble**, the cordiverse zero-config esbuild wrapper 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.
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 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.
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 ## Decision
Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-backed, actively released):
VoidZero-backed, actively released):
- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']` - 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).
(explicit globs, not `workspace: true`, which would also pick up - 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).
`examples/*` — they have package.json files but are not yarn workspaces). - 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).
- 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`. - `scripts/build.ts` deleted; `yarn build` = `tsc -b && tsdown`.
Alternatives considered: **direct esbuild script** (most established engine, 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).
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 ## Consequences
Output file lists are byte-for-byte-list identical to dumble's (verified by 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.
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.

View file

@ -1,12 +1,8 @@
# Architecture Decision Records # Architecture Decision Records
Short, immutable records of the *why* behind decisions that shape this 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.
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 / 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.
Consequences. An ADR is never edited into a different decision — supersede it
with a new one and cross-link.
| # | Title | Status | | # | Title | Status |
|---|---|---| |---|---|---|

View file

@ -1,16 +1,10 @@
# DeepSeek Harness Architecture # DeepSeek Harness Architecture
This document describes the phase-1 architecture of the DeepSeek Harness — the 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:
foundation of **DeepSeek Code**. The governing principle, from the
[microkernel design discussion][microkernel-doc], is:
> **Microkernel approach. Everything is a plugin.** > **Microkernel approach. Everything is a plugin.**
The harness core is deliberately tiny: a handful of abstract services plus one 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.
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]. 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 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.
`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 ## 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.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 | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go 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.
through `ctx.effect()` and return disposers, so plugin hot-reload (vendored
HMR) and fiber disposal clean up automatically.
## Capability seams: interface / implementation / consumer ## Capability seams: interface / implementation / consumer
Swappable capabilities are split into **three packages** so each part evolves Swappable capabilities are split into **three packages** so each part evolves independently. The bash capability is the template:
independently. The bash capability is the template:
1. **Interface** (`dsh-bash`) — an abstract service plus the vocabulary 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.
(`BashExecutor`, `BashRunResult`, `BashTask`, …). Defines the contract, 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.
owns the `ctx.bash` key, depends only on cordis. 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.
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 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.
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 > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above
> ("one plugin provides a capability, another needs it") is realized by > ("one plugin provides a capability, another needs it") is realized by
@ -98,103 +73,55 @@ otherwise.
## The vocabulary (dsh-llm) ## The vocabulary (dsh-llm)
Messages are arrays of typed **content blocks** (`text`, `reasoning`, 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.
`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`, 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.
`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 `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'}`).
`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) ## Event-sourced sessions (dsh-session)
A `Session` is an append-only log of typed `SessionEvent`s — the single source 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()`):
of truth. The LLM message history is *derived* from the log
(`deriveMessages()`):
- `user/message` → user message - `user/message` → user message
- `assistant/message` → assistant message (raw `assistant/chunk` events are - `assistant/message` → assistant message (raw `assistant/chunk` events are replay/UI data and are skipped in derivation)
replay/UI data and are skipped in derivation)
- `tool/result` → user message carrying a `tool-result` block - `tool/result` → user message carrying a `tool-result` block
- `context/message`, `steering/message` → user-role messages wrapped in a - `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.
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 Replay/fork = `ctx.sessions.create(id, seedEvents)`. Trace/telemetry = listen to `session/event`.
to `session/event`.
**Durability seam**: `session/event` is a synchronous notification; **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.
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) ## Prompt assembly (dsh-system-prompt)
Plugins contribute `PromptSection`s (named, ordered, static or computed) and 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-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 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.
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) ## Tool pipeline (dsh-tools)
`ToolRegistry.register()` takes schema + `execute()`. The registry feeds its `ToolRegistry.register()` takes schema + `execute()`. The registry feeds its schemas into the system-prompt assembly automatically.
schemas into the system-prompt assembly automatically.
`execute()` runs through the **`tools/execute` waterfall** — the single seam `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.
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 **TODO**: tool shapes get revisited when real tools land (e.g. a concurrency-safety hint for parallel execution; phase 1 executes tool calls sequentially).
concurrency-safety hint for parallel execution; phase 1 executes tool calls
sequentially).
## Agents (dsh-agent) and the loop (dsh-agent-loop) ## Agents (dsh-agent) and the loop (dsh-agent-loop)
`Agent` is the handle every plugin programs against: `Agent` is the handle every plugin programs against:
- `send(content)` — queued message; starts a turn when idle, else next turn - `send(content)` — queued message; starts a turn when idle, else next turn
- `steer(content)` — mid-turn injection, drained **between steps**; behaves - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle
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)
- `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` - `abort(reason)` — aborts the in-flight step via `AbortSignal`
- `session`, `status`, `options` - `session`, `status`, `options`
**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds **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.
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) ### Loop lifecycle (session / turn / step)
- **Session**: the whole event log of one agent. - **Session**: the whole event log of one agent.
- **Turn**: triggered by ≥1 queued message; runs steps until the model stops - **Turn**: triggered by ≥1 queued message; runs steps until the model stops requesting tools and no plugin requests continuation.
requesting tools and no plugin requests continuation.
- **Step**: one model request + its tool executions. - **Step**: one model request + its tool executions.
``` ```
@ -231,14 +158,7 @@ forever:
emit agent/status(idle) unless more queued emit agent/status(idle) unless more queued
``` ```
Error containment: a throwing `agent/turn-continuation` listener or a 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')`.
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 ### Event taxonomy
@ -262,24 +182,17 @@ Declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package).
### Cordis waterfall semantics (important) ### Cordis waterfall semantics (important)
`ctx.waterfall` is **around-middleware**, not a value reducer. Each listener `ctx.waterfall` is **around-middleware**, not a value reducer. Each listener receives `(...args, next)`:
receives `(...args, next)`:
- call `next()` to delegate to later listeners (and ultimately the core - call `next()` to delegate to later listeners (and ultimately the core behavior), possibly wrapping it;
behavior), possibly wrapping it;
- return a value **without** calling `next()` to short-circuit (veto); - return a value **without** calling `next()` to short-circuit (veto);
- listeners run in registration order; `prepend: true` jumps the queue. - listeners run in registration order; `prepend: true` jumps the queue.
Composition caveat: values propagate through `next()`'s **return value**. 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.
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 ## Plugin sanity checklist
Every MVP feature (including the TODO-marked ones), with the mechanism that Every MVP feature (including the TODO-marked ones), with the mechanism that implements it **without modifying the loop**:
implements it **without modifying the loop**:
| MVP feature | Plugin mechanism | | MVP feature | Plugin mechanism |
|---|---| |---|---|
@ -334,9 +247,7 @@ export function apply(ctx: Context) {
} }
``` ```
(Raw JSON-Schema `ToolDefinition`s are still accepted by (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.)
`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) ### 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) 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.
(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, Step-by-step guides live in [`docs/cookbook`](./cookbook): adding a package, adding a tool, adding an LLM adapter.
adding a tool, adding an LLM adapter.
## Deferred work (TODO) ## Deferred work (TODO)
Tracked here deliberately — each is designed-for but not implemented: Tracked here deliberately — each is designed-for but not implemented:
- **Restructure this document** — it has grown long; split it into focused - **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.
sections (or per-area files) so readers can navigate it without scrolling - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events.
the whole thing. - **Persistence backends** (JSONL session dirs, sqlite) on the `session/event` + `session/flush` seam.
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent - **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging.
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). - **Parallel tool execution** (concurrency-safety hints on ToolDefinition).
- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based - **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking.
forking. - **Session event vocabulary review** once the loop and a persistence plugin coexist (`TODO(review)` in dsh-session).
- **Session event vocabulary review** once the loop and a persistence plugin
coexist (`TODO(review)` in dsh-session).

View file

@ -1,7 +1,6 @@
# Cookbook: adding a workspace package # Cookbook: adding a workspace package
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` 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.)
(Verified by the bash and adapter packages; if it drifts, fix it here.)
## 1. Create the package ## 1. Create the package
@ -16,11 +15,7 @@ packages/<name>/
README.md # service API, events, extension points, design notes README.md # service API, events, extension points, design notes
``` ```
package.json invariants (enforced by `yarn constraints` / yarn.config.cjs): 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.
`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 ## 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 | | `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`) | | `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` Covered automatically by globs — no edits needed: root `package.json` workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`.
workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`.
## 3. Decide the package topology ## 3. Decide the package topology
For a swappable capability, split interface / implementation / consumer into 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.
separate packages (see docs/architecture.md § "Capability seams" — the bash
trio is the template). A single-purpose plugin stays one package.
## 4. Verify ## 4. Verify
@ -50,6 +42,4 @@ yarn test:coverage # 100% per-file over src (types.ts exempt)
yarn build && yarn knip && yarn publint yarn build && yarn knip && yarn publint
``` ```
Test expectations: every registry/registration needs an HMR-safety test 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.
(register from a child fiber, dispose it, assert cleanup). Excessive tests
are welcome — see AGENTS.md.

View file

@ -1,8 +1,6 @@
# Cookbook: adding a tool # Cookbook: adding a tool
How to give the model a new capability. Reference implementations: 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).
`examples/echo-agent/src/echo-tool.ts` (minimal) and
`packages/tool-bash` (production-grade, three-package seam).
## The minimal shape ## The minimal shape
@ -30,33 +28,18 @@ export function apply(ctx: Context) {
} }
``` ```
Registration is effect-based: disposing the plugin fiber unregisters the Registration is effect-based: disposing the plugin fiber unregisters the tool (write the HMR test). Schemas flow into the system-prompt assembly automatically.
tool (write the HMR test). Schemas flow into the system-prompt assembly
automatically.
## Rules of the execute() contract ## Rules of the execute() contract
- **Validate args at runtime.** `defineTool`'s `InferArgs` typing is - **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.
compile-time only; at runtime `arguments` is whatever JSON the model - **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).
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. - **Honor `exec.signal`.** Cancel in-flight work when it fires.
- **Use `exec.agent` for async notifications.** `agent.inject(content, - **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).
{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 ## Long-running work
Follow tool-bash's background pattern: a `run_in_background` flag returns a 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.
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 > 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, > 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 ## Permissions / sandboxing
Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall 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.
(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 ## Tests every tool needs
Arg-validation rejections, result shaping for every outcome, the HMR 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.
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.

View file

@ -1,10 +1,6 @@
# Cookbook: adding an LLM adapter # Cookbook: adding an LLM adapter
How to connect a new model provider. Reference implementations: 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.
`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 ## The shape
@ -22,54 +18,26 @@ export function apply(ctx: Context, config: Config) {
} }
``` ```
Registration is effect-based (HMR-safe); one adapter per model name — 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.
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) ## Protocol obligations (the contract two implementations verified)
- Emit `usage` BEFORE `finish`; emit NOTHING after `finish`. The robust way: - 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).
buffer finish/usage until the provider's end-of-stream marker, then flush - 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`.
(handles providers that send trailing usage-only chunks). - Allocate block `index`es in first-seen stream order; reuse the index for every delta of the same block.
- Tool-call `arguments` are RAW JSON strings end-to-end; stream fragments as - 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.
`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). - Honor `options.signal` (pass it to fetch / your SDK).
- `prefill` and other unsupported `GenerateOptions` fields: throw - `prefill` and other unsupported `GenerateOptions` fields: throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping.
`LlmError(..., 'UNSUPPORTED')` rather than silently dropping.
Provider-specific request knobs (thinking modes, effort levels) belong in Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral.
the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays
provider-neutral.
## Structure that worked ## Structure that worked
Split the adapter into testable stages (llm-deepseek's layout): wire types 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.
(`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 ## Testing
- **Unit: mock the provider, not the harness.** A scripted `node:http` - **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).
server speaking the provider's wire format covers happy paths, every error - **Hostile framing tests.** Split stream payloads at arbitrary byte positions (including mid-UTF-8) — real networks do.
status, malformed payloads, premature closes, and aborts — no network, and - **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).
it drives the 100% per-file coverage gate. Works for SDK-backed adapters - Register the e2e file pattern in `knip.json` (per-workspace `entry` override) or knip flags it unused.
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.

View file

@ -4,44 +4,21 @@ Status: proposed
## Problem ## Problem
Example-based tests pin the cases we thought of. The harness's core is 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).
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 ## Proposal
Adopt fast-check (vitest integration) with generators for our vocabulary: Adopt fast-check (vitest integration) with generators for our vocabulary:
- **BlockAssembler**: arbitrary chunk sequences (valid and malformed — - **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.
duplicate indices, stragglers after block-end, missing block-start). - **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.
Invariants: `flushReady() + flushRemaining() ≡ blocks()` in order; - **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).
`streamBlocks ≡ generate().message.content`; memory bounded (partials map - **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.
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 ## Plan
One `tests/properties.spec.ts` per package; fast-check as devDependency; 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.
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 ## Risks
Generator quality determines value — invest in generators that produce 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.
*realistic-but-adversarial* streams, not uniform noise. Property flake from
timeouts must be treated as a finding, not retried away.

View file

@ -4,35 +4,23 @@ Status: proposed
## Problem ## Problem
The per-file 100% coverage gate (ADR 0007) proves every line *executes* under 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.
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 ## Proposal
Stryker (`@stryker-mutator/vitest-runner`) over `packages/*/src`: Stryker (`@stryker-mutator/vitest-runner`) over `packages/*/src`:
- **PR-scoped incremental runs** (changed files only) as a CI job — fast - **PR-scoped incremental runs** (changed files only) as a CI job — fast enough to gate merges once tuned.
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).
- **Nightly full runs** with a tracked mutation score; start by recording, - Surviving mutants are work items: an agent picks a survivor, writes the killing test, repeats — a well-shaped autonomous loop.
then set the threshold at the observed baseline and ratchet upward (same - Equivalent mutants (provably behavior-preserving) get annotated exclusions with reasons, mirroring the `/* v8 ignore */` policy.
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 ## Plan
1. Add Stryker config scoped to one package (llm — smallest, most algorithmic) 1. Add Stryker config scoped to one package (llm — smallest, most algorithmic) and measure runtime.
and measure runtime.
2. Expand to all packages; record baseline scores in the config. 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. 3. Wire the nightly job; add the incremental PR job once runtime is acceptable.
## Risks ## Risks
Runtime: mutation testing is expensive; per-file 100% coverage helps (every 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.
mutant is at least reached). If PR-scoped runs stay too slow, keep them
nightly-only and rely on the score ratchet.

View file

@ -4,37 +4,20 @@ Status: proposed
## Problem ## Problem
Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt 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.
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 ## Proposal
Three measures: Three measures:
1. **No wall-clock sleeps in tests.** Replace `setTimeout(N)` waits with 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.
event-driven waits (the existing `waitForIdle` pattern, extended to 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.
`waitForStatus`, `waitForEvent(n)`) or vitest fake timers where time 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.
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 ## Plan
Land 1 and 2 together (they touch the same helpers); add the nightly job Land 1 and 2 together (they touch the same helpers); add the nightly job after the suite is sleep-free so repeats are fast.
after the suite is sleep-free so repeats are fast.
## Risks ## Risks
Fake timers interact subtly with Promise scheduling in the loop — prefer Fake timers interact subtly with Promise scheduling in the loop — prefer event-driven waits; reserve fake timers for timer-service behavior itself.
event-driven waits; reserve fake timers for timer-service behavior itself.

View file

@ -4,40 +4,24 @@ Status: proposed
## Problem ## Problem
Two architectural guarantees currently live only in prose: (1) nothing 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).
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 ## Proposal
**dependency-cruiser** with rules: **dependency-cruiser** with rules:
- `packages/*` (except agent-loop's own tests and examples/) must not import - `packages/*` (except agent-loop's own tests and examples/) must not import `@deepseek-ai/dsh-agent-loop`.
`@deepseek-ai/dsh-agent-loop`. - No cross-package deep imports (`@deepseek-ai/dsh-*/src/...` paths) — public entry points only.
- No cross-package deep imports (`@deepseek-ai/dsh-*/src/...` paths) — public
entry points only.
- No import cycles anywhere in packages/. - No import cycles anywhere in packages/.
- `vendor/*` must not import from `packages/*`. - `vendor/*` must not import from `packages/*`.
- Layering: dsh-llm imports nothing from other dsh packages; dsh-session only - Layering: dsh-llm imports nothing from other dsh packages; dsh-session only dsh-llm; etc. (the dependency table in packages/README.md, enforced).
dsh-llm; etc. (the dependency table in packages/README.md, enforced).
**Adapter conformance kit** in dsh-llm (`@deepseek-ai/dsh-llm/conformance`): **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).
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 ## Plan
dependency-cruiser config + CI step first (an hour of work, permanent 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.
guarantee); the conformance kit lands with its first consumer test against
MockAdapter, and is a prerequisite for the V4 adapter phase.
## Risks ## Risks
Dep-cruiser rule maintenance as packages are added — keep rules pattern-based Dep-cruiser rule maintenance as packages are added — keep rules pattern-based (`dsh-*`) rather than enumerated.
(`dsh-*`) rather than enumerated.

View file

@ -6,44 +6,20 @@ Status: proposed
Three gaps where compile-time guarantees stop: Three gaps where compile-time guarantees stop:
1. Tool args are model-generated JSON — `defineTool`'s `InferArgs<S>` claim 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.
is only as true as the model's output. Today a malformed call reaches 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.
`execute` untyped-in-practice. 3. Loop ordering invariants (seq monotonicity, step/turn event nesting, turn-number continuity) are asserted only where tests look.
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 ## Proposal
1. **Schema validation in defineTool**: before `execute`, validate parsed 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.
args against the SchemaSpec (the converter already encodes the structure — 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.
a small interpreter walks it: presence of required keys, primitive type 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.
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 ## Plan
2 first (taxonomy is a dependency of 1's error shape), then 1, then 3. 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.
Property tests (RFC 001) then close the loop: generated args ↔ validator ↔
InferArgs agreement.
## Risks ## Risks
Validator/InferArgs drift — covered by the RFC 001 composition property. Validator/InferArgs drift — covered by the RFC 001 composition property. Validation cost per call is negligible next to a model call.
Validation cost per call is negligible next to a model call.

View file

@ -4,36 +4,18 @@ Status: proposed
## Problem ## Problem
AGENTS.md policy says docs and code must stay strictly in sync, but sync is 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.
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 ## Proposal
1. **Typecheck documentation code blocks.** A script extracts fenced ```ts 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.)
blocks from README.md / docs/architecture.md / packages/*/README.md into a 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.
temp project resolving workspace packages, and runs tsc. Blocks that are 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.
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 ## Plan
1 is a standalone script + CI step. 3 next (it also documents the surface for 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.
plugin authors). 2 last — verify-don't-generate is likely sufficient.
## Risks ## Risks
Doc blocks often show fragments; the ignore-check escape hatch must stay rare Doc blocks often show fragments; the ignore-check escape hatch must stay rare or the gate is theater — lint the ratio if needed.
or the gate is theater — lint the ratio if needed.

View file

@ -4,38 +4,19 @@ Status: proposed
## Problem ## Problem
The vendor manifest (ADR 0001) is enforced at commit time in the *forward* 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.
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 ## Proposal
1. **Vendor drift check** (nightly CI): clone the upstream repos at the 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).
manifest SHAs (shallow), copy the corresponding package sources, and diff 2. **Dependency advisories**: osv-scanner (or `yarn npm audit`) job on the lockfile, scheduled + on lockfile-touching PRs.
against `vendor/*/src`. The job fails unless the diff matches the logged 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.
local modifications (kept as a checked-in patch file per modification — 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).
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 ## Plan
3 is trivial — do first. 1 requires network access from CI to the upstream 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.
repos (private — needs a token) and converting the two existing logged
modifications into patch files. 2 and 4 are config.
## Risks ## Risks
Upstream repos are private mirrors; CI credentials and availability are the 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.
main friction for the drift check. If blocked, run it as a local scheduled
agent task instead of CI.

View file

@ -4,41 +4,21 @@ Status: proposed
## Problem ## Problem
The session log is append-only by contract, but `session.events` returns 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.
`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 ## Proposal
Make immutability part of the type where mutation is corruption: Make immutability part of the type where mutation is corruption:
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session - `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.
(`events`, `session/event` listeners); `append()` keeps taking plain - `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).
mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to - `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true).
the brand/never helpers. - 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.
- `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 ## Plan
Introduce `DeepReadonly`, flip the session read paths, fix resulting 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.
compile errors in consumers (expected: a handful in tests), add the
freeze-in-dev option alongside RFC 005's invariants plugin.
## Risks ## Risks
`DeepReadonly` types can produce noisy errors at waterfall boundaries where `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.
mutation IS the API — keep the mutable/readonly boundary exactly at "logged
vs in-flight" and document it in the session README.

View file

@ -1,9 +1,6 @@
# RFCs # RFCs
Proposals for substantial future work — reviewed before implementation, 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).
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 | | # | Title | Status |
|---|---|---| |---|---|---|

View file

@ -1,8 +1,7 @@
# coding-agent # coding-agent
The first REAL agent wiring: DeepSeek V4 + the bash tool suite + stdio chat The first REAL agent wiring: DeepSeek V4 + the bash tool suite + stdio chat
+ JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the + JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant.
skeleton with mocks, this example is a usable coding assistant.
## Run it ## Run it
@ -13,11 +12,7 @@ skeleton with mocks, this example is a usable coding assistant.
yarn demo:coding yarn demo:coding
``` ```
Type a coding task. The agent's only tools are `bash` (+ `bash_output` / Type a coding task. The agent's only tools are `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Reasoning streams dimmed; tool calls/results render inline.
`bash_kill` for background tasks): file reads, writes, searches, and test
runs all happen through shell commands, each in a fresh `bash -c` (the
system prompt tells the model to pass `workdir` instead of `cd`). Reasoning
streams dimmed; tool calls/results render inline.
``` ```
> fix the failing test in /path/to/project > fix the failing test in /path/to/project
@ -39,13 +34,7 @@ streams dimmed; tool calls/results render inline.
## End-to-end tests (`yarn test:e2e`, key-gated) ## End-to-end tests (`yarn test:e2e`, key-gated)
- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` - `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer.
through the real bash tool; asserts `tool/call`/`tool/result` session - `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted.
events and the final answer.
- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds
`add.js` (with `a - b` where `a + b` belongs) and a failing
`add.test.js`; the agent must fix the bug and verify. The test re-runs
`node add.test.js` ITSELF and inspects the files — agent claims are not
trusted.
Both self-skip without `DEEPSEEK_API_KEY`. Both self-skip without `DEEPSEEK_API_KEY`.

View file

@ -5,14 +5,10 @@ Runnable demo: stdin chat with a scripted mock model and an echo tool.
## What it shows ## What it shows
- A complete Cordis app loaded from `cordis.yml` — the standard "stack of plugins" pattern - A complete Cordis app loaded from `cordis.yml` — the standard "stack of plugins" pattern
- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the - `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo <something>"
`echo` tool when the user types "echo <something>" - `echo-tool.ts` — a tool registered via `ctx.tools.register()` that echoes text back uppercased
- `echo-tool.ts` — a tool registered via `ctx.tools.register()` that echoes text - `session-jsonl.ts` — a minimal persistence plugin: write-behind buffering of `session/event` notifications, drained to a JSONL file at `session/flush`
back uppercased - `stdio-chat.ts` — a minimal UI plugin: reads stdin lines and `send`/`steer`s the agent, renders stream deltas, tool calls, and tool results
- `session-jsonl.ts` — a minimal persistence plugin: write-behind buffering of
`session/event` notifications, drained to a JSONL file at `session/flush`
- `stdio-chat.ts` — a minimal UI plugin: reads stdin lines and `send`/`steer`s
the agent, renders stream deltas, tool calls, and tool results
## Plugin files ## Plugin files
@ -32,9 +28,6 @@ yarn demo
node --expose-internals --import tsx examples/echo-agent/start.ts node --expose-internals --import tsx examples/echo-agent/start.ts
``` ```
Type a message and press Enter. "echo <text>" triggers a tool call round-trip Type a message and press Enter. "echo <text>" triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it).
(the mock model requests the `echo` tool, which echoes the text uppercased,
and the next model step acknowledges it).
The session is persisted to `<session-id>.jsonl` in the `examples/echo-agent/` The session is persisted to `<session-id>.jsonl` in the `examples/echo-agent/` directory. Clean up with: `rm -f examples/echo-agent/*.jsonl`
directory. Clean up with: `rm -f examples/echo-agent/*.jsonl`

View file

@ -1,30 +1,16 @@
# AGENTS.md — Harness Packages # AGENTS.md — Harness Packages
This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing code here, follow these conventions:
code here, follow these conventions:
- **Effect-based registrations**: every contribution (tool, section, adapter, - **Effect-based registrations**: every contribution (tool, section, adapter, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and `register()` methods return disposers. Never use bare arrays or manual cleanup.
agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and - **Declaration merging**: services declare their ctx key in `declare module 'cordis' { interface Context { } }` and their events in `interface Events`. Merge-extensible maps (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap`) are how plugins add new variants.
`register()` methods return disposers. Never use bare arrays or manual cleanup. - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning.
- **Declaration merging**: services declare their ctx key in - **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races.
`declare module 'cordis' { interface Context { } }` and their events in
`interface Events`. Merge-extensible maps (`ContentBlockMap`,
`MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`,
`SessionEventMap`) are how plugins add new variants.
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`;
call `next()` to delegate, or return without it to short-circuit (veto). Never
call `next()` after returning.
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an
HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on
the side of more tests — edge cases, error paths, event ordering, races.
Naming notes: Naming notes:
- Files `src/index.ts` export the service default + all public types - Files `src/index.ts` export the service default + all public types
- `src/types.ts` contain only types — no runtime code - `src/types.ts` contain only types — no runtime code
- Tests live at package level under `tests/`, not `src/__tests__/` - Tests live at package level under `tests/`, not `src/__tests__/`
- A package's README and module/JSDoc comments are part of the change: when you - A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI has no doc-sync gate, so stale docs are on the author.
alter behavior (config keys, defaults, error codes, wire fields), update them
in the same commit. CI has no doc-sync gate, so stale docs are on the author.
Read the per-package README.md for package-specific details: service API, Read the per-package README.md for package-specific details: service API, events, extension points, TODOs.
events, extension points, TODOs.

View file

@ -1,10 +1,6 @@
# Packages # Packages
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis service (microkernel plugin-style): it exports a default `Service` class that gets registered via `ctx.plugin()`, declares its ctx key and events through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`.
Cordis service (microkernel plugin-style): it exports a default `Service` class
that gets registered via `ctx.plugin()`, declares its ctx key and events through
declaration merging, and exposes extension points through `ctx.effect()`,
`ctx.on()`, and `ctx.waterfall()`.
## Dependency graph ## Dependency graph
@ -17,9 +13,7 @@ dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-agent-loop ← dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent dsh-agent-loop ← dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent
``` ```
The rule: plugins depend on interfaces, never on the concrete loop. The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced.
`dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the
`dsh-agent` vocabulary if the loop is replaced.
## What goes where ## What goes where
@ -32,25 +26,13 @@ The rule: plugins depend on interfaces, never on the concrete loop.
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | THE concrete plugin: `LoopAgent` + the loop driver | `ctx.agentLoop` | | `agent-loop/` | THE concrete plugin: `LoopAgent` + the loop driver | `ctx.agentLoop` |
Each package has its own `README.md` with purpose, service API, events, Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
extension points, and deliberate non-goals (TODOs).
## Conventions (applied across all harness packages) ## Conventions (applied across all harness packages)
- **Registrations are effects**: every contribution (adapter, tool, section, - **Registrations are effects**: every contribution (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, so disposal and HMR clean up automatically. Every `register()` returns the disposer.
agent, event listener) goes through `ctx.effect()` / `ctx.on()`, so disposal - **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`.
and HMR clean up automatically. Every `register()` returns the disposer. - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism).
- **Declaration merging for events and ctx**: services declare their events in - **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging.
`declare module 'cordis' { interface Events { ... } }` and their ctx key in - **ESM everywhere**; imports use package names across package boundaries, `.ts` extensions within a package.
`interface Context`. - **Tests**: vitest, colocated under `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests.
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`
and MUST call `next()` to delegate; returning without it short-circuits (the
veto mechanism).
- **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`,
`FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap`
use the merge-extensible-map pattern so plugins can add variants via
declaration merging.
- **ESM everywhere**; imports use package names across package boundaries,
`.ts` extensions within a package.
- **Tests**: vitest, colocated under `packages/<name>/tests/*.spec.ts`. Every
registry needs an HMR-safety test. Err on the side of more tests.

View file

@ -1,24 +1,18 @@
# dsh-agent-loop # dsh-agent-loop
THE concrete agent plugin: `LoopAgent` and the loop driver. Implements the THE concrete agent plugin: `LoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
`Agent` interface and drives the session/turn/step lifecycle.
This is the only package in the harness that contains concrete loop logic. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
Everything else is an abstract service or a plugin against extension seams —
new behavior goes into plugins, not here.
## Service: `AgentLoop` (ctx key: `agentLoop`) ## Service: `AgentLoop` (ctx key: `agentLoop`)
### Public API ### Public API
- `ctx.agentLoop.create(id: string, options?: AgentOptions): LoopAgent` - `ctx.agentLoop.create(id: string, options?: AgentOptions): LoopAgent` Create an agent, start its loop, and register it in `ctx.agents`. Disposed with the calling fiber.
Create an agent, start its loop, and register it in `ctx.agents`. Disposed
with the calling fiber.
### Injected services ### Injected services
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface `agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services.
services.
### Configuration (schemastery) ### Configuration (schemastery)
@ -36,11 +30,8 @@ Agents listed in config are auto-created at startup.
### Classes ### Classes
- `LoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), - `LoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
the per-step `AbortController`, and the loop driver. Everything observable - `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`).
happens through session events and the `agent/*` event taxonomy.
- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`,
`drainSteering`, `waitForQueued`).
### Loop lifecycle (`loop.ts`) ### Loop lifecycle (`loop.ts`)
@ -68,13 +59,11 @@ forever:
idle unless more queued idle unless more queued
``` ```
Error containment: a throwing plugin ends the **turn**, never the loop. Dispose Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`.
mid-turn emits `agent/status('disposed')` and ends with reason `disposed`.
### What is NOT here ### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
plugins listening on the event taxonomy:
- Hooks: `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation` - Hooks: `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
- Compaction: `agent/request` - Compaction: `agent/request`
- Sandbox, permission, plan mode: `tools/execute` - Sandbox, permission, plan mode: `tools/execute`

View file

@ -1,25 +1,20 @@
# dsh-agent # dsh-agent
Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI, Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
hooks, orchestrators) programs against the `Agent` handle defined here — it has
zero loop dependency, so the loop is swappable.
## Service: `AgentRegistry` (ctx key: `agents`) ## Service: `AgentRegistry` (ctx key: `agents`)
Tracks live agents so UI, hook, and orchestrator plugins can find them without Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package.
importing the concrete loop package.
### Public API ### Public API
- `ctx.agents.register(agent: Agent): () => void` - `ctx.agents.register(agent: Agent): () => void` Register a live agent. Disposed with the calling fiber.
Register a live agent. Disposed with the calling fiber.
- `ctx.agents.get(id: string): Agent | undefined` - `ctx.agents.get(id: string): Agent | undefined`
- `ctx.agents.list(): Agent[]` - `ctx.agents.list(): Agent[]`
### Events ### Events
The full `agent/*` event taxonomy is declared via declaration merging in The full `agent/*` event taxonomy is declared via declaration merging in `dsh-agent` (not `dsh-agent-loop`), so plugins depend only on this package.
`dsh-agent` (not `dsh-agent-loop`), so plugins depend only on this package.
#### Lifecycle (emit) #### Lifecycle (emit)
@ -34,12 +29,9 @@ The full `agent/*` event taxonomy is declared via declaration merging in
#### Interception seams (waterfall) #### Interception seams (waterfall)
- `agent/request` — mutate `GenerateOptions` before the model call (hooks, - `agent/request` — mutate `GenerateOptions` before the model call (hooks, compaction, model switching, tool filtering)
compaction, model switching, tool filtering) - `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/step-result` — post-process the assembled assistant message before tool - `agent/turn-continuation` — override the continue/stop decision (force-continue /loop, force-stop budget guard)
dispatch (validates what the log records)
- `agent/turn-continuation` — override the continue/stop decision
(force-continue /loop, force-stop budget guard)
#### Streaming + tool (emit) #### Streaming + tool (emit)
@ -52,20 +44,15 @@ The full `agent/*` event taxonomy is declared via declaration merging in
The handle every plugin programs against: The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle - `agent.send(content, options?)` — queue a message; starts a turn when idle
- `agent.steer(content, options?)` — steer a running turn (inject between steps); - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle
behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context without triggering a turn (context/message event); next request sees it
- `agent.inject(content, options?)` — inject in-session context without triggering
a turn (context/message event); next request sees it
- `agent.abort(reason?)` — abort the in-flight step - `agent.abort(reason?)` — abort the in-flight step
- `agent.session`, `agent.status`, `agent.options`, `agent.id` - `agent.session`, `agent.status`, `agent.options`, `agent.id`
### Extension points ### Extension points
- Agent creation: `AgentLoop.create()` is the concrete implementation (in - Agent creation: `AgentLoop.create()` is the concrete implementation (in `dsh-agent-loop`). Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`.
`dsh-agent-loop`). Replace the loop by implementing `Agent` and registering - Event listeners: all `agent/*` events are declared here — no dependency on the loop package needed.
via `ctx.agents.register()`.
- Event listeners: all `agent/*` events are declared here — no dependency on the
loop package needed.
### What is NOT here (TODO) ### What is NOT here (TODO)

View file

@ -1,9 +1,6 @@
# @deepseek-ai/dsh-bash-local # @deepseek-ai/dsh-bash-local
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
`LocalBashExecutor` spawns `bash -c <command>` per call in its own process
group, collects bounded output with full-stream spill files, and escalates
kills SIGTERM→SIGKILL across the whole group.
## Config ## Config
@ -19,33 +16,14 @@ kills SIGTERM→SIGKILL across the whole group.
## Behavior (and where it came from) ## Behavior (and where it came from)
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
pi; the notable choices:
- **Spawn per call, no shell state** — every call is a fresh non-login - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `TODO(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
`bash -c` (deterministic; no rc files). All four surveyed tools spawn per - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
call. `TODO(stateful-shell)` in `src/run.ts` records the two proven - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported. The model can `grep`/`tail` the spill file with bash itself.
stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec - **Model-friendly env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results.
sessions) for when real workflows demand them. - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything.
- **Process-group kills with escalation** — children are spawned `detached`
(own process group); kills send SIGTERM to the group, then SIGKILL after a
3s grace (OpenCode's escalation; pipelines and subshells die with the
parent). ESRCH is tolerated; daemons that re-parent away from the group can
still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes`
keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode
rationale) while the FULL stream is appended to a temp file whose path is
reported. The model can `grep`/`tail` the spill file with bash itself.
- **Model-friendly env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`
(Codex's hardcoded set) so pagers and ANSI color don't garble results.
- **Background tasks** — `start()` returns immediately, no timeout applies
(Claude Code detaches timeouts when backgrounding), `readOutput()` is
incremental with whole-stream byte offsets, and disposal kills everything.
## Sandboxing ## Sandboxing
`TODO(permissions/sandbox)`: execution policy does NOT belong in this `TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Wrap the `tools/execute` waterfall (veto/ask) or implement a sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
package. Wrap the `tools/execute` waterfall (veto/ask) or implement a
sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist.
Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap;
Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.

View file

@ -1,11 +1,8 @@
# @deepseek-ai/dsh-bash # @deepseek-ai/dsh-bash
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
defining WHAT a bash backend does — run commands, manage background tasks —
without saying HOW.
This package is one third of the bash capability, split so each concern can This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
evolve (and be swapped) independently:
| Package | Role | | Package | Role |
|---|---| |---|---|
@ -13,11 +10,7 @@ evolve (and be swapped) independently:
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses | | `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` | | `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
survey: pi hides execution behind a `BashOperations` interface (local shell /
SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed,
containerized, or remote executor implements this interface and the tool
schemas don't change.
## Service API (`ctx.bash`) ## Service API (`ctx.bash`)
@ -30,13 +23,8 @@ schemas don't change.
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. | | `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. | | `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. |
Implementations subclass `BashExecutor`, implement the abstract methods, and Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests.
call `notifyTaskDone(task)` on background completion. Disposal must kill every
running task (no orphan processes) — see the HMR-safety tests.
## Vocabulary ## Vocabulary
`BashExecSpec` (command, workdir?, timeoutMs?, signal?) → `BashExecSpec` (command, workdir?, timeoutMs?, signal?) → `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
`BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr
as `CollectedOutput`) and `BashTask`/`BashTaskRead` for the background side.
See `src/types.ts` for the full contracts.

View file

@ -1,13 +1,8 @@
# @deepseek-ai/dsh-llm-deepseek # @deepseek-ai/dsh-llm-deepseek
DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol.
`fetch` + SSE translation from the official wire format (source of truth:
the API docs — guides/thinking_mode, guides/tool_calls,
api/create-chat-completion) into the `StreamChunk` protocol.
A second, independent implementation of the same seam exists in A second, independent implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai` (library-backed). Same Config shape — pick one per context (registering both for the same model names throws by design).
`@deepseek-ai/dsh-llm-pi-ai` (library-backed). Same Config shape — pick one
per context (registering both for the same model names throws by design).
## Config ## Config
@ -22,62 +17,30 @@ per context (registering both for the same model names throws by design).
reasoningEffort: high # optional; high | max — omitted ⇒ not sent reasoningEffort: high # optional; high | max — omitted ⇒ not sent
``` ```
`models` lists every model name this one adapter instance serves: the adapter `models` lists every model name this one adapter instance serves: the adapter registers itself for each (the harness model name IS the wire `model` string), so a `generate`/`stream` call routes to it whenever `options.model` is any of them. Registering a second adapter for a name already taken throws `LlmError('DUPLICATE_ADAPTER')` (the LLM service enforces one adapter per model, all-or-nothing).
registers itself for each (the harness model name IS the wire `model` string),
so a `generate`/`stream` call routes to it whenever `options.model` is any of
them. Registering a second adapter for a name already taken throws
`LlmError('DUPLICATE_ADAPTER')` (the LLM service enforces one adapter per
model, all-or-nothing).
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` `reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
wire field is not sent and the server applies its own default for the model.
The only accepted values are `high` and `max` (DeepSeek's official effort
levels). It is meaningful only with thinking enabled (the provider default).
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as `thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
the official top-level `thinking: {type}` / `reasoning_effort` wire fields.
They live in adapter config (not `GenerateOptions`) to keep the core
vocabulary provider-neutral.
## Wire-format notes (verified live + against the official docs) ## Wire-format notes (verified live + against the official docs)
- Streaming only (`stream_options.include_usage` always on). `usage` may - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
arrive attached to the finish chunk or as a trailing usage-only chunk — - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
the translator defers both to `[DONE]`, so `usage` always precedes - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
`finish` and nothing follows `finish`. - `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly).
- The first thinking-mode chunk carries `reasoning_content: ""` — handled - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
(no spurious reasoning block).
- **Reasoning passback rule**: on assistant turns that carried tool calls,
`reasoning_content` is serialized back in history (required by the API in
thinking mode); on tool-call-free turns it is dropped (ignored anyway —
saves tokens).
- `strict` on tool schemas passes through (officially Beta; the public API
wants the `/beta` base URL for it, the internal endpoint accepts it
directly).
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` /
`prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write
metric.
## Limitations (MVP, documented deliberately) ## Limitations (MVP, documented deliberately)
- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix - `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work.
completion is a Beta feature on the `/beta` base URL; future work.
- `image` blocks are skipped (no vision support on these models). - `image` blocks are skipped (no vision support on these models).
- `tool_choice` is not mapped (not part of the core vocabulary). - `tool_choice` is not mapped (not part of the core vocabulary).
## Errors ## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_<status>` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
`RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_<status>`
otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or
`MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s
(e.g. `content_filter`, `insufficient_system_resource`) become
`finish {kind: 'error', code: <REASON>}` chunks.
## Testing ## Testing
Unit suites run against a local `node:http` mock SSE server (no network). Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`yarn test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
Real-API coverage lives in `tests/adapter.e2e.ts` (`yarn test:e2e`,
key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both
official effort levels, including the thinking+tools round trip with
reasoning passback.

View file

@ -1,33 +1,19 @@
# @deepseek-ai/dsh-llm-pi-ai # @deepseek-ai/dsh-llm-pi-ai
DeepSeek adapter for the harness LLM seam backed by DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) (the LLM library behind the pi agent).
[`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai)
(the LLM library behind the pi agent).
## Why a second adapter exists ## Why a second adapter exists
`@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This `@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose:
package is its **design-verification twin**: same models, same wire
protocol, completely different internals — a unified LLM library with its
own event vocabulary versus hand-rolled fetch/SSE. Anything the harness
`StreamChunk` protocol cannot express for BOTH implementations is a
core-vocabulary bug. The differences it exercised on purpose:
- pi-ai hands back tool-call `arguments` as **parsed objects**; the harness - pi-ai hands back tool-call `arguments` as **parsed objects**; the harness keeps raw JSON strings (re-stringified at `block-end`).
keeps raw JSON strings (re-stringified at `block-end`). - pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
- pi-ai reports failures as **in-stream error events** (it never throws - pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the - pi-ai's options omit stop sequences; `GenerateOptions.stop` is injected via its `onPayload` hook.
protocol's other sanctioned error path besides throwing (which
llm-deepseek uses).
- pi-ai folds reasoning tokens into `usage.output`; there is no separate
reasoning count to map.
- pi-ai's options omit stop sequences; `GenerateOptions.stop` is injected
via its `onPayload` hook.
## Config ## Config
Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's thinking-level vocabulary:
thinking-level vocabulary:
```yaml ```yaml
- id: llm - id: llm
@ -41,20 +27,12 @@ thinking-level vocabulary:
## Dependency weight ## Dependency weight
pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time dependencies. They are lazy-loaded — only the openai SDK actually loads for this adapter — but they do land in `node_modules`. Accepted for a package whose purpose is design verification.
dependencies. They are lazy-loaded — only the openai SDK actually loads for
this adapter — but they do land in `node_modules`. Accepted for a package
whose purpose is design verification.
## Limitations ## Limitations
Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped.
are not representable, `tool_choice` is not mapped.
## Testing ## Testing
Unit suites run against a local `node:http` mock SSE server (pi-ai's openai Unit suites run against a local `node:http` mock SSE server (pi-ai's openai SDK happily talks to any base URL). Real-API coverage in `tests/adapter.e2e.ts` (`yarn test:e2e`, key-gated): V4 Flash + V4 Pro across all exposed reasoning levels (off/high/xhigh), the thinking+tools round trip, and a cross-adapter structural-equivalence check against llm-deepseek.
SDK happily talks to any base URL). Real-API coverage in
`tests/adapter.e2e.ts` (`yarn test:e2e`, key-gated): V4 Flash + V4 Pro across
all exposed reasoning levels (off/high/xhigh), the thinking+tools round trip,
and a cross-adapter structural-equivalence check against llm-deepseek.

View file

@ -1,24 +1,18 @@
# dsh-llm # dsh-llm
Provider-neutral LLM vocabulary and abstract service. This package defines the Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin.
canonical language spoken by the agent loop, session logs, and every plugin.
## Service: `LlmService` (ctx key: `llm`) ## Service: `LlmService` (ctx key: `llm`)
An adapter registry plus streaming / non-streaming call surfaces. Both call An adapter registry plus streaming / non-streaming call surfaces. Both call surfaces are interceptable via waterfall events.
surfaces are interceptable via waterfall events.
### Public API ### Public API
- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` - `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber.
Register an adapter for the given model names. Disposed with the calling fiber.
- `ctx.llm.models(): string[]` — model names with a registered adapter. - `ctx.llm.models(): string[]` — model names with a registered adapter.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` - `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas).
Stream one model call as raw chunks (token-level deltas). - `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock>` Stream as completed content blocks (convenience view).
- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock>` - `ctx.llm.generate(options: GenerateOptions): Promise<GenerateResult>` One model call, fully assembled.
Stream as completed content blocks (convenience view).
- `ctx.llm.generate(options: GenerateOptions): Promise<GenerateResult>`
One model call, fully assembled.
### Events ### Events
@ -30,35 +24,23 @@ surfaces are interceptable via waterfall events.
### Extension points ### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` - Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider.
to add a new model provider. - Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
- Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for
caching, retry, logging, rate-limiting, etc.
### Content-block vocabulary (`types.ts`) ### Content-block vocabulary (`types.ts`)
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, 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.
`tool-result`, `image`. The union is derived from the merge-extensible
`ContentBlockMap`, so plugins can add block types via declaration merging.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, 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.
`reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`).
`BlockAssembler` is the single shared implementation that assembles chunks into
blocks/messages.
### Classes ### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. Used by the agent loop (raw chunks for replay
- `BlockAssembler` — incrementally assembles raw chunks into complete content
blocks and an assistant message. Used by the agent loop (raw chunks for replay
+ assembled for history) and by `streamBlocks()`/`generate()`. + assembled for history) and by `streamBlocks()`/`generate()`.
- `LlmError` — typed error with a `code` string (`NO_ADAPTER`, - `LlmError` — typed error with a `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`).
`DUPLICATE_ADAPTER`).
### What is NOT here (TODO) ### What is NOT here (TODO)
- **DeepSeek V4 adapter** — the first real adapter lands in a later phase. - **DeepSeek V4 adapter** — the first real adapter lands in a later phase.
- **Streaming protocol review** — the chunk protocol has `TODO(review)` markers - **Streaming protocol review** — the chunk protocol has `TODO(review)` markers and needs careful review before the first real adapter (DeepSeek V4 wire format, partial JSON arguments, interleaved reasoning signatures, ...).
and needs careful review before the first real adapter (DeepSeek V4 wire
format, partial JSON arguments, interleaved reasoning signatures, ...).

View file

@ -1,20 +1,14 @@
# dsh-session # dsh-session
Event-sourced session log and in-memory store. A `Session` is the append-only Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it.
source of truth for an agent's whole interaction history — the LLM message
history is *derived* from it.
## Service: `SessionStore` (ctx key: `sessions`) ## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event` and flush on `session/flush`.
not implemented here — plugins subscribe to `session/event` and flush on
`session/flush`.
### Public API ### Public API
- `ctx.sessions.create(id?: string, seed?: SessionEvent[]): Session` - `ctx.sessions.create(id?: string, seed?: SessionEvent[]): Session` Create a session. `seed` replays/forks an existing event log. Disposed with the calling fiber.
Create a session. `seed` replays/forks an existing event log. Disposed with
the calling fiber.
- `ctx.sessions.get(id: string): Session | undefined` - `ctx.sessions.get(id: string): Session | undefined`
- `ctx.sessions.list(): Session[]` - `ctx.sessions.list(): Session[]`
@ -31,35 +25,24 @@ not implemented here — plugins subscribe to `session/event` and flush on
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O. - `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O.
- `session.deriveMessages(): Message[]` — derive the LLM message history from - `session.deriveMessages(): Message[]` — derive the LLM message history from the event log. Raw `assistant/chunk` events are skipped; `context/message` and `steering/message` render as tagged synthetic user messages.
the event log. Raw `assistant/chunk` events are skipped; `context/message` and
`steering/message` render as tagged synthetic user messages.
- `session.events`, `session.seq`, `session.id` - `session.events`, `session.seq`, `session.id`
### Session event vocabulary (`types.ts`) ### Session event vocabulary (`types.ts`)
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `usage`, `error`.
`user/message`, `assistant/message`, `assistant/chunk`, `tool/call`,
`tool/result`, `steering/message`, `context/message`, `usage`, `error`.
Merge-extensible via `SessionEventMap` — a compaction plugin adds Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/marker`, etc.
`compaction/marker`, etc.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
for typed turn boundaries — `kind`-tagged instead of strings).
### Extension points ### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. See `examples/echo-agent/src/session-jsonl.ts` for the pattern.
`session/flush` (awaited) and fiber dispose. See - Replay/fork: `ctx.sessions.create(id, seed)` seeds a new session with an existing event log.
`examples/echo-agent/src/session-jsonl.ts` for the pattern.
- Replay/fork: `ctx.sessions.create(id, seed)` seeds a new session with an
existing event log.
### What is NOT here (TODO) ### What is NOT here (TODO)
- **Real persistence backends** (JSONL per session dir, sqlite) — future phase. - **Real persistence backends** (JSONL per session dir, sqlite) — future phase.
- **Session event vocabulary review** — `TODO(review)` once the loop and a - **Session event vocabulary review** — `TODO(review)` once the loop and a persistence plugin coexist.
persistence plugin coexist. - **Session branching/tree** (pi-style entry tree) — defered unless needed beyond seed-based forking.
- **Session branching/tree** (pi-style entry tree) — defered unless needed beyond
seed-based forking.

View file

@ -1,19 +1,14 @@
# dsh-system-prompt # dsh-system-prompt
System prompt assembly registry. Plugins contribute ordered text sections and System prompt assembly registry. Plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step.
tool-schema providers; the agent loop calls `assemble()` once per step.
## Service: `SystemPrompt` (ctx key: `systemPrompt`) ## Service: `SystemPrompt` (ctx key: `systemPrompt`)
### Public API ### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` - `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Disposed with the calling fiber.
Contribute a section. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` - `ctx.systemPrompt.assemble(): Promise<PromptAssembly>` Assemble the current prompt. Runs through the `system-prompt/assemble` waterfall.
Contribute tool schemas (evaluated at each assembly). Disposed with the calling
fiber.
- `ctx.systemPrompt.assemble(): Promise<PromptAssembly>`
Assemble the current prompt. Runs through the `system-prompt/assemble` waterfall.
### Events ### Events
@ -24,24 +19,17 @@ tool-schema providers; the agent loop calls `assemble()` once per step.
### Key types ### Key types
- `PromptSection` — `{ name, order, text: string | (() => string) }`. Sections - `PromptSection` — `{ name, order, text: string | (() => string) }`. Sections are concatenated in ascending `order`.
are concatenated in ascending `order`. - `PromptAssembly` — `{ sections: PromptSection[], tools: ToolSchema[] }`. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `PromptAssembly` — `{ sections: PromptSection[], tools: ToolSchema[] }`.
Tool schemas are part of the assembly by design: "what the model is told it
can do" is one coherent thing, even though adapters transmit schemas as a
separate wire field.
- `renderPrompt(assembly)` — joins section texts with blank lines. - `renderPrompt(assembly)` — joins section texts with blank lines.
Merge-extensible: plugins can declare extra fields on `PromptAssembly` via Merge-extensible: plugins can declare extra fields on `PromptAssembly` via declaration merging.
declaration merging.
### Extension points ### Extension points
- Section providers: AGENTS.md reader, cwd notifier, persona config, etc. - Section providers: AGENTS.md reader, cwd notifier, persona config, etc.
- Tool schema providers: `ToolRegistry` registers itself as a tool provider - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
automatically. - The `system-prompt/assemble` waterfall: mutate or replace the assembly (system-prompt configurability, dynamic tool filtering).
- The `system-prompt/assemble` waterfall: mutate or replace the assembly
(system-prompt configurability, dynamic tool filtering).
### What is NOT here ### What is NOT here

View file

@ -1,13 +1,8 @@
# @deepseek-ai/dsh-tool-bash # @deepseek-ai/dsh-tool-bash
The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees.
over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema +
text shaping; every process concern lives behind the seam, so sandboxed or
remote executor implementations swap in without changing what the model sees.
Requires a loaded executor implementation (e.g. Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash']`).
`@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash`
exists (`inject: ['tools', 'bash']`).
## Tools ## Tools
@ -21,42 +16,22 @@ exists (`inject: ['tools', 'bash']`).
| `workdir` | string | Working directory for this call. | | `workdir` | string | Working directory for this call. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. | | `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values.
config defaults via `ctx.bash.resolve()` before execution, so the executor
seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values.
Result text: stdout, then a `[stderr]` section, then status markers — Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
`[timed out after Nms]` whenever the executor's timer fired (reported
independently of how the process ended, so a command that traps SIGTERM and
exits 0 still shows it), `[killed by signal: …]` for a signal death,
`[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model
decides how to react), and `[output truncated; full output: <path>]` when the
tail was kept. Only infrastructure failures (spawn errors, aborts) surface as
`isError` results.
### `bash_output` ### `bash_output`
`task_id` → output produced **since the previous `bash_output` call** plus a `task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file.
status line (`running` / `completed, exit code: N` / `killed`). Reads that
lost data to buffer bounds say so and point at the full-output spill file.
### `bash_kill` ### `bash_kill`
`task_id` → SIGTERM→SIGKILL on the task's process group. Killing an `task_id` → SIGTERM→SIGKILL on the task's process group. Killing an already-finished task is a reported no-op; unknown ids are errors.
already-finished task is a reported no-op; unknown ids are errors.
## Background completion notices ## Background completion notices
When a background task finishes, a short notice is injected into the owning When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
agent's session (`agent.inject()`, source `{kind: 'plugin', plugin:
'tool-bash'}`). Injection is **durable context for the next model request,
not a wake-up** — an idle agent stays idle until something sends a message.
That's why the tool descriptions tell the model to poll with `bash_output`.
## Permissions ## Permissions
`TODO(permissions)`: commands run with the executor's full authority. The `TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/execute` waterfall (veto or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
permission/sandbox seam is the `tools/execute` waterfall (veto or ask) plus
sandboxing `BashExecutor` implementations — see docs/architecture.md.
`@cordisjs/plugin-capability` (a named-permission service with a session
`test()`) is a candidate building block for that work.

View file

@ -1,24 +1,19 @@
# dsh-tools # dsh-tools
Tool registry and execution waterfall. Tool plugins register their schemas and Tool registry and execution waterfall. Tool plugins register their schemas and executors; the agent loop executes calls through the `tools/execute` waterfall.
executors; the agent loop executes calls through the `tools/execute` waterfall.
## Service: `ToolRegistry` (ctx key: `tools`) ## Service: `ToolRegistry` (ctx key: `tools`)
### Public API ### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void` - `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined` - `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` - `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions).
Schemas of all registered tools (without the `execute` functions). - `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>`
Execute one tool call through the `tools/execute` waterfall.
### Injected services ### Injected services
`SystemPrompt` — the registry automatically feeds its tool schemas into the `SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`.
system-prompt assembly via `ctx.systemPrompt.tools()`.
### Events ### Events
@ -35,19 +30,13 @@ system-prompt assembly via `ctx.systemPrompt.tools()`.
### Extension points ### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
automatically. - The `tools/execute` waterfall is the single seam for sandbox, permission, hooks, and plan-mode plugins to wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed, or return a result without calling `next()` to short-circuit (veto).
- The `tools/execute` waterfall is the single seam for sandbox, permission, - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
hooks, and plan-mode plugins to wrap or veto a call. Listeners receive
`(exec, next)`: call `next()` to proceed, or return a result without calling
`next()` to short-circuit (veto).
- MCP servers: one plugin per server, discover tools, call
`ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas ### Typed tool parameter schemas
First-party plugin authors can use the `defineTool()` helper (exported from this First-party plugin authors can use the `defineTool()` helper (exported from this package) for typed tool parameter schemas:
package) for typed tool parameter schemas:
```ts ```ts
import { defineTool } from '@deepseek-ai/dsh-tools' import { defineTool } from '@deepseek-ai/dsh-tools'
@ -68,16 +57,11 @@ ctx.tools.register(defineTool({
})) }))
``` ```
The helper converts the author-facing `SchemaSpec` (with `required: true` as a The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
per-property boolean) to standard JSON Schema for the wire format. Raw
JSON-Schema tool definitions (from MCP servers) are still accepted by the
registry directly.
See `defineTool`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the See `defineTool`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
public API for details.
### What is NOT here (TODO) ### What is NOT here (TODO)
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint - **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
for parallel execution); phase 1 executes tool calls sequentially.
- **Parallel execution** — the loop currently iterates tool calls sequentially. - **Parallel execution** — the loop currently iterates tool calls sequentially.

10
vendor/AGENTS.md vendored
View file

@ -1,13 +1,7 @@
# AGENTS.md — Vendored Packages # AGENTS.md — Vendored Packages
This directory contains source-vendored copies of the Cordis framework and its This directory contains source-vendored copies of the Cordis framework and its foundation libraries. See `vendor/README.md` for the manifest, local-modification log, and the upstream sync procedure.
foundation libraries. See `vendor/README.md` for the manifest, local-modification
log, and the upstream sync procedure.
**Do NOT edit `vendor/*/src/` files casually.** Every local divergence from **Do NOT edit `vendor/*/src/` files casually.** Every local divergence from upstream must be logged exhaustively in `vendor/README.md` under "Local modifications." The `vendor/*/tsconfig.json` files are the exception — regenerated to fit the monorepo build, and they may be touched for type-checking policy changes (e.g., `noImplicitAny`).
upstream must be logged exhaustively in `vendor/README.md` under "Local
modifications." The `vendor/*/tsconfig.json` files are the exception —
regenerated to fit the monorepo build, and they may be touched for type-checking
policy changes (e.g., `noImplicitAny`).
When changes are unavoidable, follow the sync procedure in `vendor/README.md`. When changes are unavoidable, follow the sync procedure in `vendor/README.md`.