From e98c1c5d42ae60216031166618616c374f3f3abe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:47:32 +0800 Subject: [PATCH] Add examples/coding-agent and the docs cookbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real agent wiring: DeepSeek V4 + the bash tool suite + stdio chat + JSONL persistence, runnable via yarn demo:coding (reads the gitignored repo-root .env through process.loadEnvFile). - examples/coding-agent: cordis.yml wiring both real plugin families (llm-deepseek with !!js env secrets; bash-local + tool-bash), a bash-only coding system prompt, a max-steps-guard plugin (bounds runaway turns via the agent/turn-continuation waterfall — abort() from step-end is a no-op by then), and a stdio UI with dimmed reasoning and exit-on-idle for piped stdin. - e2e (yarn test:e2e, key-gated): full-loop.e2e.ts runs a real model against the real bash tool; coding-task.e2e.ts is the swebench-style smoke — the model fixes a buggy add.js in a temp dir and the test re-runs node add.test.js itself rather than trusting the agent. - docs/cookbook: adding-a-package (the verified checklist), adding-a-tool (execute() contract, background pattern, seams), adding-an-llm-adapter (protocol obligations, mock-server testing, e2e policy). AGENTS.md layout/commands/secrets sections updated; architecture.md points at both examples and the cookbook. - vitest.e2e.config.ts: serialize test files + retry twice — parallel e2e files trip the shared internal key's concurrency quota. - fix: the !js YAML tag spelling in docs/JSDoc is actually !!js (js-yaml resolves custom tags under tag:yaml.org,2002:js). --- AGENTS.md | 87 ++++++++++++- docs/architecture.md | 14 ++- docs/cookbook/adding-a-package.md | 55 ++++++++ docs/cookbook/adding-a-tool.md | 77 ++++++++++++ docs/cookbook/adding-an-llm-adapter.md | 75 +++++++++++ examples/coding-agent/README.md | 51 ++++++++ examples/coding-agent/cordis.yml | 77 ++++++++++++ examples/coding-agent/package.json | 7 ++ examples/coding-agent/src/session-jsonl.ts | 36 ++++++ examples/coding-agent/src/stdio-chat.ts | 119 ++++++++++++++++++ examples/coding-agent/start.ts | 25 ++++ .../coding-agent/tests/coding-task.e2e.ts | 86 +++++++++++++ examples/coding-agent/tests/full-loop.e2e.ts | 43 +++++++ examples/coding-agent/tests/harness.ts | 55 ++++++++ knip.json | 2 +- package.json | 1 + vitest.e2e.config.ts | 11 +- 17 files changed, 810 insertions(+), 11 deletions(-) create mode 100644 docs/cookbook/adding-a-package.md create mode 100644 docs/cookbook/adding-a-tool.md create mode 100644 docs/cookbook/adding-an-llm-adapter.md create mode 100644 examples/coding-agent/README.md create mode 100644 examples/coding-agent/cordis.yml create mode 100644 examples/coding-agent/package.json create mode 100644 examples/coding-agent/src/session-jsonl.ts create mode 100644 examples/coding-agent/src/stdio-chat.ts create mode 100644 examples/coding-agent/start.ts create mode 100644 examples/coding-agent/tests/coding-task.e2e.ts create mode 100644 examples/coding-agent/tests/full-loop.e2e.ts create mode 100644 examples/coding-agent/tests/harness.ts diff --git a/AGENTS.md b/AGENTS.md index 584f9a23a6..dd4b75562f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,13 @@ packages/ Harness packages, all named @deepseek-ai/dsh-: tool-bash/ model-facing bash/bash_output/bash_kill tool schemas examples/ Runnable demos (not workspaces). echo-agent = mock model + echo tool + stdio UI + JSONL persistence, wired via cordis.yml. + coding-agent = the real thing: DeepSeek V4 + bash tools + (yarn demo:coding, needs DEEPSEEK_API_KEY). docs/ architecture.md — the design doc. adr/ — decision records (the why behind vendoring, event-sourcing, the schema DSL, …). rfc/ — proposals for substantial future work. + cookbook/ — step-by-step guides: adding a package, a tool, + an LLM adapter. scripts/ repo maintenance scripts (vendor-manifest guard, publint runner). JS bundling is tsdown (root tsdown.config.ts + two per-package overrides in vendor/). @@ -53,6 +57,8 @@ yarn typecheck # tsc -b tsconfig.build.json (declarations only) yarn build # typecheck + tsdown JS bundles into each package's lib/ yarn demo # run examples/echo-agent (needs --expose-internals, the # script passes it; type "echo hi" to see a tool call) +yarn demo:coding # run examples/coding-agent — the real agent (needs + # DEEPSEEK_API_KEY; give it a coding task) ``` ## Secrets / .env @@ -98,13 +104,21 @@ unresolved-type `no-unsafe-*` errors. - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits. This is the veto mechanism — use deliberately. +- **Discriminated unions: match, don't chain**: branch on a tagged union + (`StreamChunk`, `FinishReason`, `SessionEvent`, …) with a `switch` on the + tag, not a chain of `if (x.kind === '…')`. The switch narrows each arm so + member-only fields (`finish.message`, `finish.code`) are reachable in the + right case and a typo'd tag fails to compile. Prefer extracting a small + typed helper (`finishError(finish: FinishReason)`) over inlining the + branches at the call site. - **Switch exhaustiveness**: switches over CLOSED unions (e.g. `StreamChunk`) end with `default: assertNever(value, 'context')` (from dsh-llm) so adding a variant breaks compilation at every switch that must handle it. Switches - over MERGE-EXTENSIBLE unions (`SessionEventMap`, `ContentBlockMap`, …) must - NOT use assertNever — plugin-added variants are valid unknown values; handle - known cases and fall through with a comment (the lint rule - `switch-exhaustiveness-check` makes the choice explicit either way). + over MERGE-EXTENSIBLE unions (`SessionEventMap`, `ContentBlockMap`, + `FinishReason`, …) must NOT use assertNever — plugin-added variants are + valid unknown values; handle known cases and fall through `default` with a + comment (the lint rule `switch-exhaustiveness-check` makes the choice + explicit either way; a redundant disable directive is itself a lint error). - **Plugins, not loop changes**: new behavior goes into a plugin on the documented extension seams (see the plugin sanity checklist in docs/architecture.md). Changing `agent-loop` requires updating that doc. @@ -136,6 +150,11 @@ unresolved-type `no-unsafe-*` errors. `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error. +- **Symmetry is usually more correct**: when two related values play parallel + roles (a test fixture and its expected output, a request shape and its + response shape, a buggy input and the test that checks the fix), give them + parallel form — both named consts, or both inline, not one each way. Asymmetry + is a smell that usually points at a missed extraction. - **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; @@ -143,6 +162,66 @@ unresolved-type `no-unsafe-*` errors. concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). +## Defensive patterns (hard-won) + +Each bullet is a bug class that bit us; the rule prevents the reoccurrence. + +- **Report orthogonal outcomes independently.** A result can be several + things at once (a process can both time out AND exit 0 because it trapped + the signal). Don't nest the report of one flag inside the branch of + another. Surface each independent fact (`timedOut`, `signal`, `exitCode`) + on its own so a caller never reads a cut-short run as a clean success. +- **Honor cross-seam contracts on BOTH sides.** When an interface documents + two valid ways to signal something (e.g. an adapter may report a model + failure by THROWING from `stream()` *or* by ending the stream with a + `finish {kind:'error'|'aborted'}` chunk), the consumer must handle both — + not just the one the first implementation happened to use. A library-backed + adapter that can't throw mid-stream relies on the finish-chunk path; if the + loop only catches throws, a provider 401 becomes a normal completed turn. + Document the contract where the type is defined and exercise every branch + through the real consumer in tests. +- **Async state is not synchronous state.** `agent.send()` does not flip + status to `running` before it returns; a background task's completion races + turn boundaries; `reader.close()` fires for both EOF and disposal. Never + gate control flow on a status you only *just* requested. Drive lifecycle off + the events/promises that actually fire (`agent/status`, `task.done`), and + when "done" needs a settle signal, observe the transition (saw `running` + THEN `idle`) rather than counting actions you assume map 1:1 to turns — + the loop batches queued messages into one turn. But a settle-signal guard + cuts both ways: if the awaited transition can *never* occur (EOF with no + work submitted → no turn ever starts → never `running`), it hangs forever. + Always handle the "nothing to wait for" branch explicitly alongside the + "wait for the work" branch. +- **Dispose must reach quiescence, not just request it.** A teardown that + issues kills/aborts but returns before the work stops leaves orphans. Make + cleanup `async` and `await` the children's exit (kill → await `done`), and + close listener/notification registries *before* killing so late completions + stay silent. Tests must prove disposal *waited* (pid already gone right + after `await fiber.dispose()`), not merely that the process eventually dies. +- **Contain callback exceptions at the boundary.** A user-supplied listener + (`onTaskDone`, event handlers) that throws must not reject the promise it + runs inside or starve the listeners after it. Wrap the dispatch loop in + try/catch and log; never let one bad subscriber break core lifecycle. +- **Never hand untrusted/model output the ambient environment or predictable + paths.** Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/ + `*TOKEN*`) so the harness's own credentials can't leak into output, `env`, + or spill files. Temp/spill files use a private (0700) dir, random names, + and exclusive owner-only (`'wx'`, `0o600`) opens — predictable + world-readable paths invite symlink races and disclosure. +- **e2e tests own their resources.** Real-API/integration tests must create + the harness in the test and dispose it in `afterEach` (even on + failure/retry/timeout), so a flaky run doesn't leak processes or contexts. + Shared fixtures live in a plain `tests/harness.ts` module, NOT another + `*.e2e.ts` file — importing a spec file re-registers its `describe` and + duplicates real API calls. Verify the WORLD, not the agent's self-report: + re-run the command/check externally and assert files are byte-identical + where they should be unchanged (a keyword probe lets a cheating agent pass). +- **Tag spelling and EOF hygiene.** cordis.yml interpolates env via the + `!!js` tag (js-yaml resolves custom tags under `tag:yaml.org,2002:js`), not + `!js` — keep code, comments, and docs consistent. Files end with exactly + one trailing newline; `git diff --check` (a pre-push gate) rejects new + blank lines at EOF. + ## Type Safety and Documentation This codebase aims to be **very type-safe and well documented** for diff --git a/docs/architecture.md b/docs/architecture.md index aa13870f0f..52820e4b87 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -371,14 +371,22 @@ export function apply(ctx: Context) { } ``` -A complete runnable wiring lives in [`examples/echo-agent`](../examples/echo-agent) -(mock model + echo tool + stdio UI + JSONL persistence, loaded from -`cordis.yml` with HMR). +Two complete runnable wirings exist: [`examples/echo-agent`](../examples/echo-agent) +(mock model + echo tool — the all-mock skeleton check) and +[`examples/coding-agent`](../examples/coding-agent) (DeepSeek V4 + the bash +tool suite — the real thing; `yarn demo:coding`). Both load from `cordis.yml` +with HMR. + +Step-by-step guides live in [`docs/cookbook`](./cookbook): adding a package, +adding a tool, adding an LLM adapter. ## Deferred work (TODO) Tracked here deliberately — each is designed-for but not implemented: +- **Restructure this document** — it has grown long; split it into focused + sections (or per-area files) so readers can navigate it without scrolling + the whole thing. - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. - **Persistence backends** (JSONL session dirs, sqlite) on the diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md new file mode 100644 index 0000000000..99d5d330e4 --- /dev/null +++ b/docs/cookbook/adding-a-package.md @@ -0,0 +1,55 @@ +# Cookbook: adding a workspace package + +The file-by-file checklist for a new `@deepseek-ai/dsh-` package. +(Verified by the bash and adapter packages; if it drifts, fix it here.) + +## 1. Create the package + +``` +packages// + package.json # copy from packages/tools, adjust name/description/deps + tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib, + # references: vendor/cosmokit, vendor/cordis (+ vendor/schemastery + # if you use Config, + ../ for each dsh dependency) + src/index.ts # service default export or plugin (name/inject/apply/Config) + tests/.spec.ts + README.md # service API, events, extension points, design notes +``` + +package.json invariants (enforced by `yarn constraints` / yarn.config.cjs): +`private: true`, `version: 0.0.1`, `type: module`, `cordis` in BOTH +peerDependencies and devDependencies (same range). Mirror every dsh peer +dependency in devDependencies. `schemastery` goes in `dependencies` (it is a +runtime validator), matching agent-loop. + +## 2. Register it in the root configs + +| File | Change | +|---|---| +| `tsconfig.base.json` | add `"@deepseek-ai/dsh-": ["./packages//src"]` to `paths` | +| `tsconfig.typecheck.json` | same entry (this file overrides the map wholesale) | +| `tsconfig.build.json` | add `{ "path": "./packages/" }` to `references` | +| `scripts/publint-all.ts` | add `'packages/'` to the array | +| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm-deepseek`) | + +Covered automatically by globs — no edits needed: root `package.json` +workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. + +## 3. Decide the package topology + +For a swappable capability, split interface / implementation / consumer into +separate packages (see docs/architecture.md § "Capability seams" — the bash +trio is the template). A single-purpose plugin stays one package. + +## 4. Verify + +```sh +yarn install # registers the workspace +yarn constraints && yarn typecheck && yarn lint +yarn test:coverage # 100% per-file over src (types.ts exempt) +yarn build && yarn knip && yarn publint +``` + +Test expectations: every registry/registration needs an HMR-safety test +(register from a child fiber, dispose it, assert cleanup). Excessive tests +are welcome — see AGENTS.md. diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md new file mode 100644 index 0000000000..d00556c26b --- /dev/null +++ b/docs/cookbook/adding-a-tool.md @@ -0,0 +1,77 @@ +# Cookbook: adding a tool + +How to give the model a new capability. Reference implementations: +`examples/echo-agent/src/echo-tool.ts` (minimal) and +`packages/tool-bash` (production-grade, three-package seam). + +## The minimal shape + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'my-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'read_file', + description: 'Read a file from disk.', // what the model sees + parameters: { + path: { type: 'string', required: true, description: 'Absolute path' }, + limit: { type: 'number' }, // optional by default + }, + async execute(args, exec) { + // args is TYPED from the schema: { path: string; limit?: number } + // exec carries { callId, name, arguments, agent?, signal? } + return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + }, + })) +} +``` + +Registration is effect-based: disposing the plugin fiber unregisters the +tool (write the HMR test). Schemas flow into the system-prompt assembly +automatically. + +## Rules of the execute() contract + +- **Validate args at runtime.** `defineTool`'s `InferArgs` typing is + compile-time only; at runtime `arguments` is whatever JSON the model + emitted. Check every field; throw a descriptive Error for bad input. +- **Throwing means isError.** The registry catches anything `execute()` + throws and returns `{isError: true}` to the model. Use that for + infrastructure failures (bad input, spawn errors, aborts) — but REPORT + domain failures in the result text instead (e.g. tool-bash returns + `[exit code: 9]` with `isError: false`: the model decides what a failing + command means). +- **Honor `exec.signal`.** Cancel in-flight work when it fires. +- **Use `exec.agent` for async notifications.** `agent.inject(content, + {source: {kind: 'plugin', plugin: ''}})` appends durable context the + NEXT model request sees — it is not a wake-up (an idle agent stays idle). + Guard against disposed agents (try/catch). + +## Long-running work + +Follow tool-bash's background pattern: a `run_in_background` flag returns a +task id immediately; companion tools poll incrementally and kill; completion +notices arrive via `agent.inject()`. Bound buffers and spill full output to +disk so nothing is silently lost. + +> TODO: each tool reimplements this background pattern by hand today. At some +> point we need a generic long-running-tool layer that handles task ids, +> incremental polling, kill, and completion notices uniformly. + +## Permissions / sandboxing + +Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall +(veto or wrap — see the permission-gate example in docs/architecture.md), or +a sandboxing implementation behind the tool's executor seam. + +## Tests every tool needs + +Arg-validation rejections, result shaping for every outcome, the HMR +disposal test, and — for tools with side effects — an integration spec that +drives the tool through the agent loop with a scripted `MockAdapter` +(`packages/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / +`tool/result` session events. diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md new file mode 100644 index 0000000000..361bca6f28 --- /dev/null +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -0,0 +1,75 @@ +# Cookbook: adding an LLM adapter + +How to connect a new model provider. Reference implementations: +`packages/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm-pi-ai` +(wrapping an LLM library). Read the `StreamChunk` doc in +`packages/llm/src/types.ts` first — it records the protocol conventions both +adapters were verified against. + +## The shape + +```ts +class MyAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { … } +} + +export const name = 'llm-myprovider' +export const inject = ['llm'] +export const Config: z = z.object({ apiKey: z.string(), … }) + +export function apply(ctx: Context, config: Config) { + ctx.llm.registerAdapter(['model-a', 'model-b'], new MyAdapter(…)) +} +``` + +Registration is effect-based (HMR-safe); one adapter per model name — +duplicates throw. Secrets are cordis-native: schemastery Config with env +fallbacks, fed from cordis.yml via `!!js process.env.MY_KEY`. Never read +ad-hoc key files in code. + +## Protocol obligations (the contract two implementations verified) + +- Emit `usage` BEFORE `finish`; emit NOTHING after `finish`. The robust way: + buffer finish/usage until the provider's end-of-stream marker, then flush + (handles providers that send trailing usage-only chunks). +- Tool-call `arguments` are RAW JSON strings end-to-end; stream fragments as + `argumentsDelta`. If your provider hands back parsed objects, re-stringify + at `block-end`. +- Allocate block `index`es in first-seen stream order; reuse the index for + every delta of the same block. +- Errors have exactly two sanctioned paths: THROW from `stream()` (transport + and protocol failures — use `LlmError` with a stable code), or end the + stream with `finish {kind: 'error' | 'aborted'}` (provider in-band + failures). Consumers handle both; pick per failure class and document it. +- Honor `options.signal` (pass it to fetch / your SDK). +- `prefill` and other unsupported `GenerateOptions` fields: throw + `LlmError(..., 'UNSUPPORTED')` rather than silently dropping. + +Provider-specific request knobs (thinking modes, effort levels) belong in +the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays +provider-neutral. + +## Structure that worked + +Split the adapter into testable stages (llm-deepseek's layout): wire types +(`types.ts`, coverage-exempt) → request serializer → SSE/transport parser → +chunk-translation state machine → a thin adapter class wiring them. Each +stage gets its own unit suite. + +## Testing + +- **Unit: mock the provider, not the harness.** A scripted `node:http` + server speaking the provider's wire format covers happy paths, every error + status, malformed payloads, premature closes, and aborts — no network, and + it drives the 100% per-file coverage gate. Works for SDK-backed adapters + too (point the SDK's baseURL at the mock). +- **Hostile framing tests.** Split stream payloads at arbitrary byte + positions (including mid-UTF-8) — real networks do. +- **E2E: `tests/*.e2e.ts`** under `yarn test:e2e`, gated with + `describe.skipIf(!process.env.MY_KEY)` so CI (no secrets) stays green. + Cover each model × each provider mode you map (thinking on/off, effort + levels), a tool-call round trip INCLUDING the follow-up turn with results + in history, and loose assertions only (substring/structure, bounded + maxTokens — real models are nondeterministic). +- Register the e2e file pattern in `knip.json` (per-workspace `entry` + override) or knip flags it unused. diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md new file mode 100644 index 0000000000..b9ce3ee7af --- /dev/null +++ b/examples/coding-agent/README.md @@ -0,0 +1,51 @@ +# coding-agent + +The first REAL agent wiring: DeepSeek V4 + the bash tool suite + stdio chat ++ JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the +skeleton with mocks, this example is a usable coding assistant. + +## Run it + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +yarn demo:coding +``` + +Type a coding task. The agent's only tools are `bash` (+ `bash_output` / +`bash_kill` for background tasks): file reads, writes, searches, and test +runs all happen through shell commands, each in a fresh `bash -c` (the +system prompt tells the model to pass `workdir` instead of `cd`). Reasoning +streams dimmed; tool calls/results render inline. + +``` +> fix the failing test in /path/to/project +[main turn 1] (reasoning…) + [tool call] bash({"command": "node --test", "workdir": "/path/to/project"}) + [tool result] … [exit code: 1] + … +``` + +## What each plugin demonstrates + +| Entry | Demonstrates | +|---|---| +| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | +| `bash` (`dsh-bash-local`) + `tool-bash` | the executor seam + tool schemas as separate plugins | +| `agent-loop` | agent created from config with a coding system prompt | +| `src/session-jsonl.ts` | write-behind persistence on `session/event` + `session/flush` (copied from echo-agent) | +| `src/stdio-chat.ts` | UI as a plugin; copied from echo-agent with reasoning-dimming and an exit-on-idle close handler for piped stdin. Example-local on purpose — extract a shared UI package when a third example needs it | + +## End-to-end tests (`yarn test:e2e`, key-gated) + +- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` + through the real bash tool; asserts `tool/call`/`tool/result` session + events and the final answer. +- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds + `add.js` (with `a - b` where `a + b` belongs) and a failing + `add.test.js`; the agent must fix the bug and verify. The test re-runs + `node add.test.js` ITSELF and inspects the files — agent claims are not + trusted. + +Both self-skip without `DEEPSEEK_API_KEY`. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml new file mode 100644 index 0000000000..96a6fef4b1 --- /dev/null +++ b/examples/coding-agent/cordis.yml @@ -0,0 +1,77 @@ +# The coding-agent plugin tree, loaded via @cordisjs/plugin-include. +# Core services first, then the real adapters/tools, then the agent itself. +# +# 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' + +- 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' + +# 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 + +# 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' + +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: + - id: main + model: deepseek-v4-flash + 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. + +- id: session-jsonl + name: './src/session-jsonl.ts' + +- id: stdio-chat + name: './src/stdio-chat.ts' diff --git a/examples/coding-agent/package.json b/examples/coding-agent/package.json new file mode 100644 index 0000000000..d92eeb6fdb --- /dev/null +++ b/examples/coding-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "coding-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable demo: a real coding agent — DeepSeek V4 + the bash tool suite" +} diff --git a/examples/coding-agent/src/session-jsonl.ts b/examples/coding-agent/src/session-jsonl.ts new file mode 100644 index 0000000000..8d6ff53285 --- /dev/null +++ b/examples/coding-agent/src/session-jsonl.ts @@ -0,0 +1,36 @@ +import { appendFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +export const name = 'session-jsonl' +export const inject = ['sessions'] + +/** + * Minimal persistence plugin: buffers session events (write-behind) and + * drains to a JSONL file at every `session/flush` checkpoint — the pattern a + * real JSONL/sqlite persistence plugin would follow. + */ +export function apply(ctx: Context) { + const buffers = new Map() + const path = (session: Session) => join(import.meta.dirname, '..', `${session.id}.jsonl`) + + ctx.on('session/event', (session, event) => { + let buffer = buffers.get(session) + if (!buffer) buffers.set(session, buffer = []) + buffer.push(event) + }) + + const flush = async (session: Session) => { + const buffer = buffers.get(session) + if (!buffer?.length) return + const lines = buffer.splice(0).map(event => JSON.stringify(event) + '\n').join('') + await appendFile(path(session), lines) + } + + ctx.on('session/flush', flush) + ctx.effect(() => () => { + // drain remaining buffers on dispose + for (const session of buffers.keys()) void flush(session) + }, 'session-jsonl') +} diff --git a/examples/coding-agent/src/stdio-chat.ts b/examples/coding-agent/src/stdio-chat.ts new file mode 100644 index 0000000000..c8ee8265ce --- /dev/null +++ b/examples/coding-agent/src/stdio-chat.ts @@ -0,0 +1,119 @@ +import { createInterface } from 'node:readline' +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-agent' + +export const name = 'stdio-chat' +export const inject = ['agents'] + +// Copied from examples/echo-agent (welcome text + reasoning rendering +// adjusted). Deliberately example-local rather than a shared package — two +// examples don't justify the abstraction yet; revisit at the third. + +/** + * Minimal UI plugin: reads lines from stdin → agent.send(); renders the + * agent's stream chunks and tool activity to stdout. Demonstrates that a UI + * is "just a plugin" — it only consumes the agent/* event taxonomy. + */ +export function apply(ctx: Context) { + let inReasoning = false + ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => { + if (chunk.type === 'reasoning-delta') { + // Dim the chain-of-thought so the answer stands out. + if (!inReasoning) process.stdout.write('\x1B[2m') + inReasoning = true + process.stdout.write(chunk.text) + } else if (chunk.type === 'text-delta') { + if (inReasoning) process.stdout.write('\x1B[0m\n') + inReasoning = false + process.stdout.write(chunk.text) + } + }) + + ctx.on('agent/turn-start', (agent, turn) => { + process.stdout.write(`\n[${agent.id} turn ${turn}] `) + }) + + ctx.on('agent/turn-end', () => { + if (inReasoning) process.stdout.write('\x1B[0m') + inReasoning = false + process.stdout.write('\n> ') + }) + + ctx.on('session/event', (_session, event) => { + if (event.type === 'tool/call') { + const { name: toolName, arguments: args } = event.data + if (inReasoning) process.stdout.write('\x1B[0m') + inReasoning = false + process.stdout.write(`\n [tool call] ${toolName}(${args})`) + } else if (event.type === 'tool/result') { + const { content } = event.data + const text = content.filter(b => b.type === 'text').map(b => b.text).join('') + process.stdout.write(`\n [tool result] ${text}\n `) + } + }) + + ctx.effect(() => { + const reader = createInterface({ input: process.stdin }) + // Piped-input exit, once stdin reaches EOF: + // - If no line ever submitted work (empty stdin, blank-only lines), exit + // immediately — no turn will ever start, so there is nothing to wait + // for. (Gating on an observed 'running' here would hang forever.) + // - If work WAS submitted, exit the next time the agent settles to idle + // AFTER having run. Two subtleties this handles: the loop batches + // several queued messages into ONE turn (one idle), so we don't count + // sends; and agent.send() does NOT synchronously flip status to + // 'running', so requiring an observed 'running' first (`sawRunning`) + // avoids exiting in the gap before the turn starts and dropping work. + let stdinClosed = false + let disposed = false + let submittedWork = false + let sawRunning = false + + const maybeExit = (): void => { + if (disposed || !stdinClosed) return + // No work submitted: nothing will ever run, exit straight away. + // Work submitted: wait until a turn has run and the agent is idle. + if (submittedWork) { + if (!sawRunning) return + const agent = ctx.agents.get('main') + if (agent && agent.status !== 'idle') return // a turn is still running + } + // Let any final output flush, then exit. + setTimeout(() => process.exit(0), 200) + } + + const disposeStatusListener = ctx.on('agent/status', (subject, status) => { + if (subject.id !== 'main') return + if (status === 'running') sawRunning = true + if (status === 'idle') maybeExit() + }) + + reader.on('line', (line) => { + const text = line.trim() + if (!text) return + const agent = ctx.agents.get('main') + if (!agent) { + console.error('agent "main" is not running') + return + } + submittedWork = true + if (agent.status === 'running') { + agent.steer([{ type: 'text', text }]) + } else { + agent.send([{ type: 'text', text }]) + } + }) + reader.on('close', () => { + // Fires for BOTH stdin EOF and plugin disposal (reader.close() below); + // `disposed` guards teardown so HMR/dispose never exits the process. + stdinClosed = true + maybeExit() + }) + process.stdout.write('coding-agent ready. Give it a coding task (bash is its only tool).\n> ') + return () => { + disposed = true + disposeStatusListener() + reader.close() + } + }, 'stdio-chat') +} diff --git a/examples/coding-agent/start.ts b/examples/coding-agent/start.ts new file mode 100644 index 0000000000..c0920e8d3d --- /dev/null +++ b/examples/coding-agent/start.ts @@ -0,0 +1,25 @@ +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. +try { + process.loadEnvFile(new URL('../../.env', import.meta.url).pathname) +} catch { + // no .env — 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/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts new file mode 100644 index 0000000000..4684301725 --- /dev/null +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -0,0 +1,86 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' + +/** + * The swebench-style smoke test: a real model fixes a real bug in a temp + * directory using only the bash tool, and the fix is verified OUTSIDE the + * agent by re-running the test script. Key-gated. + */ + +const TEST_FILE = [ + "const assert = require('node:assert');", + "const { add } = require('./add.js');", + 'assert.strictEqual(add(2, 3), 5);', + 'assert.strictEqual(add(-1, 1), 0);', + "console.log('PASS');", + '', +].join('\n') + +const BUGGY_ADD = [ + '// A tiny module with an obvious bug.', + 'function add(a, b) {', + ' return a - b;', + '}', + 'module.exports = { add };', + '', +].join('\n') + +let workdir: string | undefined +let ctx: Context | undefined + +afterEach(async () => { + // Dispose the harness even on failure/retry: agent-loop teardown stops the + // loop and LocalBashExecutor teardown kills anything the model left running. + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test via bash', () => { + it('repairs add.js so node add.test.js passes', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-coding-task-')) + await writeFile(join(workdir, 'add.js'), BUGGY_ADD) + await writeFile(join(workdir, 'add.test.js'), TEST_FILE) + + // Confirm the fixture actually fails before the agent touches it. + const before = spawnSync('node', ['add.test.js'], { cwd: workdir }) + expect(before.status).not.toBe(0) + + ctx = await codingHarness(workdir) + const agent = ctx.agentLoop.create('e2e-task', { + model: 'deepseek-v4-flash', + systemPrompt: SYSTEM_PROMPT, + }) + + agent.send([{ + type: 'text', + text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' + + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' + + 'Do not modify add.test.js.', + }]) + await waitForIdle(ctx, agent) + + // The agent claims success… + const summary = finalText([...agent.session.events]).toLowerCase() + expect(summary.length).toBeGreaterThan(0) + + // …and the world agrees: the test passes when WE run it, and the test + // file is byte-identical (an agent that neutered the test instead of + // fixing the bug fails here, not just on a keyword probe). + const untouchedTest = await readFile(join(workdir, 'add.test.js'), 'utf8') + expect(untouchedTest).toBe(TEST_FILE) + + const after = spawnSync('node', ['add.test.js'], { cwd: workdir, encoding: 'utf8' }) + expect(after.stdout).toContain('PASS') + expect(after.status).toBe(0) + + const fixed = await readFile(join(workdir, 'add.js'), 'utf8') + expect(fixed).not.toMatch(/a\s*-\s*b/) + }, 180_000) +}) diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts new file mode 100644 index 0000000000..93bc0b1fac --- /dev/null +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' + +/** + * The first place a REAL model meets the REAL bash tool: the cheap canary + * before the coding-task e2e. Key-gated (see vitest.e2e.config.ts). + */ + +let ctx: Context | undefined + +afterEach(async () => { + // Always dispose the harness, even on failure/retry/timeout: agent-loop + // teardown stops the loop and LocalBashExecutor teardown kills any + // process the model left behind. + await ctx?.fiber.dispose() + ctx = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => { + it('runs a bash command on request and reports its output', async () => { + ctx = await codingHarness(process.cwd()) + const agent = ctx.agentLoop.create('e2e-loop', { + model: 'deepseek-v4-flash', + systemPrompt: SYSTEM_PROMPT, + }) + + agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + const calls = events.filter(event => event.type === 'tool/call') + expect(calls.length).toBeGreaterThan(0) + expect(calls.some(event => event.data.name === 'bash')).toBe(true) + + const results = events.filter(event => event.type === 'tool/result') + const resultTexts = results.flatMap(event => + event.data.content.filter(block => block.type === 'text').map(block => block.text)) + expect(resultTexts.some(text => text.includes('e2e-ok'))).toBe(true) + + expect(finalText(events)).toContain('e2e-ok') + }, 120_000) +}) diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts new file mode 100644 index 0000000000..db89b2c183 --- /dev/null +++ b/examples/coding-agent/tests/harness.ts @@ -0,0 +1,55 @@ +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionEvent } 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 AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' + +/** + * Shared harness for the coding-agent e2e suites: the full plugin stack + * with the real DeepSeek adapter and the real bash tool. Lives outside the + * *.e2e.ts pattern so importing it never re-registers another file's tests. + */ + +export const SYSTEM_PROMPT = 'You are a coding agent. Your only tool is bash; ' + + 'do file operations with cat/grep/heredocs, check [exit code: N] markers, ' + + 'and report results briefly.' + +export async function codingHarness(workdir: string): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) + await ctx.plugin(ToolBash) + return ctx +} + +export function waitForIdle(ctx: Context, agent: LoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +export function finalText(events: SessionEvent[]): string { + const message = events.findLast(event => event.type === 'assistant/message') + if (message?.type !== 'assistant/message') return '' + return message.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} diff --git a/knip.json b/knip.json index ec8336b13c..273ff31665 100644 --- a/knip.json +++ b/knip.json @@ -4,7 +4,7 @@ "ignoreWorkspaces": ["vendor/*"], "workspaces": { ".": { - "entry": ["examples/echo-agent/src/*.ts"], + "entry": ["examples/echo-agent/src/*.ts", "examples/coding-agent/src/*.ts"], "project": ["scripts/**/*.ts", "examples/**/*.ts"] }, "packages/*": { diff --git a/package.json b/package.json index 5750d4e6e6..6d389e0ae5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "publint": "tsx scripts/publint-all.ts", "hygiene": "yarn knip && yarn publint && yarn constraints", "demo": "node --expose-internals --import tsx examples/echo-agent/start.ts", + "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", "postinstall": "lefthook install" }, "devDependencies": { diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 0602f0ba5d..7ae0d0bd17 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -25,10 +25,15 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], test: { include: ['packages/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], - // Real model calls: generous timeouts, one retry for transient flakes, - // no coverage (unit suites own the coverage gate). + // Real model calls: generous timeouts, and retries for transient flakes + // (the shared internal key hits concurrency quotas). No coverage — the + // unit suites own the coverage gate. testTimeout: 120_000, hookTimeout: 30_000, - retry: 1, + retry: 2, + // Run e2e files one at a time: the shared internal API key has a small + // concurrency quota, and parallel files issue enough simultaneous requests + // to trip it (manifesting as flaky rate-limit errors). + fileParallelism: false, }, })