From e2bde2902cd9e2bed1d539425b433b21fd34964a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:03:44 +0800 Subject: [PATCH] refactor(examples): extract the app spine into dsh-agent-core + app packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 28 ++-- docs/cookbook/extension-cookbook.md | 2 +- docs/module-graph.md | 19 +++ docs/rfc/README.md | 2 +- ...2026-06-20-extract-example-app-packages.md | 53 +++++++ .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- ...2026-06-20-extract-example-app-packages.md | 44 ------ .../2026-06-20-providerless-example-base.md | 12 +- examples/AGENTS.md | 2 +- examples/README.md | 21 +-- examples/acp-agent/README.md | 4 +- examples/acp-agent/acp-tail.yml | 33 ---- examples/acp-agent/cordis.snapshot.yml | 53 ++++--- examples/acp-agent/cordis.yml | 66 +++++--- examples/acp-agent/start.ts | 63 -------- examples/acp-agent/tests/acp.e2e.ts | 10 +- examples/acp-agent/tests/snapshot-harness.ts | 9 +- examples/base-core.yml | 37 ----- examples/base.yml | 37 ----- examples/coding-agent/cordis.yml | 92 +++++------ examples/coding-agent/start.ts | 30 ---- .../coding-agent/tests/keyless-smoke.e2e.ts | 23 ++- examples/echo-agent/README.md | 24 +-- examples/echo-agent/cordis.yml | 65 +++----- examples/echo-agent/start.ts | 16 -- examples/echo-agent/tests/echo.e2e.ts | 29 ++-- knip.json | 4 + package.json | 6 +- packages/README.md | 6 + packages/core/README.md | 3 + packages/core/agent-core/README.md | 44 ++++++ packages/core/agent-core/package.json | 46 ++++++ packages/core/agent-core/src/index.ts | 88 +++++++++++ .../core/agent-core/tests/agent-core.spec.ts | 57 +++++++ packages/core/agent-core/tsconfig.json | 42 +++++ packages/ui/README.md | 4 + packages/ui/acp-agent/README.md | 39 +++++ packages/ui/acp-agent/package.json | 47 ++++++ packages/ui/acp-agent/src/bin.ts | 100 ++++++++++++ packages/ui/acp-agent/src/index.ts | 70 +++++++++ packages/ui/acp-agent/tests/acp-agent.spec.ts | 52 +++++++ packages/ui/acp-agent/tests/load-path.e2e.ts | 147 ++++++++++++++++++ packages/ui/acp-agent/tsconfig.json | 30 ++++ packages/ui/acp-agent/tsdown.config.ts | 18 +++ packages/ui/stdio-agent/README.md | 60 +++++++ packages/ui/stdio-agent/package.json | 53 +++++++ packages/ui/stdio-agent/src/bin.ts | 70 +++++++++ packages/ui/stdio-agent/src/index.ts | 98 ++++++++++++ .../ui/stdio-agent/tests/stdio-agent.spec.ts | 71 +++++++++ packages/ui/stdio-agent/tsconfig.json | 39 +++++ packages/ui/stdio-agent/tsdown.config.ts | 18 +++ pnpm-lock.yaml | 98 ++++++++++++ tsconfig.build.json | 3 + vitest.config.ts | 7 +- 54 files changed, 1631 insertions(+), 465 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md delete mode 100644 docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md delete mode 100644 examples/acp-agent/acp-tail.yml delete mode 100644 examples/acp-agent/start.ts delete mode 100644 examples/base-core.yml delete mode 100644 examples/base.yml delete mode 100644 examples/coding-agent/start.ts delete mode 100644 examples/echo-agent/start.ts create mode 100644 packages/core/agent-core/README.md create mode 100644 packages/core/agent-core/package.json create mode 100644 packages/core/agent-core/src/index.ts create mode 100644 packages/core/agent-core/tests/agent-core.spec.ts create mode 100644 packages/core/agent-core/tsconfig.json create mode 100644 packages/ui/acp-agent/README.md create mode 100644 packages/ui/acp-agent/package.json create mode 100644 packages/ui/acp-agent/src/bin.ts create mode 100644 packages/ui/acp-agent/src/index.ts create mode 100644 packages/ui/acp-agent/tests/acp-agent.spec.ts create mode 100644 packages/ui/acp-agent/tests/load-path.e2e.ts create mode 100644 packages/ui/acp-agent/tsconfig.json create mode 100644 packages/ui/acp-agent/tsdown.config.ts create mode 100644 packages/ui/stdio-agent/README.md create mode 100644 packages/ui/stdio-agent/package.json create mode 100644 packages/ui/stdio-agent/src/bin.ts create mode 100644 packages/ui/stdio-agent/src/index.ts create mode 100644 packages/ui/stdio-agent/tests/stdio-agent.spec.ts create mode 100644 packages/ui/stdio-agent/tsconfig.json create mode 100644 packages/ui/stdio-agent/tsdown.config.ts diff --git a/AGENTS.md b/AGENTS.md index 2fdd25b552..109fd4fe64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,9 @@ packages/ Harness packages, grouped by role at packages///. tools/ tool registry + tools/execute waterfall agent/ Agent interface, registry, agent/* event vocabulary agent-loop/ THE concrete plugin: ReactLoopAgent + the loop driver + agent-core/ bundle plugin: the providerless/executor-less/UI-less spine + (timer+llm+sessions+system-prompt+tools+agents+invariants+ + tool-bash+agent-loop) as code; forwards agent-loop's `agents` llm/ LLM capability family llm/ abstract LLM service + content-block vocabulary llm-deepseek/ DeepSeek API adapter (hand-rolled fetch/SSE) @@ -67,21 +70,28 @@ packages/ Harness packages, grouped by role at packages///. ui/ product integration surfaces acp/ Agent Client Protocol bridge: drive the agent from an ACP editor (Zed) over JSON-RPC stdio + stdio-agent/ stdio chat APP: agent-core spine + console logger + readline + UI + a pre-created main agent + a bin (the demo:echo/coding + front door) + acp-agent/ ACP server APP: agent-core spine + JSONL persistence + the + acp bridge, NO stdout logger + a bin (the demo:acp front door) support/ dev/test/example infrastructure (lower compat expectations) invariants/ dev-mode event-contract invariants + session-log freeze ui-stdio/ minimal stdio (readline) UI plugin: renders agent/* events, feeds stdin lines to the agent (shared by the demos) llm-replay/ record/replay adapter: short-circuits llm/stream from a recorded session JSONL (keyless snapshot tests) -examples/ Runnable demos (not workspaces; see examples/AGENTS.md). echo-agent - = mock model + echo tool + stdio UI + JSONL persistence, wired via - cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools - (pnpm run demo:coding, needs DEEPSEEK_API_KEY). - acp-agent = the coding agent exposed as an ACP server over - JSON-RPC stdio (pnpm run demo:acp, needs DEEPSEEK_API_KEY). - base.yml = shared provider/tool core both real demos include - (= base-core.yml, the providerless core, + the llm-deepseek adapter; - base-core.yml is reused by the acp-agent snapshot-replay config). +examples/ Runnable demos (not workspaces; see examples/AGENTS.md). Each is a + THIN leaf cordis.yml: it picks the swappable backends (an LLM adapter, + a bash executor) and loads ONE app package (dsh-stdio-agent or + dsh-acp-agent), which bundles the agent-core spine + front-door + cluster + boot glue (a bin). No start.ts. echo-agent = mock model + + echo tool on dsh-stdio-agent (pnpm run demo:echo, no key). + coding-agent = the real thing: DeepSeek V4 + bash tools on the same + app (pnpm run demo:coding, needs DEEPSEEK_API_KEY). acp-agent = the + coding agent as an ACP server on dsh-acp-agent (pnpm run demo:acp, + needs DEEPSEEK_API_KEY). cordis.snapshot.yml = the acp leaf with + llm-replay for keyless snapshot replay. docs/ architecture.md — the design doc. module-graph.md — generated inter-package dependency graph (Mermaid; `pnpm run gen-module-graph`). rfc/ — design decisions and proposals, one kind of doc grouped by diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 41fc85be0e..3a9f2c2f1b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -83,4 +83,4 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml` with HMR: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `pnpm run demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). The two real demos share their provider/tool core via [`examples/base.yml`](../../examples/base.yml). +Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `pnpm run demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is now just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent), the ACP demo loads [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle. diff --git a/docs/module-graph.md b/docs/module-graph.md index 92a2e7a09b..9efe6e9669 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -49,6 +49,22 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + agent-core --> agent + agent-core --> agent-loop + agent-core --> invariants + agent-core --> llm + agent-core --> session + agent-core --> system-prompt + agent-core --> tool-bash + agent-core --> tools + acp-agent --> acp + acp-agent --> agent-core + acp-agent --> session-persistence-jsonl + stdio-agent --> agent + stdio-agent --> agent-core + stdio-agent --> session + stdio-agent --> session-persistence-jsonl + stdio-agent --> ui-stdio ``` | Package | Depends on | @@ -72,3 +88,6 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | +| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index b9a5eef295..33efff0340 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -58,7 +58,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | ### Process @@ -115,6 +114,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | +| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md new file mode 100644 index 0000000000..eba2d9501b --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -0,0 +1,53 @@ +# 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 deeper problem was a **coupled front-door cluster** that lived at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` was 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) and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) That coupling was enforced only by prose warnings in the leaf YAML. A leaf that wired a console logger into the ACP config was a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guarded by hand. The three `start.ts` files also duplicated the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. + +## What shipped + +Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): 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`** ([packages/core/agent-core](../../../../packages/core/agent-core)) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`, mounted as child plugins inside its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nested `plugin-include` subtree's services were seen before. +- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The coupling becomes structurally unreachable from the leaf. They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed. +- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); 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 moved into that bin, owned by the app. The `bin.ts` files are coverage-excluded (a self-executing CLI entry, like the old `start.ts`) and driven by the keyless Loader-path tests. +- **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for 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 own `Config`, 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-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) 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. +- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — 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. + +### 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-agent` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: + +1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier. +2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically 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 has it, the ACP app structurally cannot. + +## 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 + +- Each example directory is `cordis.yml` (+ the acp `cordis.snapshot.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 `bin`s. +- The new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. Each app package has a keyless **real-load-path** smoke that boots it through its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). +- The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record. + +## 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 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](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. +- Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. +- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors). diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 2f0fc38bf9..0bf983281e 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -46,7 +46,7 @@ Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. -`examples/base.yml` always loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. +The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. The rest of the tree is not duplicated: both the normal `examples/acp-agent/cordis.yml` and the replay config load the same `@deepseek-ai/dsh-acp-agent` app entry (which bundles the agent-core spine + JSONL persistence + the ACP bridge), differing only in the LLM backend (`llm-deepseek` vs `llm-replay`) and the bash executor line. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. ### Two surfaces: normalize, then compare diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md deleted file mode 100644 index 5c99e7a197..0000000000 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ /dev/null @@ -1,44 +0,0 @@ -# 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 (`timer`, and — for the stdio demos — `logger` + `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](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/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`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/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](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 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](../../implemented/architecture/2026-06-13-capability-seams.md): 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: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`. This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (default `[]`, exactly the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason [base-core.yml](../../../../examples/base-core.yml) gives today for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. -- **`@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 the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). A handful of 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](../../../../examples/base.yml), [base-core.yml](../../../../examples/base-core.yml), and [acp-tail.yml](../../../../examples/acp-agent/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 `bin`s. -- `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](../../../../examples/AGENTS.md). - -## Related - -- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. -- Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. -- Complements [Reorganize packages into a modular hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md): the new app/core packages slot into a group under that hierarchy (a product group for the reusable core bundle, or alongside the examples for app-specific wiring). diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md index c83824d21c..7856c04b81 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md @@ -1,23 +1,23 @@ # RFC: Make the shared example base providerless -Status: rejected — superseded by [Extract example apps into packages](../../proposed/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. +Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. ## Problem -The examples have two shared base files: [examples/base-core.yml](../../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. +The examples had two shared base files: `examples/base-core.yml` was providerless, while `examples/base.yml` included that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result was a naming inversion: the file named `base.yml` was not the reusable base for all examples, while the true base was `base-core.yml`. -The split is understandable, but it makes every config explanation longer. It also leads to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter can boot even though the model is not called. +The split was understandable, but it made every config explanation longer. It also led to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter could boot even though the model is not called. ## Proposal -Rename the providerless core to [examples/base.yml](../../../../examples/base.yml) and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete [examples/base-core.yml](../../../../examples/base-core.yml). +Rename the providerless core to `examples/base.yml` and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete `examples/base-core.yml`. The shared base should contain only provider-neutral services and tools: `llm`, sessions, system prompt, tools, agents, invariants, bash executor, and bash tool schemas. Anything that chooses a model provider belongs at the leaf config. ## Acceptance criteria -- [examples/base.yml](../../../../examples/base.yml) is providerless. -- [examples/base-core.yml](../../../../examples/base-core.yml) is deleted. +- `examples/base.yml` is providerless. +- `examples/base-core.yml` is deleted. - Real demo configs explicitly add the DeepSeek adapter. - Snapshot replay config includes the same providerless base and its replay adapter. - The [examples README](../../../../examples/README.md), example-specific READMEs, and RFC references stop explaining "base = base-core plus adapter". diff --git a/examples/AGENTS.md b/examples/AGENTS.md index e352679c39..67ea68ae6f 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -2,7 +2,7 @@ Runnable demos that show how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub with no build. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`. -Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: `start.ts`, the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. +Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. There is no `start.ts` — the boot glue (Loader tail, `.env` load, snapshot-mode selection, stdin-dispose lifecycle) lives in each app package's `bin` (`@deepseek-ai/dsh-stdio-agent`, `@deepseek-ai/dsh-acp-agent`), which the `demo:*` scripts invoke against the leaf `cordis.yml`. ## Every example ships e2e smokes (keyless + with-key) diff --git a/examples/README.md b/examples/README.md index 59d3f70719..3fd9259e36 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,23 +1,26 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is now a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor) and loads ONE app package, plus any demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. ## echo-agent -A mock model + echo tool + stdio UI + JSONL persistence demo. Demonstrates: +A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-agent`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates: -- Loading plugins from a `cordis.yml` via `@cordisjs/plugin-loader` + `@cordisjs/plugin-include` +- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-agent` app - Registering a mock `LlmAdapter` (streaming scripted responses) - Registering a tool via `ctx.tools.register()` -- Persisting session events to JSONL via the `session/event` + `session/flush` pattern -- A minimal stdio UI consuming `agent/stream-chunk` and session events +- "Swap the backend, keep the app" — the only difference from `coding-agent` is the adapter -Run with: `pnpm run demo:echo` - -When prompted, type "echo " to trigger a tool call round-trip. +Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigger a tool call round-trip. ## coding-agent -The real thing: DeepSeek V4 + the bash tool suite + stdio chat + JSONL persistence, wired from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant. +The real thing: DeepSeek V4 + the bash tool suite on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant. Run with: `pnpm run demo:coding` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. + +## acp-agent + +The same coding agent exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. + +Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`). See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index afaf920bfe..3f65a2f354 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -6,11 +6,11 @@ The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)** pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) ``` -This boots `@deepseek-ai/dsh-acp` over the shared provider/tool core (`../base.yml`), with `agent-loop` configured with **no pre-created agents** (ACP `session/new` creates them on demand) and JSONL session persistence (so `session/load` works). +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand) plus the two swappable backends (`llm-deepseek`, `bash-local`). The app package bakes in the no-stdout-logger cluster, so the stdout-purity guarantee is a property of the artifact, not a leaf convention. ## stdout is the protocol -This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. Do not add `@cordisjs/plugin-logger-console` or a stdio UI here. Use a stderr exporter if you need logs. +This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. `@deepseek-ai/dsh-acp-agent` contains no logger entry, so the footgun is structurally unreachable from this leaf. Use a stderr exporter if you need logs. ## Zed configuration diff --git a/examples/acp-agent/acp-tail.yml b/examples/acp-agent/acp-tail.yml deleted file mode 100644 index ce58343add..0000000000 --- a/examples/acp-agent/acp-tail.yml +++ /dev/null @@ -1,33 +0,0 @@ -# The acp-agent "tail" shared by every acp-agent config (the normal demo, the -# snapshot RECORD path which reuses cordis.yml, and the snapshot REPLAY config): -# agent-loop (no pre-created agents — ACP session/new creates them on demand), -# JSONL session persistence, and the ACP bridge with its system prompt. The -# providerless core + an LLM adapter are included BEFORE this tail by each -# config; nothing here loads an adapter, so the tail is provider-agnostic. -# -# Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets -# it (so it can harvest / isolate the log), else ./.sessions for the demo. - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: [] - -- id: session-persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - -- id: acp - name: '@deepseek-ai/dsh-acp' - config: - model: deepseek-v4-flash - systemPrompt: | - You are a coding assistant driven over the Agent Client Protocol. - - 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. Check the [exit code: N] marker; verify your work. Keep - answers brief and factual. diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index b4dfbe21ad..b57920668a 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -1,32 +1,39 @@ -# Snapshot-test REPLAY config: the acp-agent plugin tree with the model replaced -# by llm-replay (serves a recorded session JSONL — no API key, no network). +# Snapshot-test REPLAY config: the acp-agent plugin tree with the model backend +# swapped to llm-replay (serves a recorded session JSONL — no API key, no +# network). The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. # -# It reuses ../base-core.yml (the providerless core) + ./acp-tail.yml (agent- -# loop + persistence + the ACP bridge), the SAME pieces cordis.yml shares — only -# the LLM adapter differs: llm-replay here, llm-deepseek there. It can't reuse -# ../base.yml because that loads llm-deepseek, whose apply() throws without -# DEEPSEEK_API_KEY, killing a keyless replay run at boot. +# Same app as cordis.yml (@deepseek-ai/dsh-acp-agent: the agent-core spine + +# JSONL persistence + the ACP bridge) — only the LLM backend differs: llm-replay +# here, llm-deepseek there. It can't reuse the real adapter because llm-deepseek's +# apply() throws without DEEPSEEK_API_KEY, killing a keyless replay run at boot. # -# stdout is reserved for the ACP JSON-RPC protocol — no stdout logger (see -# cordis.yml). The replay fixture path comes from $DSH_SNAPSHOT_FILE (and an -# optional $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. - -- id: timer - name: '@cordisjs/plugin-timer' - -# Providerless core (everything base.yml has EXCEPT the llm-deepseek adapter). -- id: base-core - name: '@cordisjs/plugin-include' - config: - path: '../base-core.yml' +# stdout is reserved for the ACP JSON-RPC protocol — no stdout logger (the app +# package omits it). The replay fixture path comes from $DSH_SNAPSHOT_FILE (and +# an optional $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. # The replay adapter: short-circuits llm/stream with the recorded log's chunks, # in place of llm-deepseek. - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' -# agent-loop + persistence + the ACP bridge — shared with cordis.yml. -- id: acp-tail - name: '@cordisjs/plugin-include' +# Local bash executor (the agent's only tool, via agent-core's tool-bash schema). +- id: bash + name: '@deepseek-ai/dsh-bash-local' config: - path: './acp-tail.yml' + timeoutMs: 60000 + +# The ACP server app — identical to cordis.yml's entry. +- id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + systemPrompt: | + You are a coding assistant driven over the Agent Client Protocol. + + 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. Check the [exit code: N] marker; verify your work. Keep + answers brief and factual. diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 384330cc9d..e456e8ff05 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -1,30 +1,48 @@ -# The acp-agent plugin tree, loaded via @cordisjs/plugin-include. Also the -# snapshot RECORD config (start.ts selects it for DSH_SNAPSHOT=record): a real -# llm-deepseek run whose persisted log the snapshot harness harvests. +# The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config +# (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek +# run whose persisted log the snapshot harness harvests. Just the two swappable +# backends — the DeepSeek adapter and the local bash executor — plus the ACP +# server app (@deepseek-ai/dsh-acp-agent), which bundles the agent-core spine, +# JSONL persistence, and the ACP bridge. # -# CRITICAL: this example loads NO stdout logger (no @cordisjs/plugin-logger- -# console, no stdio-chat). stdout is reserved for the ACP JSON-RPC protocol — -# anything else written there corrupts the frames (see packages/acp, RFC 010 § -# Risks). Use a stderr exporter if you need logging. The timer plugin is loaded -# (no stdout writes); hmr is omitted (an editor manages the subprocess). +# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for +# the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a +# property of @deepseek-ai/dsh-acp-agent (it contains no logger entry), not a +# leaf convention: there is no logger here to get wrong. # -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the -# environment — start.ts loads the gitignored repo-root .env first. +# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the +# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only). -- id: timer - name: '@cordisjs/plugin-timer' - -# Shared provider/tool core, INCLUDING the real llm-deepseek adapter. Nested -# include resolved relative to THIS file's directory. -- id: base - name: '@cordisjs/plugin-include' +# The DeepSeek adapter. +- 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 (no pre-created agents) + JSONL persistence + the ACP bridge. -# Shared with the snapshot REPLAY config (cordis.snapshot.yml) so the three -# acp-agent configs don't drift. -- id: acp-tail - name: '@cordisjs/plugin-include' +# Local bash executor (the agent's only tool, via agent-core's tool-bash schema). +- id: bash + name: '@deepseek-ai/dsh-bash-local' config: - path: './acp-tail.yml' + timeoutMs: 60000 + +# The ACP server app: the agent-core spine + JSONL persistence + the ACP bridge. +# Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it +# (so it can harvest / isolate the log), else ./.sessions for the demo. +- id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + systemPrompt: | + You are a coding assistant driven over the Agent Client Protocol. + + 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. Check the [exit code: N] marker; verify your work. Keep + answers brief and factual. diff --git a/examples/acp-agent/start.ts b/examples/acp-agent/start.ts deleted file mode 100644 index 11c2769603..0000000000 --- a/examples/acp-agent/start.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' - -// Snapshot-test modes (set by the snapshot harness via env): -// DSH_SNAPSHOT=replay — load cordis.snapshot.yml (providerless; llm-replay -// serves a recorded session log). Skip .env so a stray -// key can never trigger a live model call. -// DSH_SNAPSHOT=record — load the normal cordis.yml (the real llm-deepseek -// adapter + persistence) so a real run can be harvested -// (the persistence root is redirected by env). -// Absent — the normal demo (cordis.yml), driven by a real editor. -const snapshotMode = process.env.DSH_SNAPSHOT -const configPath = snapshotMode === 'replay' ? './cordis.snapshot.yml' : './cordis.yml' - -// Load DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from a gitignored repo-root .env -// (Node native). Absent file is fine — the environment may already carry them. -// In REPLAY mode we deliberately skip this: replay must never reach the network, -// so we don't want a present .env to enable a live call. -// -// IMPORTANT: this server speaks ACP JSON-RPC on stdout. Do NOT add any -// stdout logging here or in cordis.yml — it would corrupt the protocol frames. -// A present-but-unreadable/malformed .env is a real misconfiguration: surface -// it on STDERR (never stdout) rather than silently running with the wrong env. -if (snapshotMode !== 'replay') { - try { - process.loadEnvFile(new URL('../../.env', import.meta.url).pathname) - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { - process.stderr.write(`acp-agent: failed to load .env: ${String(error)}\n`) - } - // ENOENT (no .env) is fine — rely on the ambient environment. - } -} - -// Resolve relative cordis.yml paths from the repo root no matter where the -// editor launches this demo command. -process.chdir(fileURLToPath(new URL('../..', import.meta.url))) - -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: configPath, - }, -}) - -// Graceful shutdown for snapshot runs (both replay and record): when the client -// closes our stdin (it is done driving the session), dispose the whole context. -// Disposal awaits the agent-loop teardown and the persistence backend's final -// `session/flush`, so the session `.jsonl` is fully written before the process -// exits and the harness harvests it (and the subprocess exits cleanly so the -// harness's waitForExit resolves). (In a normal editor session stdin stays open -// for the connection's lifetime; the editor kills the process, so this never -// fires.) -if (snapshotMode !== undefined) { - process.stdin.on('end', () => { - void ctx.fiber.dispose().then(() => { process.exit(0) }) - }) -} diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 8dd8af6d01..ee60a7d131 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -26,7 +26,11 @@ import { * WITHOUT a key, since it only needs the server to boot and answer initialize. */ -const startScript = fileURLToPath(new URL('../start.ts', import.meta.url)) +// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. The +// bin resolves its config-path arg from CWD; the subprocess runs from a temp +// workdir, so pass the example config's ABSOLUTE path. +const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) // Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to // a temp workdir (this test launches there and uses it as the session cwd; the // bridge no longer requires cwd === the launch dir, but a temp dir keeps the @@ -55,7 +59,7 @@ interface Spawned { function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { const child = spawn( process.execPath, - ['--import', tsxLoader, startScript], + ['--import', tsxLoader, binScript, configPath], { cwd, env: { ...env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] @@ -101,7 +105,7 @@ describe('acp-agent over real stdio (no key required)', () => { // A dummy key lets the deepseek adapter APPLY (it only checks the key is // present at boot, not valid — the key is used only on a real model call, // which this purity test never triggers). So this runs WITHOUT real creds. - const child = spawn(process.execPath, ['--import', tsxLoader, startScript], { + const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], { cwd: workdir, env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/examples/acp-agent/tests/snapshot-harness.ts b/examples/acp-agent/tests/snapshot-harness.ts index 54d64c3174..7545768b1d 100644 --- a/examples/acp-agent/tests/snapshot-harness.ts +++ b/examples/acp-agent/tests/snapshot-harness.ts @@ -31,7 +31,12 @@ import { type SessionNotification, } from '@agentclientprotocol/sdk' -const startScript = fileURLToPath(new URL('../start.ts', import.meta.url)) +// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. +// The bin resolves its config-path arg from CWD and, under DSH_SNAPSHOT=replay, +// swaps it for the sibling cordis.snapshot.yml. The child's cwd is a temp dir +// OUTSIDE the repo, so pass the example config's ABSOLUTE path. +const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The repo-root tsconfig: dev/test run UNBUILT and the `@deepseek-ai/dsh-*` // imports resolve through its `paths` map. The child's cwd is a temp dir @@ -130,7 +135,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise child = spawn( process.execPath, - ['--import', tsxLoader, startScript], + ['--import', tsxLoader, binScript, configPath], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, ) diff --git a/examples/base-core.yml b/examples/base-core.yml deleted file mode 100644 index 15282c8ff6..0000000000 --- a/examples/base-core.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Providerless provider/tool core — everything the model and tools need EXCEPT -# an LLM adapter. Split out of base.yml so two consumers can share it: -# - base.yml = base-core.yml + the real llm-deepseek adapter (the demos). -# - acp-agent/cordis.snapshot.yml = base-core.yml + llm-replay (keyless -# snapshot replay — base.yml can't be reused there because llm-deepseek's -# apply() throws without DEEPSEEK_API_KEY). -# -# Plugin entries use package names (resolved from node_modules), so they are -# insensitive to the baseUrl reset that plugin-include performs per file. - -- 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). -- id: invariants - name: '@deepseek-ai/dsh-invariants' - -# Bash execution: the local executor implementation + the tool schemas. -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' diff --git a/examples/base.yml b/examples/base.yml deleted file mode 100644 index 897cef725d..0000000000 --- a/examples/base.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Shared provider/tool core for the example agents, loaded via a nested -# @cordisjs/plugin-include from each example's cordis.yml. This is -# base-core.yml (the providerless core: llm, sessions, system-prompt, tools, -# agents, invariants, bash-local, tool-bash) PLUS the real llm-deepseek adapter. -# -# The providerless core lives in base-core.yml so the keyless snapshot-replay -# config (acp-agent/cordis.snapshot.yml) can reuse it with llm-replay in place -# of the adapter — it can't reuse THIS file, because llm-deepseek's apply() -# throws without DEEPSEEK_API_KEY. -# -# Deliberately EXCLUDES: -# - the console logger: it writes to stdout, which the acp-agent reserves for -# the JSON-RPC protocol (see packages/acp). Each example loads logging itself. -# - agent-loop: AgentLoop pre-creates its configured `agents` in its -# constructor, and the examples disagree — coding-agent needs a pre-created -# `main` (its stdio-chat calls ctx.agents.get('main')), while acp-agent must -# pre-create NONE (ACP session/new creates agents on demand). So each example -# declares agent-loop with its own `agents` list. -# -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the env. - -# The providerless core (resolved relative to THIS file's directory). -- id: base-core - name: '@cordisjs/plugin-include' - config: - path: './base-core.yml' - -# 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: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - models: - - deepseek-v4-flash - - deepseek-v4-pro diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index aa748eb7fd..497aa8896a 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -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. diff --git a/examples/coding-agent/start.ts b/examples/coding-agent/start.ts deleted file mode 100644 index 1794b6b510..0000000000 --- a/examples/coding-agent/start.ts +++ /dev/null @@ -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', - }, -}) diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index cb8bcb837a..4e5f3e78dc 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -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: { diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index a5311e1fd5..de42234ef1 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -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 " -- `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 ". 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 " 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). diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 71ee1d3841..9eef3d1a1b 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -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 " triggers the tool).' + persistenceRoot: './.sessions' diff --git a/examples/echo-agent/start.ts b/examples/echo-agent/start.ts deleted file mode 100644 index 90dba7b5b0..0000000000 --- a/examples/echo-agent/start.ts +++ /dev/null @@ -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', - }, -}) diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 2e9ef0275c..944a36e429 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -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 diff --git a/knip.json b/knip.json index 6699203570..e73d165138 100644 --- a/knip.json +++ b/knip.json @@ -28,6 +28,10 @@ "packages/llm/llm-pi-ai": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/ui/acp-agent": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/package.json b/package.json index 499cffaf77..7a0b770d1e 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", - "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", - "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", - "demo:acp": "node --expose-internals --import tsx examples/acp-agent/start.ts", + "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", + "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", + "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { diff --git a/packages/README.md b/packages/README.md index 139050683d..d5e1f55fe6 100644 --- a/packages/README.md +++ b/packages/README.md @@ -35,6 +35,9 @@ dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks) dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge) dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin) dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests) +dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) +dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) +dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) ``` 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. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). @@ -49,6 +52,7 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l | `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` | | `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) | | `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | @@ -59,6 +63,8 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l | `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | | `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | | `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | +| `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | | `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | diff --git a/packages/core/README.md b/packages/core/README.md index 9c5411fd5f..8d8805471a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,5 +9,8 @@ The packages every harness build is assembled from: the session log, the system- | `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) | `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. + +`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md new file mode 100644 index 0000000000..1357a6ce8a --- /dev/null +++ b/packages/core/agent-core/README.md @@ -0,0 +1,44 @@ +# @deepseek-ai/dsh-agent-core + +The **providerless, executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. + +This is the package to read to see **the whole plugin tree at once** — the teaching role the inlined `echo-agent` `cordis.yml` used to play before the spine moved behind this bundle. + +## The tree it loads + +`apply(ctx, config)` mounts each of these as a child of the bundle fiber: + +``` +@cordisjs/plugin-timer timer service (writes nothing to stdout) +@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary +@deepseek-ai/dsh-session event-sourced session log + store +@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly +@deepseek-ai/dsh-tools tool registry + tools/execute waterfall +@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary +@deepseek-ai/dsh-invariants dev-mode event-contract assertions +@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas +@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`) +``` + +## What it deliberately leaves OUTSIDE the bundle + +The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle: + +- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). +- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). +- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC). + +This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. + +## Config + +```ts +import type { Config } from '@deepseek-ai/dsh-agent-core' +// Config === AgentLoop.Config — the `agents` list, default []. +``` + +The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`). Forwarding the list is exactly why the loop can live in the shared spine even though the apps disagree on which agents to pre-create. + +## Why a code bundle, not a shared YAML include + +A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact. Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order. diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json new file mode 100644 index 0000000000..d6e716835b --- /dev/null +++ b/packages/core/agent-core/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-agent-core", + "description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-timer": "^1.1.2", + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tool-bash": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts new file mode 100644 index 0000000000..ad3f5d8c46 --- /dev/null +++ b/packages/core/agent-core/src/index.ts @@ -0,0 +1,88 @@ +/** + * The providerless, executor-less, UI-less agent spine as ONE bundle plugin. + * + * Loads the fixed set of services every harness agent needs — `timer`, the LLM + * service, the session store, system-prompt assembly, the tool registry, the + * agent registry, the dev-mode invariants, the model-facing `bash` tool + * schemas, and the concrete `agent-loop` — and forwards the loop's `agents` + * list as its OWN config (default `[]`), so each app supplies its own + * pre-created agents. + * + * It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the + * bundle, picked by whatever loads it. + * - the LLM ADAPTER (`llm-deepseek`/`llm-pi-ai`/`llm-replay`) — the bundle + * ships the abstract `llm` service + `tool-bash` consumer schema; the leaf + * registers a concrete adapter on `ctx.llm`. + * - the bash EXECUTOR (`bash-local` or a sandboxed impl) — the bundle ships + * the `bash` tool consumer; the leaf provides `ctx.bash`. + * - the PRESENTATION (stdio UI / ACP bridge / a logger) and the per-app infra + * (a console logger, `hmr`) — these are the coupled "front-door cluster" the + * app packages ({@link @deepseek-ai/dsh-stdio-agent}, + * {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine. + * + * This is the interface/implementation/consumer seam at the composition level: + * the bundle owns the shared spine, the leaf owns the backends, the app package + * owns the front door. `timer` is in the spine (common to every front door — it + * writes nothing to stdout); the console logger is NOT (it writes to stdout, + * which the ACP bridge reserves for its JSON-RPC channel). + * + * Services register in the root store keyed by their isolate symbol, so a child + * loaded here via `ctx.plugin(...)` is visible to the bundle's SIBLINGS (the + * leaf's adapter and executor) exactly as a nested `plugin-include` subtree's + * services were before this bundle existed — cordis gates every read on + * `inject`, never on load order, so the fixed child set resolves regardless of + * which entry loads first. + * + * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the + * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray + * default would collapse the module to the bare `apply` function and drop the + * `Config` schema (see docs/postmortem/0001). The keyless Loader-path smokes in + * the app packages guard this end-to-end. + * + * @module @deepseek-ai/dsh-agent-core + */ + +import type { Context } from 'cordis' +import Timer from '@cordisjs/plugin-timer' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import * as invariants from '@deepseek-ai/dsh-invariants' +import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' + +export const name = 'agent-core' + +/** + * Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]` + * — an app that pre-creates no agents (the ACP bridge creates them on demand at + * `session/new`) simply omits it; an app that needs a pre-created `main` (the + * stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and + * the forwarded shape can never drift. + */ +export type Config = AgentLoopConfig + +/** Forward the loop's own schema so validation + defaulting stay identical. */ +export const Config = AgentLoop.Config + +/** + * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber; + * `agent-loop` receives the forwarded `agents` list. Load order is irrelevant + * (cordis pends each fiber on its `inject` until the services it needs exist), + * but the listing mirrors the dependency layering for readability: the LLM + * vocabulary and core registries first, then the dev tripwire and the bash tool + * consumer, then the loop that drives them. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(Timer) + ctx.plugin(LlmService) + ctx.plugin(SessionStore) + ctx.plugin(SystemPrompt) + ctx.plugin(ToolRegistry) + ctx.plugin(AgentRegistry) + ctx.plugin(invariants) + ctx.plugin(toolBash) + ctx.plugin(AgentLoop, { agents: config.agents }) +} diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts new file mode 100644 index 0000000000..f42f9b399d --- /dev/null +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as agentCore from '../src/index.ts' +import { AgentId } from '@deepseek-ai/dsh-agent' + +/** + * Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings + * up the whole providerless spine in one `ctx.plugin`, and the forwarded + * `agents` config reaches the loop (default `[]`, or a pre-created agent). + * + * The bundle is exercised through `ctx.plugin(agentCore, …)` — the NAMESPACE + * import, the same shape the Loader builds from `unwrapExports`. The real + * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless + * bin smokes; here we assert the composition + config forwarding. + */ +async function mount(config?: agentCore.Config): Promise { + const ctx = new Context() + await ctx.plugin(agentCore, config) + // The bundle mounts its children inside apply() (not awaited there); let their + // fibers settle so the spine services and any pre-created agent are ready. + await new Promise(resolve => setTimeout(resolve, 50)) + return ctx +} + +describe('dsh-agent-core bundle', () => { + it('brings up the full providerless spine', async () => { + const ctx = await mount() + // One service from each layer of the spine proves the children loaded. + expect(ctx.get('timer')).toBeDefined() + expect(ctx.get('llm')).toBeDefined() + expect(ctx.get('sessions')).toBeDefined() + expect(ctx.get('systemPrompt')).toBeDefined() + expect(ctx.get('tools')).toBeDefined() + expect(ctx.get('agents')).toBeDefined() + expect(ctx.get('agentLoop')).toBeDefined() + await ctx.fiber.dispose() + }) + + it('defaults the agents list to empty (no pre-created agents)', async () => { + const ctx = await mount() + expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('forwards a pre-created agent to the loop', async () => { + const ctx = await mount({ + agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }], + }) + expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await ctx.fiber.dispose() + }) + + it('re-exports the loop config schema as its own', () => { + expect(agentCore.Config).toBeDefined() + expect(agentCore.name).toBe('agent-core') + }) +}) diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json new file mode 100644 index 0000000000..3cf1e3fb74 --- /dev/null +++ b/packages/core/agent-core/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/timer" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/agent-loop" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../bash/tool-bash" + } + ] +} diff --git a/packages/ui/README.md b/packages/ui/README.md index 62b2c70855..075dfd524d 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,5 +5,9 @@ Integrations that expose the agent to an external editor or client. These are ** | Package | Role | ctx key | |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | +| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product. + +`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is just the swappable backends plus one app entry. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md new file mode 100644 index 0000000000..38894f3146 --- /dev/null +++ b/packages/ui/acp-agent/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-acp-agent + +The **ACP server app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. + +It is the structured counterpart to [`@deepseek-ai/dsh-stdio-agent`](../stdio-agent/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster. + +## What it bakes in — and what it deliberately omits + +stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it LEAVES OUT as what it includes: + +| Plugin | Why | +|---|---| +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | +| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | +| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC | +| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) | +| ~~`hmr`~~ | **omitted** — the editor owns the subprocess | + +Because there is no logger entry in the package, the footgun is **structurally unreachable from the leaf**: a leaf author cannot wire a stdout logger into the ACP config, because the leaf only picks backends, not the front door. + +## Config + +| Key | Default | Routed to | +|---|---|---| +| `model` | (required) | the per-session agent template the bridge creates agents from | +| `systemPrompt` | (required) | the per-session agent's system prompt | +| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | + +The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`). + +## The bin + +`dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`): + +- loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call; +- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`); +- in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit. + +All diagnostics go to **stderr** — stdout is the protocol. diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json new file mode 100644 index 0000000000..0ecbba9e6a --- /dev/null +++ b/packages/ui/acp-agent/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-acp-agent", + "description": "ACP server app: the agent-core spine + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "bin": { + "dsh-acp-agent": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./bin": { + "types": "./lib/bin.d.ts", + "default": "./lib/bin.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@deepseek-ai/dsh-acp": "^0.0.1", + "@deepseek-ai/dsh-agent-core": "^0.0.1", + "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "cordis": "^4.0.0-rc.6", + "schemastery": "^3.17.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "cordis": "^4.0.0-rc.6", + "schemastery": "^3.17.0" + } +} diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts new file mode 100644 index 0000000000..00a8bfdec0 --- /dev/null +++ b/packages/ui/acp-agent/src/bin.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that + * loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter + * and a bash executor), speaking ACP JSON-RPC on stdio. + * + * Owns the ACP-specific boot glue the example's `start.ts` once held: + * - `.env` loading (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`) — SKIPPED in + * snapshot REPLAY so a stray key can never trigger a live model call. + * - snapshot-mode config selection: `DSH_SNAPSHOT=replay` swaps the given + * `cordis.yml` for its sibling `cordis.snapshot.yml` (the keyless replay + * tree: `llm-replay` in place of `llm-deepseek`). + * - the stdin-dispose lifecycle: in a snapshot run the harness closes stdin + * when done, so dispose the context (flushing persistence) and exit cleanly. + * + * IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to + * STDERR only; the app plugin loads no stdout logger. A stray stdout write + * corrupts the protocol frames. + * + * Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`). + * + * @module @deepseek-ai/dsh-acp-agent/bin + */ + +import { pathToFileURL } from 'node:url' +import { basename, dirname, resolve } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' + +/** + * Resolve the config to boot, honoring snapshot REPLAY. Given the requested + * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in + * the SAME directory (the keyless replay tree). Other modes use the path as-is. + * Returns an absolute path resolved from the cwd. + */ +export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string { + const absolute = resolve(process.cwd(), configPath) + if (snapshotMode !== 'replay') return absolute + const dir = dirname(absolute) + const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') + return resolve(dir, replayName) +} + +/** + * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the + * cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In + * REPLAY mode the caller skips this entirely — replay must never reach the + * network, so a present `.env` must not enable a live call. + */ +function loadEnv(): void { + try { + process.loadEnvFile(resolve(process.cwd(), '.env')) + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`) + } + // ENOENT (no .env) is fine — rely on the ambient environment. + } +} + +/** + * Boot the Loader against `absoluteConfigPath`. `baseUrl` is pinned to the + * config's directory and the include gets only the basename, so the config's + * relative plugin/include paths resolve as the upstream `cordis` bin does. + * Returns the root context. + */ +export async function boot(absoluteConfigPath: string): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + await ctx.plugin(Loader) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: `./${basename(absoluteConfigPath)}` }, + }) + return ctx +} + +/** + * Entry point. Selects the config (snapshot-aware), loads `.env` outside replay, + * boots, and — in a snapshot run — disposes the context on stdin EOF so the + * session log is fully flushed before exit and the harness's `waitForExit` + * resolves. In a normal editor session stdin stays open for the connection's + * lifetime (the editor kills the process), so the EOF handler never fires. + */ +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const snapshotMode = process.env.DSH_SNAPSHOT + const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode) + if (snapshotMode !== 'replay') loadEnv() + const ctx = await boot(configPath) + if (snapshotMode !== undefined) { + process.stdin.on('end', () => { + void ctx.fiber.dispose().then(() => { process.exit(0) }) + }) + } +} + +/* v8 ignore start -- top-level CLI invocation; the testable core is + resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */ +await main() +/* v8 ignore stop */ diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts new file mode 100644 index 0000000000..0ff3609fbb --- /dev/null +++ b/packages/ui/acp-agent/src/index.ts @@ -0,0 +1,70 @@ +/** + * The ACP server app: the providerless agent spine ({@link + * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster an ACP + * server needs — JSONL session persistence and the {@link @deepseek-ai/dsh-acp} + * bridge, and DELIBERATELY NOTHING that writes to stdout. + * + * The cluster is the OPPOSITE of {@link @deepseek-ai/dsh-stdio-agent}'s, and + * baking it in is the whole point: an ACP server speaks JSON-RPC on stdout, so + * a stray console logger would corrupt the protocol frames (the [stdout-purity + * footgun]). This package contains NO console-logger entry, NO `hmr` (the editor + * owns the subprocess), and pre-creates NO agents (ACP `session/new` creates + * them on demand) — so the footgun is structurally unreachable from the leaf: + * there is no logger entry to get wrong. + * + * The leaf supplies only the swappable backends: the LLM adapter (`llm-deepseek` + * for the real model, `llm-replay` for keyless snapshot replay) and the bash + * executor (`bash-local`). This app's {@link Config} (model, system prompt, + * persistence root) routes each value to where it is wired — model/prompt onto + * the bridge's per-session agent template, the root onto the JSONL backend. + * + * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the + * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray + * default would collapse the module to the bare `apply` and drop the `Config` + * namespace (see docs/postmortem/0001 — the exact bug that shipped here once). + * The keyless ACP snapshot/Loader-path tests guard this end-to-end. + * + * @module @deepseek-ai/dsh-acp-agent + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import * as acp from '@deepseek-ai/dsh-acp' +import * as agentCore from '@deepseek-ai/dsh-agent-core' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' + +export const name = 'acp-agent' + +/** + * App config: the swappable per-deployment values. `model`/`systemPrompt` + * configure the agent template the ACP bridge creates each session's agent from + * (NOT a pre-created agent — ACP creates agents at `session/new`); + * `persistenceRoot` is the JSONL backend's directory. + */ +export interface Config { + /** Model name for ACP-created agents (must have a registered adapter). */ + model: string + /** Per-agent system prompt for ACP-created agents. */ + systemPrompt: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string +} + +export const Config: z = z.object({ + model: z.string().required(), + systemPrompt: z.string().required(), + persistenceRoot: z.string().default('./.sessions'), +}) + +/** + * Compose the spine with the ACP front door. The agent-core bundle pre-creates + * NO agents (its `agents` list defaults to `[]`); the JSONL backend persists + * under `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates + * one agent per `session/new` from `model`/`systemPrompt`. No logger, no `hmr` — + * stdout stays pure. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(agentCore) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt }) +} diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts new file mode 100644 index 0000000000..227b4c67b9 --- /dev/null +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as acpAgent from '../src/index.ts' + +/** + * In-process unit coverage for the @deepseek-ai/dsh-acp-agent composition: + * mounting it brings up the agent-core spine + JSONL persistence + the ACP + * bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO + * Loader-only plugin (no hmr), so it mounts in a plain Context. + * + * The REAL Loader-path guard (export shape via `unwrapExports`, the headline + * ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`; + * this spec asserts the composition and the persistenceRoot default branch. + */ +async function mount(config: acpAgent.Config): Promise { + const ctx = new Context() + await ctx.plugin(acpAgent, config) + // The bundle mounts its children inside apply() (not awaited there); let their + // fibers settle so the spine services are ready. + await new Promise(resolve => setTimeout(resolve, 50)) + return ctx +} + +describe('dsh-acp-agent composition', () => { + it('brings up the spine + persistence + the ACP bridge', async () => { + const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' }) + expect(ctx.get('agents')).toBeDefined() + expect(ctx.get('sessions')).toBeDefined() + expect(ctx.get('sessionPersistence')).toBeDefined() + expect(ctx.get('agentLoop')).toBeDefined() + // No pre-created agents — ACP session/new creates them on demand. + expect(ctx.get('agents')!.list()).toHaveLength(0) + await ctx.fiber.dispose() + }) + + it('defaults the persistence root when omitted', async () => { + // Exercises the `?? './.sessions'` fallback for a direct-apply caller that + // bypasses the schema's `.default(...)`: call `apply` directly (not via + // `ctx.plugin`, which validates+defaults the config first) with no + // persistenceRoot, so the runtime fallback is the one that fires. + const ctx = new Context() + acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' }) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(ctx.get('sessionPersistence')).toBeDefined() + await ctx.fiber.dispose() + }) + + it('exposes its plugin shape', () => { + expect(acpAgent.name).toBe('acp-agent') + expect(acpAgent.Config).toBeDefined() + }) +}) diff --git a/packages/ui/acp-agent/tests/load-path.e2e.ts b/packages/ui/acp-agent/tests/load-path.e2e.ts new file mode 100644 index 0000000000..fd559d99bf --- /dev/null +++ b/packages/ui/acp-agent/tests/load-path.e2e.ts @@ -0,0 +1,147 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { Readable, Writable } from 'node:stream' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' + +/** + * REAL-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its + * own `bin` (the demo:acp entry) as a subprocess, driving the cordis Loader and + * `unwrapExports` over a minimal `cordis.yml` that loads THIS package. This is + * the guard a hand-built `ctx.plugin({...})` mount structurally cannot be — that + * bypasses `unwrapExports`, the exact path that once dropped the bridge's + * `inject` and shipped (docs/postmortem/0001). It exercises the headline ACP + * operations end-to-end: `initialize` → `session/new` → `session/load`. + * + * KEYLESS: `session/new` and `session/load` reach the agent FACTORY but never + * the model (no prompt is sent), so no DEEPSEEK_API_KEY is needed. A dummy key + * lets `llm-deepseek`'s `apply()` (key-PRESENT check only) boot the tree. + * + * The config is written into a temp dir whose cwd IS the session workspace, so + * the bash workdir validation passes. We point tsx at the repo-root tsconfig + * (TSX_TSCONFIG_PATH) because the child's cwd is outside the repo and the + * unbuilt `paths` map is found by searching UP from cwd. + */ + +const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +// Repo root is four levels up from packages/ui/acp-agent/tests. +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +// A minimal leaf that loads this app + the two backends — the same shape as +// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture. +const CORDIS_YML = ` +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: [deepseek-v4-flash] +- id: bash + name: '@deepseek-ai/dsh-bash-local' +- id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + systemPrompt: 'You are a test agent.' +` + +interface Spawned { + child: ChildProcessWithoutNullStreams + client: ClientSideConnection + stderr: string[] +} + +let spawned: Spawned | undefined +let workdir: string | undefined + +afterEach(async () => { + if (spawned !== undefined) { + spawned.child.kill('SIGKILL') + spawned = undefined + } + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function boot(): Promise { + workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-')) + const cwd = workdir + const configPath = join(cwd, 'cordis.yml') + await writeFile(configPath, CORDIS_YML) + const child = spawn( + process.execPath, + ['--import', tsxLoader, binScript, configPath], + { + cwd, + env: { + ...process.env, + TSX_TSCONFIG_PATH: repoTsconfig, + // Key-present check only; no prompt is sent, so the model is never called. + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + const stderr: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => stderr.push(chunk)) + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(_params: SessionNotification): Promise { + return Promise.resolve() + }, + requestPermission(_params: RequestPermissionRequest): Promise { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + }) + const client = new ClientSideConnection(makeClient, stream) + spawned = { child, client, stderr } + return { ...spawned, cwd } +} + +describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => { + it('boots via its bin and answers initialize → session/new → session/load', async () => { + const { client, cwd, stderr } = await boot() + // initialize: a broken export shape (collapsed bridge plugin, dropped inject) + // crashes the tree on the first service read here — see postmortem 0001. + const init = await client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }) + expect(init.agentCapabilities?.loadSession).toBe(true) + + // session/new reaches the agent FACTORY (create) without the model. + const { sessionId } = await client.newSession({ cwd, mcpServers: [] }) + expect(sessionId).toBeTruthy() + + // session/load reaches the resume FACTORY + persistence without the model: + // load an UNKNOWN id (loading the live `sessionId` would correctly reject as + // "already loaded"). The bridge consults `sessionPersistence.list()` then + // `agents.resume()`, both of which run from the JSON-RPC read loop OUTSIDE + // the bridge's inject scope — the exact path postmortem 0001 crashed. A + // healthy tree rejects with a not-found error; a broken export shape would + // instead throw "cannot get property … without inject" before reaching it. + const unknownId = '00000000-0000-4000-8000-000000000000' + await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then( + () => { throw new Error('expected session/load of an unknown id to reject') }, + (error: unknown) => { expect(String(error)).not.toContain('without inject') }, + ) + + expect(stderr.join('')).not.toContain('without inject') + }, 30_000) +}) diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json new file mode 100644 index 0000000000..773ca2e293 --- /dev/null +++ b/packages/ui/acp-agent/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../acp" + }, + { + "path": "../../core/agent-core" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + } + ] +} diff --git a/packages/ui/acp-agent/tsdown.config.ts b/packages/ui/acp-agent/tsdown.config.ts new file mode 100644 index 0000000000..a0710d6e4d --- /dev/null +++ b/packages/ui/acp-agent/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * acp-agent ships TWO entries: the plugin (`index`) and the CLI `bin` (`bin`), + * the latter referenced by package.json `bin`/`exports["./bin"]`. The root + * tsdown builds only `src/index.ts`, so this override adds `bin.ts`. + * Declarations come from `tsc -b` (dts: false), matching every package. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/bin.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md new file mode 100644 index 0000000000..286fe6a85b --- /dev/null +++ b/packages/ui/stdio-agent/README.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-stdio-agent + +The **terminal stdio chat app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`. + +It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster. + +## What it bakes in + +A terminal chat always wants the same cluster, so the package owns it rather than trusting each leaf to re-wire it: + +| Plugin | Why it is here | +|---|---| +| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` | +| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | +| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent | + +`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in 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. The `demo:echo` / `demo:coding` leaves load it and pass `--expose-internals`. + +The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends. + +## Config + +| Key | Default | Routed to | +|---|---|---| +| `model` | (required) | the pre-created `main` agent's model | +| `systemPrompt` | (required) | the `main` agent's system prompt | +| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | +| `welcome` | `ready.` | the stdin-chat banner | +| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | + +## The bin + +`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config — the boot glue the `examples/*/start.ts` files once each duplicated. The `demo:echo` / `demo:coding` scripts invoke it. + +## Example leaf `cordis.yml` + +```yaml +# A real coding agent: hmr + the DeepSeek adapter + local bash, then this app. +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: [deepseek-v4-flash] +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash + systemPrompt: 'You are a CLI coding assistant. Your only tools are bash…' +``` + +Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json new file mode 100644 index 0000000000..45e8021607 --- /dev/null +++ b/packages/ui/stdio-agent/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-stdio-agent", + "description": "Terminal stdio chat app: the agent-core spine + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "bin": { + "dsh-stdio-agent": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./bin": { + "types": "./lib/bin.d.ts", + "default": "./lib/bin.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@cordisjs/plugin-logger-console": "^1.0.0", + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-core": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "@deepseek-ai/dsh-ui-stdio": "^0.0.1", + "cordis": "^4.0.0-rc.6", + "schemastery": "^3.17.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-logger-console": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-ui-stdio": "workspace:^", + "cordis": "^4.0.0-rc.6", + "schemastery": "^3.17.0" + } +} diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts new file mode 100644 index 0000000000..015a462f52 --- /dev/null +++ b/packages/ui/stdio-agent/src/bin.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that + * loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM + * adapter and a bash executor). Owns the boot glue the three `examples/*` once + * duplicated in their `start.ts`: load the gitignored repo-root `.env`, then + * drive the cordis Loader against the config path (default `./cordis.yml`). + * + * Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding` + * scripts invoke it with the example's config. + * + * @module @deepseek-ai/dsh-stdio-agent/bin + */ + +import { pathToFileURL } from 'node:url' +import { basename, dirname, resolve } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' + +/** + * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the + * CURRENT WORKING DIRECTORY (Node native `process.loadEnvFile`). An absent file + * is fine — the environment may already carry the variables; the leaf + * `cordis.yml` reads them via the `!!js` tag. A present-but-unreadable/malformed + * `.env` is a real misconfiguration: surface it on stderr rather than silently + * running with the wrong environment. The mock-model demo (echo) ships no key + * and simply has no `.env`. + */ +function loadEnv(): void { + try { + process.loadEnvFile(resolve(process.cwd(), '.env')) + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + process.stderr.write(`dsh-stdio-agent: failed to load .env: ${String(error)}\n`) + } + // ENOENT (no .env) is fine — rely on the ambient environment. + } +} + +/** + * Boot the Loader against `configPath` (resolved from the CWD). `baseUrl` is + * pinned to the config's directory and the include is handed only the basename, + * so the config's relative plugin/include paths resolve exactly as the upstream + * `cordis` bin does. Returns the root context (the process owns its lifetime). + */ +export async function boot(configPath: string): Promise { + const absolute = resolve(process.cwd(), configPath) + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dirname(absolute)).href + '/' + await ctx.plugin(Loader) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: `./${basename(absolute)}` }, + }) + return ctx +} + +/** + * Entry point: load `.env`, then boot the config named on argv (default + * `./cordis.yml`). Awaited at the module top level by the published bin + * (`#!/usr/bin/env node` shebang via the package's `bin` field). + */ +export async function main(argv: string[] = process.argv.slice(2)): Promise { + loadEnv() + await boot(argv[0] ?? './cordis.yml') +} + +/* v8 ignore start -- top-level CLI invocation; the testable core is boot()/main(), driven by the keyless Loader-path smoke */ +await main() +/* v8 ignore stop */ diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts new file mode 100644 index 0000000000..c4b9ed202c --- /dev/null +++ b/packages/ui/stdio-agent/src/index.ts @@ -0,0 +1,98 @@ +/** + * The stdio chat app: the providerless agent spine ({@link + * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal + * chat needs — a console logger, the readline `ui-stdio` UI, JSONL session + * persistence, and a pre-created `main` agent the UI drives. + * + * The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the + * console (stdout is just the terminal) and always pre-creates the `main` agent + * `ui-stdio` sends to. The leaf supplies only the swappable backends (the LLM + * adapter, the bash executor), the optional `hmr` dev-reload plugin, and this + * app's {@link Config} (model, prompt, persistence root, welcome banner). + * + * `hmr` is deliberately a LEAF entry, not baked in here: it is a Loader-only, + * subprocess-only dev plugin (its constructor throws without `--expose-internals` + * + a live `loader`, and the in-process test tier cannot even import it), so a + * package whose `apply` statically pulled it in could never be unit-tested or + * 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, while + * baking the LOGGER in (the real coupling) keeps stdout-vs-no-stdout a property + * of the artifact. + * + * Counterpart to {@link @deepseek-ai/dsh-acp-agent}, which bakes in the OPPOSITE + * cluster (no stdout logger, no pre-created agents — the ACP bridge reserves + * stdout for JSON-RPC and creates agents on demand). Splitting the two front + * doors into two packages makes each cluster a property of the artifact: there + * is no logger entry in the ACP leaf to get wrong. + * + * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the + * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray + * default would collapse the module to the bare `apply` and drop the `Config` + * namespace (see docs/postmortem/0001). The keyless Loader-path smoke in the + * echo example guards this end-to-end. + * + * @module @deepseek-ai/dsh-stdio-agent + */ + +import type { Context } from 'cordis' +import ConsoleExporter from '@cordisjs/plugin-logger-console' +import z from 'schemastery' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as agentCore from '@deepseek-ai/dsh-agent-core' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as uiStdio from '@deepseek-ai/dsh-ui-stdio' + +export const name = 'stdio-agent' + +/** + * App config: the swappable per-demo values, each routed to where the app wires + * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main` + * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); + * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner. + */ +export interface Config { + /** Model name for the `main` agent (must have a registered adapter). */ + model: string + /** System prompt for the `main` agent. */ + systemPrompt: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ + welcome?: string + /** + * If set, the `main` agent RESUMES this persisted session id instead of + * starting fresh. Sourced from an env var in the leaf `cordis.yml` + * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). + */ + resumeSessionId?: string +} + +export const Config: z = z.object({ + model: z.string().required(), + systemPrompt: z.string().required(), + persistenceRoot: z.string().default('./.sessions'), + welcome: z.string().default('ready.'), + resumeSessionId: z.string(), +}) + +/** + * Compose the spine with the stdio front door. The console logger comes first + * (infra), then the agent-core bundle pre-creating the `main` agent from this + * app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then + * the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf + * concern (see the module doc), so it is not mounted here. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(ConsoleExporter) + ctx.plugin(agentCore, { + agents: [{ + id: AgentId('main'), + model: config.model, + systemPrompt: config.systemPrompt, + ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, + }], + }) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) +} diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts new file mode 100644 index 0000000000..ab095bda66 --- /dev/null +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import * as stdioAgent from '../src/index.ts' + +/** + * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it + * composes the console logger, the agent-core spine (pre-creating the `main` + * agent from the app config), the JSONL backend, and the readline UI in one + * `ctx.plugin`. The forwarded `model`/`systemPrompt` reach the pre-created + * agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends. + * + * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev + * plugin the in-process tier cannot import); the REAL Loader-path guard (export + * shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless + * echo smoke in `examples/echo-agent`. Here we assert the composition + config + * forwarding the unit tier can reach. + */ +async function mount(config: stdioAgent.Config): Promise { + const ctx = new Context() + await ctx.plugin(stdioAgent, config) + // The app mounts its children inside apply() (not awaited there); let their + // fibers settle so the spine services + the pre-created agent are ready. + await new Promise(resolve => setTimeout(resolve, 80)) + return ctx +} + +describe('dsh-stdio-agent app', () => { + it('composes the spine + front-door cluster and pre-creates the main agent', async () => { + const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' }) + // The spine services (brought up by the agent-core bundle) are all present. + expect(ctx.get('agents')).toBeDefined() + expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('sessionPersistence')).toBeDefined() + // The pre-created `main` agent the UI drives. + expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await ctx.fiber.dispose() + }) + + it('defaults persistenceRoot and welcome when omitted', async () => { + // Direct apply (NOT via ctx.plugin, which validates+defaults the config + // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on + // apply()'s last two lines are the ones that fire — covering a + // schema-bypassing direct-mount caller. + const ctx = new Context() + stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' }) + await new Promise(resolve => setTimeout(resolve, 80)) + expect(ctx.get('sessionPersistence')).toBeDefined() + expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await ctx.fiber.dispose() + }) + + it('forwards resumeSessionId onto the pre-created agent when set', async () => { + // A resume id defers agent creation until persistence loads; with no backing + // session the resume is contained + logged, so no `main` agent registers — + // the branch that maps resumeSessionId through is what this covers. + const ctx = await mount({ + model: 'mock', + systemPrompt: 'hi', + persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume', + resumeSessionId: 'no-such-session', + }) + expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('exposes its name and Config schema', () => { + expect(stdioAgent.name).toBe('stdio-agent') + expect(stdioAgent.Config).toBeDefined() + }) +}) diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json new file mode 100644 index 0000000000..2130a6162c --- /dev/null +++ b/packages/ui/stdio-agent/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/logger-console" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent-core" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../support/ui-stdio" + } + ] +} diff --git a/packages/ui/stdio-agent/tsdown.config.ts b/packages/ui/stdio-agent/tsdown.config.ts new file mode 100644 index 0000000000..62dc986c08 --- /dev/null +++ b/packages/ui/stdio-agent/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin` + * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. + * The root tsdown builds only `src/index.ts`, so this override adds `bin.ts`. + * Declarations come from `tsc -b` (dts: false), matching every package. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/bin.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d0f6ec270..3bca330308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,39 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/agent-core: + devDependencies: + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/agent-loop: dependencies: schemastery: @@ -377,6 +410,63 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/acp-agent: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-acp': + specifier: workspace:^ + version: link:../acp + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../core/agent-core + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + + packages/ui/stdio-agent: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@cordisjs/plugin-logger-console': + specifier: workspace:^ + version: link:../../../vendor/logger-console + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../core/agent-core + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-ui-stdio': + specifier: workspace:^ + version: link:../../support/ui-stdio + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + packages/util/brand: devDependencies: cordis: @@ -3893,6 +3983,14 @@ snapshots: '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6) '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) + cordis@4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-include': link:vendor/include + '@cordisjs/plugin-loader': link:vendor/loader + cosmokit@1.8.1: {} cross-spawn@7.0.6: diff --git a/tsconfig.build.json b/tsconfig.build.json index c9b377ab0d..27a17a3f17 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -20,6 +20,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/core/tools" }, { "path": "./packages/core/agent-loop" }, + { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, @@ -27,6 +28,8 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, + { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/ui-stdio" }, { "path": "./packages/support/llm-replay" } ] diff --git a/vitest.config.ts b/vitest.config.ts index 91257ea2bd..55ec8477ed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,12 @@ export default defineConfig({ // executable code; vendor/ and examples/ are out of scope (examples are // exercised by the demo smoke test instead). include: ['packages/*/*/src/**/*.ts'], - exclude: ['packages/*/*/src/types.ts'], + // Types-only files carry no executable code. `bin.ts` files are + // self-executing CLI entrypoints (a top-level `await main()`): a spec + // can't import one without booting it, so they are driven by the keyless + // Loader-path smoke (a real subprocess) instead of the in-process unit + // suite — the same reason `examples/start.ts` sat out of coverage scope. + exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts'], // 100% or it doesn't merge (AGENTS.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. // Every v8 ignore comment must carry a reason — see AGENTS.md.