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:
@@ -1,32 +1,32 @@
|
||||
# echo-agent
|
||||
|
||||
Runnable demo: stdin chat with a scripted mock model and an echo tool.
|
||||
Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-mock skeleton — "swap the backend, keep the app".
|
||||
|
||||
## What it shows
|
||||
|
||||
- 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 `echo` tool when the user types "echo <something>"
|
||||
- `echo-tool.ts` — a tool registered via `ctx.tools.register()` that echoes text back uppercased
|
||||
- `@deepseek-ai/dsh-session-persistence-jsonl` — the durable JSONL persistence backend (loaded from `cordis.yml`, `root: ./.sessions`): append-only event log per session with crash-safe atomic writes, replacing the old write-only example plugin
|
||||
- `stdio-chat.ts` — a minimal UI plugin: reads stdin lines and `send`/`steer`s the agent, renders stream deltas, tool calls, and tool results
|
||||
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app (which bundles the whole [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`:
|
||||
|
||||
- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo <something>". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`.
|
||||
- `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased.
|
||||
|
||||
Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `coding-agent` — the same app, a different backend.
|
||||
|
||||
## Plugin files
|
||||
|
||||
| File | Role | Key patterns demonstrated |
|
||||
|---|---|---|
|
||||
| `mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with proper `block-start`/`block-end` protocol |
|
||||
| `echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, tool execution returning `ContentBlock[]` |
|
||||
| `stdio-chat.ts` | UI | `agent/stream-chunk`, `session/event` (tool/*), stdin→send/steer |
|
||||
| `start.ts` | Bootstrap | `Context` + `Loader` + `plugin-include` wired to `cordis.yml` |
|
||||
| `src/mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with the proper `block-start`/`block-end` protocol |
|
||||
| `src/echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, returning `ContentBlock[]` |
|
||||
| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-agent` entry carrying the app config |
|
||||
|
||||
Persistence is the shared `@deepseek-ai/dsh-session-persistence-jsonl` plugin (not a per-example file).
|
||||
The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-agent` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
pnpm run demo:echo
|
||||
# or:
|
||||
node --expose-internals --import tsx examples/echo-agent/start.ts
|
||||
node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,57 +1,38 @@
|
||||
# The echo-agent plugin tree, loaded via @cordisjs/plugin-include.
|
||||
# Core services first, then the demo plugins, then the agent itself.
|
||||
|
||||
- id: logger
|
||||
name: '@cordisjs/plugin-logger-console'
|
||||
|
||||
- id: timer
|
||||
name: '@cordisjs/plugin-timer'
|
||||
# The echo-agent plugin tree: the stdio chat app with its LLM backend swapped to
|
||||
# the local `mock-echo` mock and the local `echo` tool added. The clean
|
||||
# demonstration of "swap the backend, keep the app" — every service the agent
|
||||
# needs lives in @deepseek-ai/dsh-stdio-agent (which bundles @deepseek-ai/dsh-
|
||||
# agent-core); this leaf only picks the backends, `hmr`, and the app config.
|
||||
#
|
||||
# No API key: the `mock-echo` adapter never touches the network.
|
||||
|
||||
# Hot-module reload for the dev/demo loop (a leaf entry, not baked into
|
||||
# dsh-stdio-agent — it needs `node --expose-internals`, which `demo:echo` passes).
|
||||
- id: hmr
|
||||
name: '@cordisjs/plugin-hmr'
|
||||
config:
|
||||
root: ['.']
|
||||
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm'
|
||||
|
||||
- id: sessions
|
||||
name: '@deepseek-ai/dsh-session'
|
||||
|
||||
- id: system-prompt
|
||||
name: '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
- id: tools
|
||||
name: '@deepseek-ai/dsh-tools'
|
||||
|
||||
- id: agents
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
# Dev-mode event-contract assertions + session-log freeze (off in prod;
|
||||
# on here so the demo smoke test exercises the contract).
|
||||
- id: invariants
|
||||
name: '@deepseek-ai/dsh-invariants'
|
||||
|
||||
- id: agent-loop
|
||||
name: '@deepseek-ai/dsh-agent-loop'
|
||||
config:
|
||||
agents:
|
||||
- id: main
|
||||
model: mock-echo
|
||||
systemPrompt: 'You are echo-agent, a demo agent.'
|
||||
|
||||
# The mock model (registers the `mock-echo` adapter) and the demo `echo` tool —
|
||||
# example-local teaching plugins, resolved relative to THIS file's directory.
|
||||
- id: mock-llm
|
||||
name: './src/mock-llm.ts'
|
||||
|
||||
- id: echo-tool
|
||||
name: './src/echo-tool.ts'
|
||||
|
||||
- id: session-persistence
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: './.sessions'
|
||||
# Local bash executor: agent-core ships the `tool-bash` consumer schema, so the
|
||||
# leaf provides the executor it runs on (the echo demo doesn't drive bash, but
|
||||
# the tool is part of the shared spine).
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
- id: stdio-chat
|
||||
name: '@deepseek-ai/dsh-ui-stdio'
|
||||
# The stdio chat app: console logger + the agent-core spine (pre-creating the
|
||||
# `main` agent on the mock model) + JSONL persistence + the readline UI.
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-agent'
|
||||
config:
|
||||
model: mock-echo
|
||||
systemPrompt: 'You are echo-agent, a demo agent.'
|
||||
welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).'
|
||||
persistenceRoot: './.sessions'
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
// 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',
|
||||
},
|
||||
})
|
||||
@@ -7,22 +7,29 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless Loader-path smoke for examples/echo-agent: boot the REAL example
|
||||
* through its `cordis.yml` (the cordis Loader, `unwrapExports`, the whole
|
||||
* plugin tree), pipe a script of stdin lines, and assert the rendered stdout.
|
||||
* through the `@deepseek-ai/dsh-stdio-agent` bin against this example's
|
||||
* `cordis.yml` (the cordis Loader, `unwrapExports`, the whole plugin tree),
|
||||
* pipe a script of stdin lines, and assert the rendered stdout.
|
||||
*
|
||||
* This is the guard the per-file unit suite structurally cannot be: it drives
|
||||
* the extracted `@deepseek-ai/dsh-ui-stdio` plugin AND the example-local
|
||||
* `mock-llm.ts` / `echo-tool.ts` through their REAL load path, so a broken
|
||||
* plugin export shape (a stray `export default` that `unwrapExports` would
|
||||
* collapse, dropping `inject`) fails here even though hand-mounted unit tests
|
||||
* stay green (see docs/postmortem/0001). It needs no API key — the `mock-echo`
|
||||
* adapter never touches the network — so it runs in the default e2e gate.
|
||||
* the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core`
|
||||
* bundle it loads, the extracted `@deepseek-ai/dsh-ui-stdio` plugin, AND the
|
||||
* example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path, so
|
||||
* a broken plugin export shape (a stray `export default` that `unwrapExports`
|
||||
* would collapse, dropping `inject`/`Config`) fails here even though hand-mounted
|
||||
* unit tests stay green (see docs/postmortem/0001). It needs no API key — the
|
||||
* `mock-echo` adapter never touches the network — so it runs in the default e2e
|
||||
* gate.
|
||||
*
|
||||
* Both branches of mock-llm.ts are exercised: an `echo …` line (the tool
|
||||
* round-trip → `ECHO: …`) and a plain line (the direct canned reply).
|
||||
*/
|
||||
|
||||
const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
|
||||
// The dsh-stdio-agent bin (the demo:echo 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: `@deepseek-ai/dsh-*` imports resolve through the root
|
||||
// tsconfig `paths` map, which tsx finds by searching UP from cwd. We spawn from
|
||||
@@ -53,8 +60,8 @@ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number
|
||||
process.execPath,
|
||||
// --expose-internals: the example's cordis.yml loads the HMR plugin, which
|
||||
// requires it (mirrors the `demo:echo` script). The whole point is to boot
|
||||
// the example EXACTLY as it really runs, through the Loader.
|
||||
['--expose-internals', '--import', tsxLoader, startScript],
|
||||
// the example EXACTLY as it really runs, through the bin + Loader.
|
||||
['--expose-internals', '--import', tsxLoader, binScript, configPath],
|
||||
{ cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
)
|
||||
child = proc
|
||||
|
||||
Reference in New Issue
Block a user