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.
This commit is contained in:
91
docs/cordis-tutorial/01-first-plugin.md
Normal file
91
docs/cordis-tutorial/01-first-plugin.md
Normal file
@@ -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.
|
||||
89
docs/cordis-tutorial/02-lifecycle-and-effects.md
Normal file
89
docs/cordis-tutorial/02-lifecycle-and-effects.md
Normal file
@@ -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.
|
||||
94
docs/cordis-tutorial/03-services.md
Normal file
94
docs/cordis-tutorial/03-services.md
Normal file
@@ -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.
|
||||
140
docs/cordis-tutorial/04-events.md
Normal file
140
docs/cordis-tutorial/04-events.md
Normal file
@@ -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<string, number>()
|
||||
|
||||
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<string>): Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
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`.
|
||||
84
docs/cordis-tutorial/05-config.md
Normal file
84
docs/cordis-tutorial/05-config.md
Normal file
@@ -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<Config> = 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.
|
||||
105
docs/cordis-tutorial/06-composition-and-hmr.md
Normal file
105
docs/cordis-tutorial/06-composition-and-hmr.md
Normal file
@@ -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.
|
||||
101
docs/cordis-tutorial/07-into-the-harness.md
Normal file
101
docs/cordis-tutorial/07-into-the-harness.md
Normal file
@@ -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://github.com/deepseek-harness/deepseek-harness)
|
||||
54
docs/cordis-tutorial/index.md
Normal file
54
docs/cordis-tutorial/index.md
Normal file
@@ -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<Config>` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
Reference in New Issue
Block a user