From 21456a36cafce19bfc088720140e481e5abf59fe Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 16:36:48 +0800 Subject: [PATCH] docs: add hands-on Cordis tutorial Seven-chapter tutorial under docs/cordis-tutorial/ for agent developers new to Cordis: first plugin, lifecycle/effects, services, events, config, composition/HMR, and a final chapter registering a tool against real harness services. Every transcript was produced by running the chapter files in a gitignored tmp/ scratch directory. Published to both website locales as mirrored English pages under a new 'Cordis tutorial' develop-sidebar section; a Chinese pair can be added later without route changes. --- .../2026-07-22-cordis-tutorial-docs.i18n.yaml | 6 + .../2026-07-22-cordis-tutorial-docs.md | 32 ++++ .../2026-07-22-cordis-tutorial-docs.zh.md | 32 ++++ docs/cordis-primer.md | 2 +- docs/cordis-tutorial/01-first-plugin.md | 91 ++++++++++++ .../02-lifecycle-and-effects.md | 89 +++++++++++ docs/cordis-tutorial/03-services.md | 94 ++++++++++++ docs/cordis-tutorial/04-events.md | 140 ++++++++++++++++++ docs/cordis-tutorial/05-config.md | 84 +++++++++++ .../cordis-tutorial/06-composition-and-hmr.md | 105 +++++++++++++ docs/cordis-tutorial/07-into-the-harness.md | 101 +++++++++++++ docs/cordis-tutorial/index.md | 54 +++++++ website/.vitepress/config.ts | 2 + website/docs.ts | 21 +++ 14 files changed, 852 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md create mode 100644 .agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md create mode 100644 docs/cordis-tutorial/01-first-plugin.md create mode 100644 docs/cordis-tutorial/02-lifecycle-and-effects.md create mode 100644 docs/cordis-tutorial/03-services.md create mode 100644 docs/cordis-tutorial/04-events.md create mode 100644 docs/cordis-tutorial/05-config.md create mode 100644 docs/cordis-tutorial/06-composition-and-hmr.md create mode 100644 docs/cordis-tutorial/07-into-the-harness.md create mode 100644 docs/cordis-tutorial/index.md diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml new file mode 100644 index 0000000000..b2aa854cf3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-22-cordis-tutorial-docs.md: 45abb8f524218ce0b2678606ae62c7bf2dbca00b +2026-07-22-cordis-tutorial-docs.zh.md: cd1a62e2a6e7f2e28bc32109dd67746840f8b8b7 diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md new file mode 100644 index 0000000000..45abb8f524 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md @@ -0,0 +1,32 @@ +# Agent Note: Tutorial-style Cordis docs under docs/cordis-tutorial + +Status: implemented + +English | [中文](2026-07-22-cordis-tutorial-docs.zh.md) + +## Problem + +The repo documents Cordis at two levels: the condensed [cordis-primer](../../../../docs/cordis-primer.md) states the concepts, and the `docs/user/develop/` pages teach harness plugin authoring against harness services. Neither serves a developer meeting Cordis itself for the first time: the primer assumes the reader already writes plugins, and the develop pages jump straight to `defineTool` without showing how contexts, fibers, services, and dispatch actually behave. There was no path where a reader runs bare Cordis, watches a fiber go PENDING, or sees a waterfall veto happen. + +## Decision + +`docs/cordis-tutorial/` holds a seven-chapter hands-on tutorial (first plugin → lifecycle/effects → services → events → config → composition/HMR → harness tool). Its properties, in decreasing order of load-bearing-ness: + +- **Every transcript is real.** Each chapter's files run in the gitignored `tmp/cordis-tutorial/` scratch directory via `node --import tsx ../../vendor/cordis/bin.js`, and the shown output is what those commands print. The chapter that uses harness packages (`@deepseek-ai/dsh-tools` and `@deepseek-ai/dsh-llm`) runs keylessly. +- **dsh-flavored, not pure Cordis**: later chapters use real harness services and events (`ctx.tools`, `tools/result`) so the tutorial lands the reader inside this repo's actual composition model, per the requesting user's choice. +- **English-only, published to both website locales** through `mirroredPages()` in [website/docs.ts](../../../../website/docs.ts) under a `Cordis 教程` / `Cordis tutorial` section of the develop sidebar — the same pattern as the reference pages, so a Chinese pair can ratchet in later without route changes. +- Code fences compile under `doc-typecheck` except the two fences that import scratch-relative files (`./stats.ts`) or intentionally throw, which carry `ignore-check`. + +## Alternatives considered + +**Under `docs/user/develop/` as paired product docs.** That tier requires en+zh+i18n records in the same PR, roughly doubling the change and coupling every future tutorial edit to a translation. Rejected for the first landing; the mirrored projection keeps the same public visibility. + +**Pure-Cordis tutorial with no harness packages.** Cleaner as framework documentation, but the audience is agent developers extending this harness; ending at `ctx.tools.execute` and `tools/result` teaches the composition they will actually work in. The user chose this explicitly. + +**Extending the primer instead of a new directory.** The primer is a 600-word budgeted concept reference; a multi-chapter walkthrough inside it would break its tier's job (and its budget) rather than complement it. + +## Consequences + +- A runnable introduction to Cordis exercises the loader, fiber states, effects, service injection, all five dispatch-mode contracts, Schemastery validation, and HMR. It demonstrates PENDING dependencies and validation failure; it explains the loader's logged unresolved-entry failure because that boot-time log may not reach a console exporter. +- The tutorial's transcripts pin behavior informally but are not snapshot-gated; if loader or HMR behavior changes, the transcripts drift until a human replays the chapters. The compile gate covers only the code fences. +- The chapters name concrete harness APIs (`ctx.tools.execute`, `CallId`, `tools/result`); renames must update the tutorial like any other doc reference (`verify-md-links` catches file moves, not API prose). diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md new file mode 100644 index 0000000000..cd1a62e2a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md @@ -0,0 +1,32 @@ +# Agent Note: `docs/cordis-tutorial` 下的 Cordis 实操教程文档 + +Status: implemented + +[English](2026-07-22-cordis-tutorial-docs.md) | 中文 + +## 问题 + +本仓库从两个层面介绍 Cordis:精简的 [cordis-primer](../../../../docs/cordis-primer.md) 阐述概念,`docs/user/develop/` 下的页面则讲解如何基于 harness 服务编写 harness 插件。但二者都不适合初次接触 Cordis 的开发者:primer 假定读者已经会编写插件,开发页面则直接从 `defineTool` 讲起,没有展示上下文、fiber、服务和 dispatch 的实际行为。此前没有一条学习路径让读者运行原生 Cordis、观察 fiber 进入 PENDING 状态,或看到 waterfall(瀑布式事件)否决实际发生。 + +## 决策 + +`docs/cordis-tutorial/` 包含一套七章实操教程(第一个插件 → 生命周期与 effect → 服务 → 事件 → 配置 → 组合与 HMR(热模块替换)→ harness 工具)。以下是教程的特性,按重要性从高到低排列: + +- **每段 transcript(文本记录)都真实可复现。** 每章文件都通过 `node --import tsx ../../vendor/cordis/bin.js` 在 git 忽略的 `tmp/cordis-tutorial/` 临时目录中运行,展示的输出就是这些命令实际打印的内容。使用 harness 包(package)(`@deepseek-ai/dsh-tools` 和 `@deepseek-ai/dsh-llm`)的章节无需密钥即可运行。 +- **采用 dsh 风格,而非纯 Cordis**:后续章节使用真实的 harness 服务和事件(`ctx.tools`、`tools/result`),使读者最终进入本仓库实际采用的组合模型,这遵循了提出请求的用户所作的选择。 +- **仅提供英文版,但发布到网站的两个语言区域**:通过 [website/docs.ts](../../../../website/docs.ts) 中的 `mirroredPages()`,发布到开发侧边栏的 `Cordis 教程` / `Cordis tutorial` 分区。该方式与参考页面采用的模式相同,因此日后可以逐步纳入中文配对,而无需更改路由。 +- 除两个围栏代码块外,其余代码块均通过 `doc-typecheck` 编译;这两个例外分别导入临时目录中的相对路径文件(`./stats.ts`)或有意抛出异常,因此标有 `ignore-check`。 + +## 考虑过的替代方案 + +**作为双语产品文档放在 `docs/user/develop/` 下。** 该层级要求在同一个 PR(Pull Request)中同时提供英文、中文和 i18n 记录,这会使变更量大致翻倍,并要求未来每次修改教程时都同步翻译。首次落地不采用此方案;镜像投影仍可保持同等的公开可见性。 + +**不使用任何 harness 包的纯 Cordis 教程。** 作为框架文档会更简洁,但目标读者是扩展此 harness 的 agent(智能体)开发者;以 `ctx.tools.execute` 和 `tools/result` 收尾,能讲清他们实际使用的组合方式。用户明确选择了此方案。 + +**扩充 primer,而非新建目录。** primer 是一份预算上限为 600 词的精简概念参考;在其中加入多章演练会破坏该文档层级的职责及其篇幅预算,而非形成补充。 + +## 结果 + +- 现在有了一份可运行的 Cordis 入门教程,涵盖 loader、fiber 状态、effect、服务注入、全部五种 dispatch 模式的契约、Schemastery 校验和 HMR。教程实际展示了依赖处于 PENDING 状态和配置校验失败;对于 loader 记录的配置项解析失败,教程只作说明,因为启动阶段的日志可能无法到达控制台导出器。 +- 教程中的 transcript 以非正式方式固定了行为,但没有快照门禁;如果 loader 或 HMR 的行为发生变化,transcript 会逐渐偏离实际结果,直到有人重新运行各章。编译门禁只覆盖围栏代码块。 +- 各章写明了具体的 harness API(`ctx.tools.execute`、`CallId`、`tools/result`);这些 API 重命名时,必须像更新其他文档引用一样同步修改教程(`verify-md-links` 能发现文件移动,但无法发现 API 文字引用变化)。 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index d8e92dcb67..64230564b7 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -1,6 +1,6 @@ # Cordis Primer -Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md). +Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; the [Cordis tutorial](cordis-tutorial/index.md) walks the same ideas hands-on. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md). ## Cordis In Five Ideas diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md new file mode 100644 index 0000000000..468683a71d --- /dev/null +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -0,0 +1,91 @@ +# 1. Your first plugin + +In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes. + +## Write the plugin + +In your `tmp/cordis-tutorial` directory (see [setup](index.md#setup)), create `hello.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'hello' + +export function apply(ctx: Context) { + console.log('hello from my first plugin') +} +``` + +The `name` export is optional display metadata; it labels the plugin in diagnostics. + +## Compose the app + +This tutorial's launcher assembles the application from configuration. Create `cordis.yml`: + +```yaml +- name: './hello.ts' +``` + +The file is a list of plugin entries. `name` is a module specifier — a relative path or an npm package name — and the loader mounts each entry in order. + +## Run it + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +Expected output: + +``` +hello from my first plugin +``` + +The process exits on its own once nothing is left running. What happened: + +1. The launcher created a root `Context` and mounted the **Loader** plugin. +2. The Loader read `cordis.yml`, resolved `./hello.ts`, and mounted it as a child plugin. +3. Cordis called your `apply(ctx)`. + +There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../examples/tui-agent/cordis.yml), for example, is a longer plugin composition. + +## The two other plugin shapes + +A function is the most common shape, but Cordis accepts three: + +```ts +import { Service, type Context } from 'cordis' + +// 1. Function plugin (what you just wrote). +export function apply(ctx: Context) {} + +// 2. Object plugin: an object with an `apply` method. +export const objectPlugin = { + name: 'object-plugin', + apply(ctx: Context) {}, +} + +// 3. Class plugin: a Service subclass (covered in chapter 3). +export class MyService extends Service { + constructor(ctx: Context) { + super(ctx, 'myTutorialService') + } +} +``` + +Use the function form until you need to expose a service; [chapter 3](03-services.md) covers when the class form earns its place. + +## Try breaking it + +Make `apply` throw: + +```ts ignore-check +export function apply(ctx: Context) { + throw new Error('apply exploded') +} +``` + +Run again: the process dies with your error. A plugin that fails to load is a loud failure, not a skipped entry. + +One caveat worth knowing early: a config entry whose module cannot be **resolved** — a typo'd path or package name — is reported through the Cordis logger service instead of crashing the process, and at boot that report can be lost before a console exporter is watching. If a freshly added entry seems to do nothing, check the spelling first. + +Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md new file mode 100644 index 0000000000..9763c0e2e6 --- /dev/null +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -0,0 +1,89 @@ +# 2. Lifecycle and effects + +A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`. + +## Effects + +For a resource Cordis does not already manage — a timer, a connection, a watcher — wrap it in `ctx.effect()` and return a disposer: + +Create `lifecycle.ts` in `tmp/cordis-tutorial`: + +```ts +import type { Context } from 'cordis' + +export const name = 'lifecycle-demo' + +function heartbeat(ctx: Context) { + console.log('heartbeat plugin loading') + ctx.effect(() => { + const timer = setInterval(() => console.log('tick'), 200) + return () => { + clearInterval(timer) + console.log('heartbeat cleaned up') + } + }) +} + +export function apply(ctx: Context) { + // Mount a child plugin and keep its fiber to dispose it later. + const fiber = ctx.plugin(heartbeat) + setTimeout(async () => { + await fiber.dispose() + console.log('disposed') + process.exit(0) + }, 700) +} +``` + +Point `cordis.yml` at it: + +```yaml +- name: './lifecycle.ts' +``` + +Run (`node --import tsx ../../vendor/cordis/bin.js`) and you get: + +``` +heartbeat plugin loading +tick +tick +tick +heartbeat cleaned up +disposed +``` + +Three things to notice: + +- `ctx.plugin(heartbeat)` mounts a plugin **from code** — the same operation the YAML loader performs for each config entry. It returns a **fiber**, the runtime handle for one loaded plugin instance. +- The effect body runs during load; the disposer it returns runs during unload. You never call the disposer yourself for a plugin-lifetime resource. +- `fiber.dispose()` resolves after all of the plugin's cleanup — including async disposers — has finished, and recursively unloads any child plugins it mounted. + +## The fiber state machine + +Every loaded plugin instance owns a fiber that moves through these states: + +``` +PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED + ↘ FAILED +``` + +- **PENDING** — declared, but a required service (chapter 3) is not available yet. +- **LOADING / ACTIVE** — `apply` is running / has completed. +- **FAILED** — `apply` or config validation threw. +- **UNLOADING / DISPOSED** — disposers are running / everything is torn down. + +You will meet PENDING again in [chapter 6](06-composition-and-hmr.md), where it is the usual answer to "why does my plugin print nothing?". + +## What is already an effect + +You rarely write `ctx.effect()` yourself, because the built-in registration APIs are effects already: + +- `ctx.on(event, listener)` — the listener is removed on unload ([chapter 4](04-events.md)). +- `ctx.plugin(child)` — the child is disposed with its parent. +- Service registrations are effects. Harness registries such as `ctx.tools.register(...)` also attach their returned disposers to the calling plugin, so they unwind automatically ([chapter 7](07-into-the-harness.md)). + +For a resource Cordis does not manage, acquire it inside `ctx.effect()` and return a disposer that releases it. Cordis then invokes that release during unloading, including hot reload. + +One ordering caveat: disposers start in reverse registration order, but multiple **async** disposers run concurrently. If teardown steps must run in sequence, keep them in one disposer and await them there. + +Next: [Services](03-services.md) — how plugins share capabilities. diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md new file mode 100644 index 0000000000..a3538d1a9e --- /dev/null +++ b/docs/cordis-tutorial/03-services.md @@ -0,0 +1,94 @@ +# 3. Services + +A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer. + +## Provide a service + +Create `greeter.ts` in `tmp/cordis-tutorial`: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + greeter: GreeterService + } +} + +export class GreeterService extends Service { + constructor(ctx: Context) { + super(ctx, 'greeter') + } + + greet(who: string) { + return `Hello, ${who}!` + } +} + +export const name = 'greeter' + +export function apply(ctx: Context) { + ctx.plugin(GreeterService) +} +``` + +Two pieces work together: + +- **Runtime**: `super(ctx, 'greeter')` registers the instance under the name `greeter`. From then on, any plugin can reach it as `ctx.greeter`. The registration is an effect — unloading the provider removes the service. +- **Compile time**: the `declare module 'cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety. + +A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx.plugin(GreeterService)` mounts it like any other. + +## Consume a service with `inject` + +Create `consumer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'consumer' +export const inject = ['greeter'] + +export function apply(ctx: Context) { + console.log(ctx.greeter.greet('world')) +} +``` + +`inject` lists the services this plugin requires. Cordis holds the plugin in PENDING until every listed service exists, so inside `apply`, `ctx.greeter` is guaranteed ready. Load order in `cordis.yml` does not matter — dependencies, not file order, decide when plugins start. + +Compose and run: + +```yaml +- name: './greeter.ts' +- name: './consumer.ts' +``` + +``` +Hello, world! +``` + +Swap the two lines in `cordis.yml` and rerun: same output. Try removing `./greeter.ts` entirely: the consumer stays PENDING and prints nothing — no crash, no partial run. [Chapter 6](06-composition-and-hmr.md) shows how to diagnose that state. + +## Dependencies are live + +`inject` is not a one-shot boot check. If a required service disappears while the app runs — its provider was unloaded or hot-replaced — every dependent plugin is unloaded too, and loads again when the service returns. Combined with effects ([chapter 2](02-lifecycle-and-effects.md)), this prevents a running consumer from retaining a reference to an unavailable service: its own registrations are unwound when the dependency disappears. + +This is also why service replacement works in config: unload the `dsh-bash-local` entry, mount a different `bash` provider, and every plugin injecting `'bash'` cleanly restarts against the new implementation. + +## Optional dependencies + +`inject` is for hard requirements. For a capability the plugin can live without, skip `inject` and probe at the use site: + +```ts ignore-check +export function apply(ctx: Context) { + // undefined when no provider is loaded; the plugin still runs. + const greeter = ctx.get('greeter') + console.log(greeter?.greet('maybe') ?? 'no greeter available') +} +``` + +## Naming + +Service names live in one flat namespace per application. Prefix or namespace your own services distinctively (the harness claims plain names like `tools` and `llm`); the generated [services catalog](../cordis-catalog/services.md) lists every name the harness registers. + +Next: [Events](04-events.md) — communication without a shared service. diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md new file mode 100644 index 0000000000..5646d86a0c --- /dev/null +++ b/docs/cordis-tutorial/04-events.md @@ -0,0 +1,140 @@ +# 4. Events + +Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions. + +## Declare, emit, listen + +Create `stats.ts` in `tmp/cordis-tutorial` — a service that counts things and announces each change: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + stats: StatsService + } + interface Events { + 'stats/report'(name: string, count: number): void + } +} + +export class StatsService extends Service { + private counts = new Map() + + constructor(ctx: Context) { + super(ctx, 'stats') + } + + bump(name: string) { + const next = (this.counts.get(name) ?? 0) + 1 + this.counts.set(name, next) + this.ctx.emit('stats/report', name, next) + } +} + +export const name = 'stats' + +export function apply(ctx: Context) { + ctx.plugin(StatsService) +} +``` + +The `interface Events` merge is the event-system twin of the `interface Context` merge from chapter 3: it declares the event name and its listener signature, so `ctx.emit` and `ctx.on` are fully typed. The `namespace/action` naming convention keeps the flat event namespace readable. + +Create `reporter.ts`: + +```ts ignore-check +import type { Context } from 'cordis' +import type {} from './stats.ts' + +export const name = 'reporter' +export const inject = ['stats'] + +export function apply(ctx: Context) { + ctx.on('stats/report', (name, count) => { + console.log(`[stats] ${name} -> ${count}`) + }) + ctx.stats.bump('tool_call') + ctx.stats.bump('tool_call') + ctx.stats.bump('prompt') +} +``` + +The `import type {} from './stats.ts'` line imports nothing at runtime; it exists so TypeScript sees the declaration merges. Compose and run: + +```yaml +- name: './stats.ts' +- name: './reporter.ts' +``` + +``` +[stats] tool_call -> 1 +[stats] tool_call -> 2 +[stats] prompt -> 1 +``` + +Because `ctx.on()` is an effect, the listener disappears with the plugin — no manual `removeListener` bookkeeping, ever. + +## Dispatch modes + +`emit` is one of five dispatch modes. Which one an event uses is part of its contract — it decides whether listeners can return values, run concurrently, or veto each other: + +| Mode | Call | Semantics | +|---|---|---| +| emit | `ctx.emit(name, ...args)` | Synchronous broadcast; returned promises and values are not awaited or collected. | +| parallel | `await ctx.parallel(name, ...args)` | All listeners run concurrently; awaited together. | +| serial | `await ctx.serial(name, ...args)` | Listeners run in order, awaited; the first non-`null`/`false`/`undefined` return wins and stops the rest. | +| bail | `ctx.bail(name, ...args)` | Synchronous version of serial. | +| waterfall | `ctx.waterfall(name, ...args, next)` | Around-middleware; see below. | + +Every harness event documents its mode in the generated [events catalog](../cordis-catalog/events.md). + +## Waterfall: transform or veto + +Waterfall is the mode that powers interception. Each listener receives the arguments plus a `next()` continuation; it can transform what `next()` returns, or refuse to call `next()` at all — the veto. Create `waterfall-demo.ts`: + +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'demo/transform'(input: string, next: () => Promise): Promise + } +} + +export const name = 'waterfall-demo' + +export function apply(ctx: Context) { + // Listener 1: wrap the downstream result. + ctx.on('demo/transform', async (input, next) => { + const downstream = await next() + return downstream.toUpperCase() + }) + + // Listener 2: veto when it owns the decision. + ctx.on('demo/transform', async (input, next) => { + if (input.includes('blocked')) return '** vetoed **' + return next() + }) + + void (async () => { + console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello')) + console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words')) + })() +} +``` + +Point `cordis.yml` at just this file and run: + +``` +HELLO +** VETOED ** +``` + +Walk through the second line: listener 1 runs first, calls `next()`, which invokes listener 2; listener 2 sees `blocked` and returns without calling `next()` — the innermost default (the function passed to `ctx.waterfall`) never runs — and listener 1 uppercases the veto message on the way out. + +The discipline that follows: **a waterfall listener that only observes or annotates must call `next()`**; returning without it is a deliberate veto. Forgetting `next()` in a logging listener silently swallows the default behavior for everyone downstream. This is important enough that it is a standing rule of this repository ([waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)). + +The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: [`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) lets a plugin replace the model-call config, and [`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) lets a policy answer instead of the user. + +Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md new file mode 100644 index 0000000000..60ee2f719d --- /dev/null +++ b/docs/cordis-tutorial/05-config.md @@ -0,0 +1,84 @@ +# 5. Configuration + +Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured. + +## A configurable plugin + +Create `config-demo.ts` in `tmp/cordis-tutorial`: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'config-demo' + +export interface Config { + greeting: string + targets: string[] +} + +export const Config: Schema = Schema.object({ + greeting: Schema.string().default('Hello'), + targets: Schema.array(String).default(['world']), +}) + +export function apply(ctx: Context, config: Config) { + for (const target of config.targets) { + console.log(`${config.greeting}, ${target}!`) + } +} +``` + +The exported `Config` is both a TypeScript interface and a runtime schema with the same name — consumers get the type, Cordis gets the validator. This repo uses [Schemastery](https://github.com/shigma/schemastery) for schemas; Cordis itself accepts any [Standard Schema](https://standardschema.dev/) validator, so a plain object exported as `Config` will not work. + +Configure it: + +```yaml +- name: './config-demo.ts' + config: + targets: ['alpha', 'beta'] +``` + +Run: + +``` +Hello, alpha! +Hello, beta! +``` + +`greeting` was omitted, so the schema default filled it in — `apply` always receives complete, validated config. + +## Fail loud + +Now feed it something invalid: + +```yaml +- name: './config-demo.ts' + config: + targets: 'not-an-array' +``` + +``` +ValidationError: invalid config: + - $.targets expected array but got not-an-array (at targets) +``` + +The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference. + +## What belongs in config + +The harness convention, useful for any Cordis app: **anything two deployments may want to set differently is a config field**, not a constant in the plugin. Timeouts, model ids, directory roots, thresholds — the test is whether `cordis.yml` can change the value without a code edit. Compare `timeoutMs` on [`dsh-bash-local`](../../packages/bash/bash-local/src/index.ts) in the [headless example](../../examples/headless-agent/cordis.yml). + +## Computed config values + +The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment: + +```yaml +- name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY +``` + +`!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration). + +Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md new file mode 100644 index 0000000000..8e7be022c3 --- /dev/null +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -0,0 +1,105 @@ +# 6. Composition and HMR + +Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads. + +## Entries are more than a name + +A config entry accepts metadata beyond `name` and `config`: + +```yaml +- id: greeter # stable identity for this entry + name: './greeter.ts' +- id: consumer + name: './consumer.ts' + disabled: true # keep the entry, skip mounting it +``` + +`id` gives the entry a stable identity so the loader can tell an edit to an existing entry apart from a removal plus an addition. `disabled: true` unmounts a plugin without deleting its entry — flip it back and the plugin (and everything PENDING on its services) loads again. + +Groups nest a sub-list of entries that load and unload as one unit, and `isolate` gives a group its own instance of a service name — two groups can each see a differently-configured `bash` without affecting each other. Those are worth knowing about before you need them; the [Cordis primer](../cordis-primer.md) and the [service isolation example](../user/develop/framework/service.md#service-isolation) cover the details. + +## Hot module replacement + +Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@cordisjs/plugin-hmr` plugin watches your files and does exactly that on save. + +In `tmp/cordis-tutorial`, write `cordis.yml`: + +```yaml +- name: '@cordisjs/plugin-logger-console' +- name: '@cordisjs/plugin-timer' +- name: '@cordisjs/plugin-hmr' + config: + root: ['.'] +- name: './hello.ts' +``` + +Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@cordisjs/plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section. + +HMR also needs Node's loader internals: + +```sh +node --expose-internals --import tsx ../../vendor/cordis/bin.js +``` + +Now edit `hello.ts` — change the log message — and save: + +``` +hello from my first plugin +2026-07-22 15:44:36 [I] hmr watching [ '.' ] +2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts +hello from my EDITED plugin +``` + +The old instance unloaded (all its effects unwound), the new code loaded, `apply` ran again. Stop the process with Ctrl-C. Editing `cordis.yml` itself is also picked up: the loader diffs entries by `id` and mounts, unmounts, or reconfigures only what changed. + +## Diagnosing a plugin that never loads + +The flip side of dependency-driven loading: a plugin whose `inject` names a service nobody provides waits forever, printing nothing. No error — PENDING is a legitimate state, since the provider may be mounted later. + +You can see the states directly. Every context can enumerate the plugin registry; create `diagnose.ts`: + +```ts +import { FiberState, type Context } from 'cordis' + +export const name = 'diagnose' + +export function apply(ctx: Context) { + setTimeout(() => { + for (const runtime of ctx.registry.values()) { + for (const fiber of runtime.fibers) { + if (fiber.state === FiberState.PENDING) { + console.log(`${fiber.name} is PENDING — a required service is missing`) + } + } + } + }, 500) +} +``` + +And a plugin with an unsatisfiable dependency, `needs-timer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'needs-timer' +export const inject = ['timer'] + +export function apply(ctx: Context) { + console.log('needs-timer loaded') +} +``` + +```yaml +- name: './needs-timer.ts' +- name: './diagnose.ts' +``` + +Run it (plain `node --import tsx ../../vendor/cordis/bin.js`; stop with Ctrl-C): + +``` +needs-timer is PENDING — a required service is missing +``` + +`inject: ['timer']` has no provider. Add `- name: '@cordisjs/plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself. + +Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md new file mode 100644 index 0000000000..8b3f8dc893 --- /dev/null +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -0,0 +1,101 @@ +# 7. Into the harness + +This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model. + +## A tool plugin + +Create `greet-tool.ts` in `tmp/cordis-tutorial`: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' + +export const name = 'greet-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'greet', + description: 'Greet the named person.', + parameters: { + name: { type: 'string', required: true, description: 'Who to greet' }, + }, + async execute(args) { + return [{ type: 'text', text: `Hello, ${args.name}!` }] + }, + })) + + // Drive one call through the real execution pipeline, standing in for + // the model. CallId brands the correlation id a provider would issue. + void (async () => { + const result = await ctx.tools.execute({ + callId: CallId('demo-1'), + name: 'greet', + arguments: { name: 'Cordis' }, + signal: new AbortController().signal, + }) + console.log('tool replied:', JSON.stringify(result.content)) + })() +} +``` + +Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3](03-services.md)) holds the plugin until the tool registry exists; `ctx.tools.register(...)` attaches the registration disposer to the plugin ([chapter 2](02-lifecycle-and-effects.md)), so unloading unregisters the tool. `defineTool` converts the `parameters` spec to the JSON Schema shown to the model, infers the type of `args`, and validates model-supplied arguments before `execute` runs. + +## An observer plugin + +Create `tool-logger.ts` — a separate plugin that watches every tool call in the app through the harness's `tools/result` event: + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' + +export const name = 'tool-logger' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.on('tools/result', (exec, result) => { + const text = result.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') + console.log(`[tool-logger] ${exec.name} -> ${text}`) + }) +} +``` + +The `import type {} from '@deepseek-ai/dsh-tools'` line pulls in the package's declaration merges so `'tools/result'` and its payload are typed — the same move as chapter 4's `stats.ts` import, at package scale. + +## Compose and run + +```yaml +- name: '@deepseek-ai/dsh-system-prompt' +- name: '@deepseek-ai/dsh-tools' +- name: './tool-logger.ts' +- name: './greet-tool.ts' +``` + +`@deepseek-ai/dsh-tools` injects the `systemPrompt` service because tools contribute schemas to the system prompt, so the composition lists its provider too. Without it, the tools plugin remains PENDING as described in [chapter 6](06-composition-and-hmr.md). + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +``` +[tool-logger] greet -> Hello, Cordis! +tool replied: [{"type":"text","text":"Hello, Cordis!"}] +``` + +The logger fired first: `tools/result` is emitted as part of result materialization, before `execute`'s promise resolves to the caller. Neither of your plugins knows the other exists — the registry service and the event connect them. + +## From here to a full agent + +A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file and, with a `DEEPSEEK_API_KEY` in the root `.env`, the model can actually call your tool. + +Where to go next: + +- [Build a tool](../user/develop/basic/tool.md) — more of `defineTool`, including presentation and richer schemas. +- [Three-layer capability design](../user/develop/practice/index.md) — how the harness structures replaceable capabilities. +- The generated [services](../cordis-catalog/services.md) and [events](../cordis-catalog/events.md) catalogs — everything you can inject and listen to. +- [Architecture](../architecture.md) — the system map these plugins live in. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md new file mode 100644 index 0000000000..243926f812 --- /dev/null +++ b/docs/cordis-tutorial/index.md @@ -0,0 +1,54 @@ +# Cordis tutorial + +Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services. + +The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output. + +If you want the condensed concept reference instead of a walkthrough, read the [Cordis primer](../cordis-primer.md). The exhaustive API reference lives in the generated [events](../cordis-catalog/events.md) and [services](../cordis-catalog/services.md) catalogs and the [Cordis core API](../cordis-catalog/core/context.md) pages. + +## Setup + +You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. + +```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness +pnpm install +``` + +Create the scratch directory the chapters work in. `tmp/` is gitignored, so nothing you write there touches version control: + +```sh +mkdir -p tmp/cordis-tutorial +cd tmp/cordis-tutorial +``` + +Every chapter runs the same command from this directory: + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) creates a root `Context`, mounts the Loader plugin, and tells it to load `./cordis.yml` from the current directory. Everything else — which plugins exist, how they are configured — comes from that YAML file, which you will write in a moment. The `--import tsx` flag lets Node run the TypeScript files the config points at without a build step. + +## Chapters + +1. [Your first plugin](01-first-plugin.md) — a plugin is a function; the loader mounts it. +2. [Lifecycle and effects](02-lifecycle-and-effects.md) — Cordis-managed registrations are undone when their plugin unloads. +3. [Services](03-services.md) — expose a capability on `ctx` and depend on it with `inject`. +4. [Events](04-events.md) — typed events, broadcast dispatch, and the waterfall veto. +5. [Configuration](05-config.md) — validated config from `cordis.yml`, failing loud on bad input. +6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads. +7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services. + +## TypeScript notes + +The examples use three TypeScript features beyond ordinary modern JavaScript: + +- **Type annotations** describe values without changing runtime behavior: `ctx: Context` says that `ctx` has the Cordis context API, `who: string` accepts text, and `string[]` means an array of strings. +- **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. +- **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. + +Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 3ea513a0bb..da3892eaa2 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -13,6 +13,7 @@ const sectionOrder = [ '基础', '框架能力', '实战', + 'Cordis 教程', '概念', '生成参考', 'Cordis API', @@ -22,6 +23,7 @@ const sectionOrder = [ 'Basics', 'Framework', 'Practice', + 'Cordis tutorial', 'Concepts', 'Generated reference', 'Cordis Core API', diff --git a/website/docs.ts b/website/docs.ts index 528fa36110..8094f342aa 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -206,6 +206,26 @@ const develop = pairedPages([ }, ]) +const cordisTutorial = mirroredPages(([ + ['index.md', 'Cordis 教程', 'Cordis tutorial'], + ['01-first-plugin.md', '1. 第一个插件', '1. Your first plugin'], + ['02-lifecycle-and-effects.md', '2. 生命周期与副作用', '2. Lifecycle and effects'], + ['03-services.md', '3. 服务', '3. Services'], + ['04-events.md', '4. 事件', '4. Events'], + ['05-config.md', '5. 配置', '5. Configuration'], + ['06-composition-and-hmr.md', '6. 组合与热重载', '6. Composition and HMR'], + ['07-into-the-harness.md', '7. 进入 Harness', '7. Into the harness'], +] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + source: `docs/cordis-tutorial/${file}`, + route: `develop/cordis-tutorial/${file}`, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: 'Cordis 教程', en: 'Cordis tutorial' }, + order, + ...(file === 'index.md' ? { sourceAliases: ['docs/cordis-tutorial'] } : {}), +}))) + const reference = mirroredPages([ ...([ ['docs/architecture.md', 'reference/index.md', '架构', 'Architecture'], @@ -301,5 +321,6 @@ const reference = mirroredPages([ export const docsPages: DocsPage[] = [ ...homeAndGuide, ...develop, + ...cordisTutorial, ...reference, ]