Files
deepseek-harness/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md
Tianyi Cui 034779d761 docs(rfc): propose extracting example apps into packages
Add a proposed architecture RFC to make the examples folder thin: each
example becomes mostly an invocation of an app package. A shared
dsh-agent-core bundle owns the providerless spine; dsh-stdio-agent and
dsh-acp-agent app packages bake in their coupled front-door cluster
(UI + logger/hmr policy + agent pre-creation), turning the ACP
stdout-purity footgun into a property of the artifact. Leaf cordis.yml
shrinks to backends + config; start.ts is dropped in favor of a package
bin.

Supersedes the providerless-example-base RFC (cross-linked) and indexes
the new RFC under Proposed -> Architecture.
2026-06-20 23:05:31 +08:00

6.3 KiB

RFC: Extract example apps into packages

Status: proposed

Problem

An example folder is supposed to be thin — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled start.ts boot bootstrap, an infra preamble (logger/timer/hmr), nested includes of three shared YAML fragments, and per-example agent-loop/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the base.yml / base-core.yml / acp-tail.yml includes.

The deeper problem is a coupled front-door cluster that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over ui-stdio is not one swappable line: an ACP server must drop the stdout console logger (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit hmr (the editor owns the subprocess), and pre-create no agents (ACP session/new creates them on demand), whereas the stdio app needs a console logger, hmr, and a pre-created main. Today that coupling is enforced only by prose warnings in acp-agent/cordis.yml and base-core.yml. A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the stdout-purity footgun the examples guard by hand. The three start.ts files also duplicate the Loader-boot tail, the .env loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle.

Proposal

Make each example mostly an invocation of an app package, splitting the wiring along the existing interface / implementation / consumer seam: the app package owns the composition, the leaf cordis.yml owns only the swappable choices (which LLM adapter, which bash executor, model, prompt, persistence root).

  • @deepseek-ai/dsh-agent-core — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop (neutral agents: []). This is today's base-core.yml minus bash-local, plus the loop, as code instead of a YAML include.
  • @deepseek-ai/dsh-stdio-agent and @deepseek-ai/dsh-acp-agent — app packages, each consuming dsh-agent-core and baking in its coupled front-door cluster: stdio = ui-stdio + console logger + hmr + a pre-created main; acp = the acp bridge + no stdout logger + no hmr + no pre-created agents. The coupling becomes structurally unreachable from the leaf.
  • Drop start.ts. Each app package exposes a bin; the demo:* scripts invoke it (e.g. dsh-stdio-agent ./cordis.yml). The Loader-boot tail, .env loading, snapshot-mode selection, and stdin-dispose lifecycle move into that bin, owned by the app.
  • Collapse each leaf cordis.yml to backends + config: the LLM adapter (llm-deepseek with apiKey/models, or llm-replay), the bash executor (bash-local), and one app-bundle entry carrying model / systemPrompt / persistence root. ~4 entries, no infra preamble.
  • Fold echo-agent onto dsh-stdio-agent, swapping the LLM backend to the local mock-llm and adding the local echo-tool at the leaf — the clean demonstration of "swap the backend, keep the app". mock-llm.ts / echo-tool.ts stay as example-local teaching plugins.
  • Retire base.yml, base-core.yml, and acp-tail.yml — the spine they shared now lives in dsh-agent-core.

bash-local and the LLM adapter stay leaf choices: the bundle ships tool-bash (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app.

Why not keep the wiring in shared YAML includes?

The base*.yml/acp-tail.yml includes already dedupe the config, but a YAML include cannot encapsulate the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a bin, so the boot glue stays copied across three start.ts files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong.

Acceptance criteria

  • Each example directory is cordis.yml + README.md + tests only — no start.ts, no infra preamble; base.yml/base-core.yml/acp-tail.yml are gone.
  • demo:echo / demo:coding / demo:acp run via the app-package bins.
  • pnpm run test, pnpm run test:snapshot (re-recorded), pnpm run typecheck, pnpm run knip, pnpm run publint, and pnpm run doc-sync are green; the new packages carry the per-file 100% coverage gate and a README like every @deepseek-ai/dsh-*.

What we give up

  • The bare-plugin-tree pedagogy. echo-agent's inlined cordis.yml showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening dsh-agent-core. The app package's README must carry that teaching weight.
  • A layer of indirection. "What does this demo load?" becomes a package read, not a single YAML scan.
  • Migration cost (the implementing PR, not this one): three new packages, three leaf rewrites, the boot glue moved into bins, re-recorded ACP snapshots, and rewritten example READMEs + examples/AGENTS.md.