refactor(examples): extract the app spine into dsh-agent-core + app packages

Implements docs/rfc/.../2026-06-20-extract-example-app-packages.md. Each
example was thick — a hand-rolled start.ts, an infra preamble, nested
base.yml/base-core.yml/acp-tail.yml includes, and a coupled front-door
cluster enforced only by prose. This moves the composition into packages so
each example is a thin leaf cordis.yml: pick the swappable backends, load one
app package.

New packages:
  - @deepseek-ai/dsh-agent-core (packages/core/agent-core): one bundle plugin
    that loads the providerless/executor-less/UI-less spine (timer + llm +
    sessions + system-prompt + tools + agents + invariants + tool-bash +
    agent-loop) via ctx.plugin(...) inside apply(), and forwards agent-loop's
    `agents` list as its own Config (export const Config = AgentLoop.Config,
    default []).
  - @deepseek-ai/dsh-stdio-agent (packages/ui/stdio-agent): terminal chat APP —
    agent-core + console logger + readline UI + a pre-created `main` agent, with
    a bin. The demo:echo/coding front door.
  - @deepseek-ai/dsh-acp-agent (packages/ui/acp-agent): ACP server APP —
    agent-core + JSONL persistence + the acp bridge, NO stdout logger, with a
    bin. The stdout-purity footgun is structurally unreachable from the leaf.

Amendment to the RFC: hmr stays a LEAF cordis.yml entry, not baked into
dsh-stdio-agent. hmr is a Loader-only dev plugin (throws without
--expose-internals; the in-process test tier can't even import its decorator
form), so a package statically importing it could never carry the per-file
coverage gate. Unlike the console logger, a stray hmr is not a stdout-purity
footgun, so leaving it at the leaf costs no safety. With hmr out, all three new
packages carry in-process unit specs at 100%.

Boot glue (Loader tail, .env load, snapshot-mode selection, stdin-dispose
lifecycle) moves into each app's bin; start.ts and base.yml/base-core.yml/
acp-tail.yml are deleted. Each app package gets a keyless real-load-path test
that boots through its bin + the cordis Loader (guarding the unwrapExports
export-shape bug class, postmortem 0001). ACP snapshot replay stays green
against the existing committed goldens (pure boot restructuring). RFC moved
proposed->implemented with the amendment recorded; package/example/architecture
docs and the module graph updated.
This commit is contained in:
Tianyi Cui
2026-06-21 12:03:44 +08:00
parent 4209e4af3f
commit e2bde2902c
54 changed files with 1631 additions and 465 deletions

View File

@@ -1,59 +1,59 @@
# The coding-agent plugin tree, loaded via @cordisjs/plugin-include.
# Infra (logger/timer/hmr) first, then the shared provider/tool core (nested
# include of ../base.yml), then this example's agent-loop config + UI.
# The coding-agent plugin tree: the real coding agent. The two swappable
# backends — the DeepSeek adapter and the local bash executor — plus `hmr` for
# the dev/demo reload loop, then the stdio chat app (@deepseek-ai/dsh-stdio-
# agent), which bundles the whole agent-core spine (timer, llm, sessions,
# system-prompt, tools, agents, invariants, tool-bash, agent-loop), the console
# logger, JSONL persistence, the readline UI, and a pre-created `main` agent.
#
# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the
# environment — start.ts loads the gitignored repo-root .env first.
- id: logger
name: '@cordisjs/plugin-logger-console'
- id: timer
name: '@cordisjs/plugin-timer'
# `hmr` is a leaf entry (not baked into dsh-stdio-agent): it is a Loader-only
# dev plugin that needs `--expose-internals` — the `demo:coding` script passes
# it. Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the
# environment — the dsh-stdio-agent bin loads the gitignored repo-root .env
# first. cordis.yml reads them via the `!!js` tag.
# Hot-module reload for the dev/demo loop (needs `node --expose-internals`).
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root: ['.']
# Shared provider/tool core (llm, sessions, system-prompt, tools, agents,
# invariants, llm-deepseek, bash-local, tool-bash). Nested include: the path is
# resolved relative to THIS file's directory.
- id: base
name: '@cordisjs/plugin-include'
# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed
# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort).
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
path: '../base.yml'
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- deepseek-v4-flash
- deepseek-v4-pro
# agent-loop is per-example (NOT in base.yml): coding-agent pre-creates a `main`
# agent its stdio-chat drives via ctx.agents.get('main').
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
# Local bash executor (the model's only tool, via agent-core's tool-bash schema).
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
agents:
- id: main
model: deepseek-v4-flash
# Set RESUME_SESSION_ID to continue a prior persisted session (the ids
# live under ./.sessions); unset starts a fresh session each run.
resumeSessionId: !!js process.env.RESUME_SESSION_ID
systemPrompt: |
You are coding-agent, a CLI coding assistant.
timeoutMs: 60000
Your only tools are bash (plus bash_output/bash_kill for background
tasks). Do ALL file operations through bash: read with cat/sed/head,
search with grep, write with heredocs (cat <<'EOF' > file), edit
with sed or a rewrite. Each bash call runs in a fresh shell — pass
workdir instead of cd, and never rely on shell state between calls.
Check the [exit code: N] marker on every command; investigate
failures before moving on. Verify your work by running the code or
tests. Keep answers brief and factual.
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
- id: stdio-chat
name: '@deepseek-ai/dsh-ui-stdio'
# The stdio chat app: the whole spine + front-door cluster, configured for a
# real coding agent driving a pre-created `main` agent.
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-agent'
config:
model: deepseek-v4-flash
# Set RESUME_SESSION_ID to continue a prior persisted session (the ids live
# under ./.sessions); unset starts a fresh session each run.
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).'
systemPrompt: |
You are coding-agent, a CLI coding assistant.
Your only tools are bash (plus bash_output/bash_kill for background
tasks). Do ALL file operations through bash: read with cat/sed/head,
search with grep, write with heredocs (cat <<'EOF' > file), edit
with sed or a rewrite. Each bash call runs in a fresh shell — pass
workdir instead of cd, and never rely on shell state between calls.
Check the [exit code: N] marker on every command; investigate
failures before moving on. Verify your work by running the code or
tests. Keep answers brief and factual.

View File

@@ -1,30 +0,0 @@
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
// Load DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from a gitignored repo-root .env
// (Node >= 21.7 native). Absent file is fine — the environment may already
// carry the variables; cordis.yml reads them via the `!!js` tag. A
// present-but-unreadable/malformed .env is a real misconfiguration: surface it
// rather than silently running with the wrong environment.
try {
process.loadEnvFile(new URL('../../.env', import.meta.url).pathname)
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
process.stderr.write(`coding-agent: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
}
// Boot a Cordis app from this example's cordis.yml — the same shape as the
// upstream `cordis` bin, pinned to this directory.
const ctx = new Context()
ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: {
path: './cordis.yml',
},
})

View File

@@ -7,21 +7,28 @@ import { afterEach, describe, expect, it } from 'vitest'
/**
* Keyless Loader-path smoke for examples/coding-agent: boot the REAL example
* through its `cordis.yml` (the cordis Loader, `unwrapExports`, the full plugin
* tree incl. the extracted `@deepseek-ai/dsh-ui-stdio`), then close stdin with
* no prompt and assert the ready banner + a clean exit.
* through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the
* cordis Loader, `unwrapExports`, the full plugin tree incl. the
* `@deepseek-ai/dsh-agent-core` bundle and the extracted
* `@deepseek-ai/dsh-ui-stdio`), then close stdin with no prompt and assert the
* ready banner + a clean exit.
*
* No prompt is ever sent, so the model is NEVER called — this is why it runs
* without a real key. coding-agent's `cordis.yml` loads `llm-deepseek`, whose
* `apply()` only requires a key to be PRESENT (it does not validate it and only
* uses it when a stream actually starts), so a dummy key lets the tree boot
* while the absence of any prompt guarantees no network call. The value is the
* real-Loader-path guard for the shared UI plugin's export shape (a broken
* `export default` that drops `inject` would crash here — see postmortem 0001),
* complementing coding-agent's with-key e2e suites which prove the real product.
* real-Loader-path guard for the app + bundle + UI plugin export shapes (a broken
* `export default` that drops `inject`/`Config` would crash here — see postmortem
* 0001), complementing coding-agent's with-key e2e suites which prove the real
* product.
*/
const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
// The dsh-stdio-agent bin (the demo:coding entry) and this example's cordis.yml.
// The bin resolves its config-path arg from CWD; the test spawns from a temp
// cwd, so we pass the example config's ABSOLUTE path.
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
@@ -45,7 +52,7 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> {
const proc = spawn(
process.execPath,
// --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:coding).
['--expose-internals', '--import', tsxLoader, startScript],
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{
cwd,
env: {