8dd82d99da
Senses trigger shell commands only. Workflows are invoked via CLI.
SenseTrigger is now { command: string } — no discriminated union.
Closes #318
Co-authored-by: Cursor <cursoragent@cursor.com>
201 lines
9.5 KiB
Markdown
201 lines
9.5 KiB
Markdown
# nerve
|
|
|
|
**Observation engine for autonomous agents** — sense the world, react to changes, run workflows.
|
|
|
|
Nerve is a lightweight daemon that continuously observes external state through **Senses** (stateful `compute(state)` + JSON persistence), schedules them via **`interval` / `on`** in `nerve.yaml`, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework.
|
|
|
|
## Core Concepts
|
|
|
|
```
|
|
External World → Sense(state) → { state, trigger? } → (shell in worker) / Log
|
|
│
|
|
Workflow → Log (CLI / daemon IPC only)
|
|
↑
|
|
scheduling: interval / on (per sense in nerve.yaml)
|
|
```
|
|
|
|
| Concept | Metaphor | Role |
|
|
|---------|----------|------|
|
|
| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, trigger }`. State lives in `data/senses/<name>.json`. |
|
|
| **Schedule** | ⏱️ When | Each sense entry sets optional `interval` (periodic) and `on: [other senses]` (run after those senses complete a compute). |
|
|
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started via CLI / daemon IPC (`nerve workflow trigger`, transport). Not started from sense `compute()` results. |
|
|
| **Log** | 📝 Record | Immutable audit trail. **Cannot** schedule senses or workflows (prevents feedback loops). |
|
|
|
|
**Sense → shell:** when `trigger` is non-null it must be `{ command: string }`. The sense worker runs it with `shell: true` (cwd = nerve root). Use `trigger: null` when no command should run. To start a workflow, invoke it from that shell command (for example calling the CLI) or trigger workflows separately via IPC.
|
|
|
|
Two extension points for **what to observe (+ when)** vs **multi-step action** — scheduling is declarative config on each sense, not a separate YAML section.
|
|
|
|
## Packages
|
|
|
|
| Package | Description |
|
|
|---------|-------------|
|
|
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, sense trigger validation (`parseSenseTrigger`), daemon IPC protocol |
|
|
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
|
|
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, sense workers, sense scheduler, workflow manager, file watcher, IPC |
|
|
| [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow |
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
# Requirements: Node.js ≥ 22.5, pnpm
|
|
pnpm add -g @uncaged/nerve-cli
|
|
|
|
# Initialize a workspace
|
|
mkdir my-agent && cd my-agent
|
|
nerve init
|
|
|
|
# Write a sense (see `nerve init` for the full template)
|
|
mkdir -p senses/cpu-usage/src
|
|
cat > senses/cpu-usage/src/index.ts << 'EOF'
|
|
import { loadavg } from "node:os";
|
|
|
|
type CpuState = { lastLoad: number };
|
|
|
|
export const initialState: CpuState = { lastLoad: 0 };
|
|
|
|
export async function compute(state: CpuState) {
|
|
const [oneMin] = loadavg();
|
|
const lastLoad = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
|
|
return { state: { lastLoad }, workflow: null };
|
|
}
|
|
EOF
|
|
|
|
# Configure scheduling on each sense in nerve.yaml
|
|
cat > nerve.yaml << 'EOF'
|
|
senses:
|
|
cpu-usage:
|
|
group: system
|
|
throttle: 10s
|
|
interval: 30s
|
|
EOF
|
|
|
|
# Run
|
|
nerve dev # foreground (development)
|
|
nerve daemon start # background (production)
|
|
nerve status # check health
|
|
nerve logs # view logs
|
|
```
|
|
|
|
## Configuration
|
|
|
|
`nerve.yaml` declares senses (each with optional `interval` / `on`), optional workflows (concurrency), and optional engine `max_rounds`. Top-level `reflexes` is **not** supported — use `interval` and `on` on each sense.
|
|
|
|
```yaml
|
|
max_rounds: 100 # default moderator cap (e.g. CLI workflow trigger)
|
|
|
|
senses:
|
|
cpu-usage:
|
|
group: system # senses in the same group share a worker process
|
|
throttle: 10s # min interval between computes
|
|
timeout: 30s # max compute duration
|
|
grace_period: 5s # wait before first compute after startup
|
|
interval: 30s # periodic trigger
|
|
|
|
derived-example:
|
|
group: system
|
|
throttle: null
|
|
timeout: 30s
|
|
grace_period: null
|
|
on:
|
|
- cpu-usage # run after cpu-usage completes a compute
|
|
|
|
workflows:
|
|
cleanup:
|
|
concurrency: 1
|
|
overflow: drop # discard if already running
|
|
code-review:
|
|
concurrency: 3
|
|
overflow: queue
|
|
max_queue: 20
|
|
```
|
|
|
|
Declare workflows under `workflows:` and start them from Sense `compute()` (non-null `workflow`) or `nerve workflow trigger`.
|
|
|
|
**Example — Sense starts a workflow** (`senses/disk-pressure/src/index.ts`):
|
|
|
|
```typescript
|
|
export const initialState = { checked: false };
|
|
|
|
export async function compute(state: typeof initialState) {
|
|
const full = await diskNearlyFull();
|
|
if (!full) return { state: { ...state, checked: true }, workflow: null };
|
|
return {
|
|
state: { ...state, checked: true },
|
|
workflow: {
|
|
name: "cleanup",
|
|
maxRounds: 10,
|
|
prompt: "Disk partition nearly full",
|
|
dryRun: false,
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌────────────────────────────────────────────────────────────────────────┐
|
|
│ Kernel │
|
|
│ │
|
|
│ ┌──────────────┐ watches nerve.yaml / senses / workflows │
|
|
│ │ File Watcher ├──────────────────────────────────────────┐ │
|
|
│ └──────────────┘ │ │
|
|
│ ┌──────────────┐ CLI ↔ newline JSON (trigger-workflow, │ │
|
|
│ │ Daemon IPC │ trigger-sense, list-senses) │ │
|
|
│ └──────┬───────┘ ▼ │
|
|
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
│ │ │ Worker │ │ Worker │ │ Worker │ (1 per│
|
|
│ │ │ (group A)│ │ (group B)│ │ (group C)│ group) │
|
|
│ │ │ sense-1 │ │ sense-3 │ │ sense-5 │ │
|
|
│ │ │ sense-2 │ │ sense-4 │ │ │ │
|
|
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
|
│ │ │ │ │ │
|
|
│ │ └──────────────┼──────────────┘ │
|
|
│ │ ▼ │
|
|
│ │ ┌──────────────┐ │
|
|
│ │ │Sense Scheduler │
|
|
│ │ │(interval + on) │
|
|
│ │ └──────┬───────┘ │
|
|
│ │ ▼ │
|
|
│ │ ┌───────────────────┐ │
|
|
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
|
|
│ └───────────────────┘ (logs.db, …) │
|
|
└────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
- **Worker pool** — one child process per sense group; isolation between groups.
|
|
- **Sense scheduler** — interval timers + `on` reverse-index (upstream sense → dependents), with throttle/coalesce.
|
|
- **Workflow Manager** — concurrency (drop/queue), per-workflow workers, crash recovery.
|
|
- **File watcher** — hot reload for config, sense modules, and workflow modules.
|
|
- **Daemon IPC** — Unix domain socket; used by the CLI when the daemon is running.
|
|
- **Log / blob storage** — implemented in `@uncaged/nerve-store` (WAL SQLite, JSONL archive, CAS blobs).
|
|
|
|
## Tech Stack
|
|
|
|
- **Zero native addons** — uses Node.js built-in `node:sqlite` (DatabaseSync) for logs / workflow persistence via `@uncaged/nerve-store`
|
|
- **Sense state as JSON** — files under `data/senses/` written by sense workers
|
|
- **rslib** (rspack) for building
|
|
- **Biome** for formatting/linting
|
|
- **Vitest** for testing
|
|
- **pnpm** workspaces for monorepo management
|
|
|
|
## Development
|
|
|
|
```bash
|
|
git clone https://git.shazhou.work/uncaged/nerve.git
|
|
cd nerve
|
|
pnpm install
|
|
pnpm build
|
|
pnpm -r test # run all tests
|
|
```
|
|
|
|
## Design Documents
|
|
|
|
- [RFC-001: Observation Engine](./docs/rfc-001-observation-engine.md) — historical sense / scheduling model (superseded in places by stateful senses — see `CLAUDE.md`)
|
|
- [RFC-002: Workflow Engine](./docs/rfc-002-workflow-engine.md) — Stateful workflow execution
|
|
- [Coding Conventions](./docs/coding-conventions.md)
|
|
|
|
## License
|
|
|
|
MIT
|