Move the agent-spine bundle and the stdio/ACP/JSON-RPC app packages out of core/ and ui/ into a new packages/examples/ group, renamed with a -demo suffix so the npm name marks them as non-product surface: core/agent-core -> examples/agent-spine-demo (dsh-agent-spine-demo) ui/stdio-agent -> examples/stdio-demo (dsh-stdio-demo) ui/acp-agent -> examples/acp-demo (dsh-acp-demo) ui/jsonrpc-agent -> examples/jsonrpc-demo (dsh-jsonrpc-demo) Update every code/config/test reference and reference-only doc mentions, and regenerate module-graph, config-catalog, and doc-graphs. The jsonrpc bin (dsh-jsonrpc-agent) and single-file exe (dsh-jsonrpc-agent-pkg) keep their names; the SDK runtime-startup surface is reconciled separately.
7.4 KiB
RFC: Extract example apps into packages
Status: implemented
Problem
An example folder is supposed to be thin — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled start.ts boot bootstrap, an infra preamble (timer, and — for the stdio demos — logger + hmr), nested includes of three shared YAML fragments (base.yml / base-core.yml / acp-agent/acp-tail.yml), and per-example agent-loop/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes.
The leaf configs also owned a coupled front door. ACP requires stdout purity and creates agents through session/new; stdio requires a console logger and a pre-created main. Prose warnings were the only guard against combining these incorrectly, while three start.ts files duplicated the Loader bootstrap and lifecycle code.
Decision
Each example is now 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-spine-demo(packages/examples/agent-spine-demo) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle.@deepseek-ai/dsh-stdio-demo(packages/examples/stdio-demo) and@deepseek-ai/dsh-acp-demo(packages/examples/acp-demo) bake in their front doors. Stdio includesui-stdio, a console logger, andmain; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact.start.tsis gone. Each app package exposes abin(dsh-stdio-demo/dsh-acp-demo); thedemo:*scripts invoke it (e.g.dsh-stdio-demo ./cordis.yml). The Loader-boot tail,.envloading, and fail-loud guards live in the shared@deepseek-ai/dsh-app-bootpackage (unit-tested under the per-file coverage gate — see share the app bins' boot glue); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). Thebin.tsfiles themselves stay coverage-excluded (self-executing CLI entries, like the oldstart.ts) and are driven by the keyless Loader-path tests.- Each leaf
cordis.ymlcollapses to backends + config: the LLM adapter (llm-deepseekwith apiKey/models, orllm-replay), the bash executor (bash-local),hmrfor the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's ownConfig, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). - echo-agent folds onto
dsh-stdio-demo, swapping the LLM backend to the localmock-llmand adding the localecho-tool(plusbash-local, which the spine'stool-bashinjects) at the leaf — the clean demonstration of "swap the backend, keep the app".mock-llm.ts/echo-tool.tsstay as example-local teaching plugins. base.yml,base-core.yml, andacp-agent/acp-tail.ymlare retired — the spine they shared now lives indsh-agent-spine-demo.
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.
Amendment on implementation: hmr stays a leaf entry
The proposal listed hmr among the stdio app's baked-in front-door cluster. Validating against the code, baking hmr into the dsh-stdio-demo package fights cordis in two ways, so it ships as a leaf cordis.yml entry instead:
@cordisjs/plugin-hmris a Loader-only, subprocess-only dev plugin — its constructor throws withoutnode --expose-internals+ a liveloaderservice, so it can only run in the realdemo:*/bin subprocess, never in the in-process unit/coverage tier.- The in-process test tier (vitest) cannot even import the vendored
hmrmodule (its class-decorator@Injectform fails under Vite's transform), so a package whoseapplystatically imported it could never satisfy the per-file 100% coverage gate on its headline function.
Crucially, hmr is not a stdout-purity footgun the way the console logger is — a stray hmr in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The logger (the real coupling) stays baked in: the stdio app includes it, the ACP app omits it.
Alternatives considered
Why not keep the wiring in shared YAML includes?
The old base*.yml/acp-tail.yml includes already deduped 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 stayed 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.
Verification
- Example directories contain only their config, README, and tests:
start.ts, the infrastructure preamble, and the shared YAML includes are gone. demo:echo,demo:repl, anddemo:acpinvoke the app-package bins.- Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in postmortem 0001.
- The ACP replay transcript remains unchanged because the plugin set and load order did not change.
Consequences
- The bare-plugin-tree pedagogy. echo-agent's inlined
cordis.ymlshowed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means openingdsh-agent-spine-demo. The app package's README carries that teaching weight. - A layer of indirection. "What does this demo load?" becomes a package read, not a single YAML scan.
Related
- Supersedes Make the shared example base providerless: renaming
base.ymlto the providerless core is moot once the spine moves intodsh-agent-spine-demoand thebase*.ymlfiles are deleted. - Builds on the capability-seams interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle.
- Complements Reorganize packages into a modular hierarchy: the new app/core packages slot into existing groups under that hierarchy (
corefor the reusable spine bundle,uifor the app-specific front doors).