nano-cordis
其他 活跃维护

nano-cordis

SheltonLiu-N/nano-cordis

轻量插件化AI智能体运行时,是Cordis与相关工具链的极简重实现,基于插件构建核心能力,整体体积极小可快速通读,开发者可按需扩展插件灵活适配不同智能体开发需求。

7
Stars 标星
0
Forks 分支
7
Watchers 关注
0
Open Issues
TypeScript
主要语言
MIT
开源协议
356 KB
仓库大小
1 个月前
最后推送
一键安装扩展 / 插件指令
dsh plugin --profile web add github:SheltonLiu-N/nano-cordis
git clone https://github.com/SheltonLiu-N/nano-cordis.git
git clone git@github.com:SheltonLiu-N/nano-cordis.git
README.md main

NanoCordis

English | 中文

NanoCordis is a small, readable codebase that answers one question: **how do you build an AI agent runtime out of plugins?** It contains two layers, about 1600 lines of TypeScript in total: - `src/cordis/` — a minimal re-implementation of the [Cordis](https://github.com/cordiverse/cordis) plugin framework that keeps every core idea (plugins, services, dependency-driven loading, effects, events, configuration-driven composition, hot reload) and leaves out the rest. - `src/harness/` — a minimal agent harness built on it, in the shape of [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): a session log that is the only source of truth, a loop that asks a model and runs its tool calls, a tool pipeline, an approval policy, a command line — each one a plugin. You can read the whole thing in an afternoon. Wherever the real projects do more at a specific place in the code, a short `// Omitted:` or `// Differs:` comment says what they do and why; whole subsystems that are simply absent are listed at the end of this document. Either way you always know where the full version goes further. ## Why this exists An **agent harness** is the program around a language model: it keeps the conversation, decides what the model sees, executes the tools the model asks for, and talks to the user. Every such program ends up with the same tension: it needs many replaceable parts (models, tools, storage, interfaces, policies), and those parts need to find each other without hard-wiring. **Cordis** is a plugin framework built for exactly that. Its core idea is that a program is a set of plugins mounted into a shared *context*: a plugin registers *services* other plugins can use, declares which services it needs, and everything it registers is undone automatically when it is unloaded. Load order falls out of dependencies, and reloading a plugin is just unload plus load. **DeepSeek Harness** (dsh) applies this idea to an agent runtime without exception: the model adapter, the tool registry, the session log, even the agent loop are plugins. Both real projects are large. NanoCordis keeps their design and drops their size, so you can see the mechanism with nothing in the way. ## Quick start Requirements: Node.js 20 or newer. Try it without cloning — the package is on npm: ```sh npx nano-cordis "Say hello" # or: npm install -g nano-cordis, then nano-cordis "Say hello" ``` To read and change the code, clone this repository: ```sh npm install npm start -- "Say hello" ``` The default configuration uses a scripted fake model (no API key needed). It asks to run one bash command, so answer `y`: ``` [tool] bash {"command":"echo hello from nano"} Allow bash {"command":"echo hello from nano"}? [y/N] y [result] hello from nano [exit code: 0] assistant> The command ran; that is all for this scripted reply. ``` Run `npm start` with no argument for a chat prompt (`you>`), and `npm start -- --resume ` to continue a saved session (the id is printed when the chat starts; logs live in `.nano/sessions/`). **Install the command.** `npm install -g nano-cordis` gives you a global `nano-cordis` command; from a clone, `npm link` registers the same command pointing at your working copy. Either way it is the launcher `npm start` uses, and it runs from any directory: the directory's own `cordis.yml` is read when present, the one shipped with the package otherwise, and session logs go to `.nano/sessions/` under the directory you run from. **Use a real model.** In `cordis.yml`, replace the `llm` entry with an OpenAI-compatible endpoint. The key is read from the named environment variable, never written into the file: ```yaml - id: llm name: ./src/harness/llm-openai.ts config: { baseUrl: https://api.deepseek.com/v1, apiKeyEnv: DEEPSEEK_API_KEY, model: deepseek-v4-flash } ``` **Watch hot reload work.** Start `npm start`, then edit `cordis.yml`: change the approval entry to `tools: []` and save. The terminal prints `[nano-cordis] applied cordis.yml`, and the next bash call runs without asking. Now edit `src/harness/tool-bash.ts` (say, its description) and save: `[nano-cordis] reloaded ./src/harness/tool-bash.ts` — the old plugin's registrations vanished and the new one's took their place. If you change an entry that other plugins depend on (the model, for instance), those plugins stop and start again too; the command line will greet you with a fresh session, which is the dependency model doing exactly what it says. **Check everything:** `npm test` (95 tests) and `npm run typecheck`. ## Repository map and reading order Read the files in this order; each file header says what it mirrors in the real projects and what to watch for. | # | File | What it is | |---|---|---| | 1 | `bin.ts` | The launcher: create a context, mount the loader, mount `cordis.yml`. | | 2 | `cordis.yml` | The application: one plugin per entry. | | 3 | `src/cordis/context.ts` | The context: service container, `plugin()`, `provide()`, `effect()`, and the caller-bound view of a service. | | 4 | `src/cordis/plugin.ts` | The three plugin shapes and config validation. | | 5 | `src/cordis/fiber.ts` | A running plugin: its state, its effects, start and stop. | | 6 | `src/cordis/refresh.ts` | The scheduler that starts and stops plugins as services come and go. | | 7 | `src/cordis/events.ts` | The event bus and its four dispatch modes. | | 8 | `src/cordis/service.ts` | The base class for plugins that provide a service. | | 9 | `src/cordis/loader.ts` | Reads `cordis.yml`; mounts, remounts and unmounts by entry id. | | 10 | `src/cordis/hmr.ts` | Watches files and reloads what changed. | | 11 | `src/harness/session.ts` (+ `freeze.ts`) | The session log and the messages derived from it; `freeze.ts` is the small helper that freezes what was logged. | | 12 | `src/harness/prompt.ts` | System prompt sections. | | 13 | `src/harness/llm.ts`, `llm-openai.ts`, `llm-fake.ts` | The model service definition and two providers. | | 14 | `src/harness/tools.ts` | The tool registry and pipeline. | | 15 | `src/harness/agent-loop.ts` | The turn: ask the model, run tools, repeat. | | 16 | `src/harness/shell.ts`, `shell-local.ts`, `tool-bash.ts` | One capability in three parts. | | 17 | `src/harness/approval.ts` | A policy plugin. | | 18 | `src/harness/persistence.ts` | Saving and resuming sessions. | | 19 | `src/harness/cli.ts` | The command line, a subscriber like any other plugin. | Tests live in `tests/cordis/` and `tests/harness/`; `tests/fixtures/agent.cordis.yml` boots the whole agent from a file inside a test. ## Part 1 — the mini Cordis, idea by idea ### 1. A plugin is a function, a class, or an object with `apply` ```ts // function form: what a module with named exports looks like to the loader export const name = 'hello' export const inject = ['prompt'] // services this plugin needs export function apply(ctx: Context) { // runs once the services exist ctx.prompt.section('hello', 10, 'Always greet the user first.') } ``` A class form is usually a `Service` subclass (see idea 6), though any class works. The loader hands it a module namespace, which is the object form; a `default` export replaces the namespace. `plugin.ts` detects the shape and validates the config; `context.ts` mounts it with `ctx.plugin(plugin, config)`. ### 2. A context is a container of services; `inject` says what a plugin needs `ctx.provide('prompt', value)` puts a service in the shared table. A plugin lists the services it needs in `inject`; it starts only when they all exist, stops when one disappears, and starts again when it comes back (`refresh.ts`). Nobody orders the plugins by hand — mount them in any order and they sort themselves out. Reading `ctx.prompt` inside a plugin that did not declare `prompt` throws (a plugin may always read the services it provides itself, and those an enclosing plugin declared or provides); reading a declared service that has gone throws too, rather than handing back `undefined`. `ctx.get('prompt')` is the escape hatch for optional services: it returns the service when its provider is running and `undefined` otherwise. ### 3. Plugins start in a microtask: `await ctx.plugin(...)` `ctx.plugin(x)` returns immediately with the plugin's *fiber* (its running instance) and starts it a moment later, once the current code has finished. Write `await ctx.plugin(x)` before using what `x` provides; the same rule holds in the real framework. If a plugin's config is invalid or its body throws, the `await` rethrows the error and the fiber's state is `failed`. A fiber changes state one step at a time: a stop that arrives while the body is still running waits for it to finish (Cordis serializes the same way and calls the change under way `inertia`). ### 4. Everything you register is an effect ```ts ctx.effect(() => { const timer = setInterval(tick, 1000) return () => clearInterval(timer) // runs when the plugin unloads }) ``` `ctx.on(...)`, `ctx.provide(...)` and `ctx.plugin(...)` are all built on `ctx.effect`, and a function returned by the plugin body itself counts as its cleanup. Unloading a plugin runs its cleanups in reverse order, which is why hot reload needs no special support: unload, then load again. `ctx.effect` returns a disposer: the first call runs the cleanup once and resolves when it is done; a later call returns at once instead of waiting (so a cleanup can never end up waiting for itself), while an unload always waits for a run already under way. ### 5. Events: four ways to dispatch | Call | Behavior | |---|---| | `ctx.emit(name, ...args)` | Every listener runs, in order; results are ignored. | | `await ctx.parallel(name, ...args)` | Listeners run at the same time; all are awaited, and every failure is reported together in one `AggregateError`. | | `await ctx.serial(name, ...args)` | Listeners run one by one; the first meaningful return value wins. | | `ctx.waterfall(name, ...args, next)` | Middleware: each listener receives a `next()`; skipping it short-circuits everything after it. | Waterfall is what makes interception possible — a policy can veto a tool call by returning without calling `next()` — and it comes with one rule: **a listener that only observes must call `next()`**. Event names and signatures are declared by extending the `Events` interface (`declare module '../cordis/index.ts' { interface Events { ... } }`); the type of `ctx.` is declared the same way (`interface Context { prompt: Prompt }`). A file that only needs those declarations imports the module for its types alone: `import type {} from './tools.ts'` — you will see this line at the top of several harness files. ### 6. Services and the caller-bound view A `Service` subclass registers itself with `super(ctx, name)`. When *another* plugin reads it through `ctx.`, it receives a view of the same object in which `this.ctx` belongs to the caller. So when `tools.register(...)` runs `this.ctx.effect(...)`, the registration is owned by the plugin that called `register`, and disappears when that plugin unloads — even though the code lives in the tools service. Service reads inside the method (`this.ctx.llm`) still follow the service's own `inject`. Cordis calls this a *traceable*; `context.ts` implements it in `bindToCaller`. ### 7. Config is validated before a plugin starts A plugin exports a `Config` schema (any [Standard Schema](https://standardschema.dev) validator; this repository uses schemastery). Defaults are filled in and the checked object is passed to `apply`; a bad `cordis.yml` entry names the field (with its path, such as `inner.deep`) and the plugin never starts half-configured. ### 8. `cordis.yml` and hot reload The loader reads a list of entries `{ id, name, config, disabled }`, mounts each as a child plugin, and on a re-read compares by `id`: removed entries unmount, changed ones remount, new ones mount. An entry whose module fails to load is reported and remembered, so the next save of the config or of that module tries again; a module save that does not even load changes nothing. `hmr.ts` watches the directory and calls the loader. Both are plugins themselves. ## Five rules for writing a plugin 1. Declare in `inject` every service you read as `ctx.`; use `ctx.get(name)` only for optional ones. 2. Register everything through `ctx.effect` (or `ctx.on`, `ctx.provide`, `ctx.plugin`, which use it) — never keep a listener or timer that the framework does not know about. 3. `await ctx.plugin(x)` before you use what `x` provides. 4. Inside a service method, `this.ctx` belongs to the caller. Registrations that belong to the service itself use the context saved at construction time (see how `Loader` keeps `host`). 5. Do not use `#private` fields in a service; the caller-bound view cannot read them. ## Part 2 — the nano harness, mechanism by mechanism ### 1. The session log is the only source of truth `session.ts` keeps an append-only list of events: `turn/start`, `step/start`, `user/message`, `request/header`, `assistant/message`, `tool/call`, `tool/result`, `step/end`, `turn/end`. `append()` is the only way to write (`session.events` is a frozen copy); each event gets the next `seq`, is frozen, and is broadcast as `session/event` — a listener may not append while that broadcast is under way, so everyone sees events in log order. The messages sent to the model are **derived** from the log by `deriveMessages()`, never stored separately — so replay, resume and inspection all read the same thing. ### 2. Nothing reaches the model that is not on record Before each request the loop logs a `request/header` (model, system prompt, tool schemas) whenever it changed, and then builds the request from the last logged header plus the derived messages. Plugins can adjust the header through the `agent/request` waterfall — but only what gets logged. In dsh this is the standing rule "model-visible means logged", checked at runtime; here it holds by construction. ### 3. Turns and steps A **turn** starts with the user's message and ends when the model answers without tool calls (`completed`), when the step limit is hit (`max-steps`), or on an error. A **step** is one model request plus the tool calls it produced. `agent-loop.ts` writes `turn/end` in a `finally`, so a log always closes what it opened. ### 4. Tools: a registry and a pipeline `tools.ts` holds the definitions and runs every call through the same pipeline: `tools/pre-execute` (allow or deny) → the tool body → `tools/post-execute` (rewrite the result). Whatever fails — unknown tool, denied call, invalid arguments, a throwing body — becomes a result with `isError: true`; the loop never has to catch anything. `schemas()` (what the model sees) and `execute()` (what can run) read the same table, so a tool that is gone is gone from both. ### 5. One capability, three parts The shell capability is split the way dsh splits every replaceable capability: `shell.ts` is the **service definition** (request, spec, result, and an explicit `resolve()` that fills defaults), `shell-local.ts` is a **provider** (bash on this machine), `tool-bash.ts` is a **consumer** that exposes it to the model. The consumer depends on the definition only; swap the provider line in `cordis.yml` and nothing else changes. The model service is split the same way (`llm.ts` / `llm-openai.ts` / `llm-fake.ts`). ### 6. Policy is a plugin `approval.ts` listens on `tools/pre-execute`; for the tools it guards it asks `approval/request` and denies unless someone answers exactly `true` (a `"yes"` or a `1` counts as a refusal, so a broken answerer can only deny, never allow). The command line answers by asking you; a test answers automatically; if nobody answers, the call is denied. Delete the entry from `cordis.yml` and there is no approval — the loop and the tools were never involved. ### 7. Prompt sections `prompt.ts` assembles the system prompt from named sections ordered by number. A section is an effect owned by the plugin that added it. ### 8. Persistence and resume `persistence.ts` appends every event to `.nano/sessions/.jsonl` and resumes a session by creating it again with the file's events as its history (which is why `Session` accepts a seed that is not broadcast). A log that stops in the middle of a turn is refused rather than guessed at. ### 9. The command line is a subscriber `cli.ts` prints `session/event`s, answers `approval/request`, and hands input to `agentLoop.run()`. Nothing in the loop knows it exists; a web interface would be another plugin doing the same. ### 10. `cordis.yml` is the application Look at the file: sessions, persistence, prompt, model, tools, shell, bash tool, approval, loop, cli, hot reload — eleven entries, in no particular order. Change the model, drop the approval, replace the shell: edit the file, and if `hmr` is running, watch it happen. ## One turn, step by step ``` user text → cli → agentLoop.run(session, text) append turn/start step 1..maxSteps: append step/start (the first step also appends user/message) config = waterfall('agent/request', { model: llm.model, system: prompt.render() }) header = config + tools.schemas() → append request/header if it changed request = last logged header + session.deriveMessages() reply = llm.chat(request) → append assistant/message no tool calls → append step/end, turn/end (completed) → done for each call: append tool/call → tools.execute → [pre-execute ▷ (approval ▷) → body → post-execute ▷] → append tool/result append step/end ``` ## Exercises 1. **Add a tool.** Copy `tool-bash.ts` into `tool-date.ts` returning the current date, add an entry to `cordis.yml`, and watch it appear in the next `request/header`. 2. **Swap the model.** Point the `llm` entry at `llm-openai.ts` with your endpoint. Nothing else changes. 3. **Write a policy.** A plugin that listens on `tools/pre-execute` and denies any bash command containing `rm -rf`. Remember to call `next()` for everything else. 4. **Change the persona live.** With `npm start` running, edit `persona:` in `cordis.yml` and save. The prompt plugin remounts, the loop and the command line restart with it, and the command line greets you with a new session; send one message, then look at the new session's `.nano/sessions/.jsonl` for the new `request/header`. 5. **A read-only shell.** Write `shell-readonly.ts` extending `LocalShell` whose `run()` refuses any command not starting with `ls`, `cat` or `echo` before calling `super.run()`, and put it in place of `shell-local.ts`. ## What is left out on purpose **Cordis features not implemented** (whole features are listed here; where an omission touches a specific line, a `// Omitted:` comment sits there too): isolated service realms and per-service config interception; listener filtering by context (the basis of dsh's per-agent scopes); `bail` and `once`; generator effects and awaited async setup; callable services, `Service.check` and async init; `ctx.set / accessor / mixin`; the per-service config carried by the object form of `inject` (its keys still name the required services); automatic retry of failed plugins; in-place config updates with `internal/update`; internal events; the logger; loader groups, transactional rollback, write-back and `!!js` expressions; HMR through Node's module graph. **Where NanoCordis deliberately behaves differently:** effects are accepted only while a plugin is active; cleanups run one by one instead of concurrently; `emit` isolates a throwing listener; a failed plugin stays failed; waterfall's `next` is composed per listener; class plugins are detected by the `class` keyword; entries without an `id` are rejected; a config change remounts the entry; a module that fails to reload is reported per entry, without rolling anything back. **dsh mechanisms not implemented:** streaming and per-chunk logging; the inbox with follow-up, steering and injected context; `agent/pre-step`, `agent/turn-stopping`, `agent/request-error`; cancellation; the agent registry and handles; scopes and per-session presets; subagents, workflows, background jobs; context compaction; sandboxing; multi-provider routing and retry; approval audit events and policies; the `tools/execute` wrapper, guards, `tools/result`; parallel tool calls; crash repair; forks; projections, search, telemetry; bundles, profiles and patch layers; the web UI, SDK and ACP. The log uses the OpenAI message format directly instead of dsh's provider-neutral one. **Where to look in the real code:** | NanoCordis | Real project | |---|---| | `src/cordis/context.ts` | cordis `packages/core/src/context.ts`, `reflect.ts` | | `src/cordis/plugin.ts` | cordis `registry.ts` | | `src/cordis/fiber.ts`, `refresh.ts` | cordis `fiber.ts` | | `src/cordis/events.ts` | cordis `events.ts` | | `src/cordis/service.ts` | cordis `service.ts` | | `src/cordis/loader.ts` | `@cordisjs/plugin-loader`, `plugin-include` | | `src/cordis/hmr.ts` | `@cordisjs/plugin-hmr` | | `src/harness/session.ts` | dsh `packages/core/session` | | `src/harness/persistence.ts` | dsh `packages/session/session-persistence`, `-jsonl` | | `src/harness/prompt.ts` | dsh `packages/core/system-prompt` | | `src/harness/llm*.ts` | dsh `packages/llm/llm`, `llm-deepseek` | | `src/harness/tools.ts` | dsh `packages/core/tools` | | `src/harness/shell*.ts`, `tool-bash.ts` | dsh `packages/shell/shell`, `bash-local`, `tool-bash` | | `src/harness/approval.ts` | dsh `packages/interaction/user-approval` and the `tools/pre-execute` policy pattern | | `src/harness/agent-loop.ts` | dsh `packages/core/agent`, `agent-loop` | | `src/harness/cli.ts` | dsh `packages/bundle/headless`, `apps/cli` | ## Tests `npm test` runs vitest: `tests/cordis/` covers effects, dependencies, startup timing, events, caller binding, config, the loader and hot reload; `tests/harness/` covers the log, the tool pipeline, a full turn, approval, persistence, the scripted model, and booting the agent from `tests/fixtures/agent.cordis.yml`. ## Credits and license Cordis is developed by the [cordiverse](https://github.com/cordiverse) organization; its design is described in *A Programming Paradigm for Spatiotemporal Composability*. DeepSeek Harness is developed by [DeepSeek](https://github.com/deepseek-ai/deepseek-harness). NanoCordis re-implements their ideas for teaching and is not affiliated with either. MIT license. The logo is eleven blocks, one per entry in `cordis.yml`; the red one is the plugin being reloaded.