Compare commits

..

19 Commits

Author SHA1 Message Date
xiaoju c98e14e9e6 refactor(cli): single-package workspace init and root dist build (#274)
Init templates match ~/.uncaged-nerve: scripts/build.mjs writes dist/senses/*/index.js and dist/workflows/*/index.js; drop @uncaged/nerve-skills from generated package.json; refresh Cursor skills rule copy.

Sense worker sends full compute result on signal IPC so the kernel can route workflow triggers; update e2e harness paths (migrations under senses/, noop under dist/workflows).

Fixes #274

Made-with: Cursor
2026-04-30 10:17:44 +00:00
xiaoju d9c86c49ae refactor(daemon): load sense/workflow bundles from dist/ directory
Workspace build output moved from senses/<name>/index.js and
workflows/<name>/dist/index.js to dist/senses/<name>/index.js
and dist/workflows/<name>/index.js.

Refs #274
小橘 <xiaoju@shazhou.work>
2026-04-30 09:16:25 +00:00
xiaomo 0140cdd952 Merge pull request 'refactor: RFC-005 — Separate Agent and Role types' (#272) from refactor/rfc-005-phase-1 into main 2026-04-30 08:29:12 +00:00
xiaomo bfadfffd40 fix: move isDryRun to value export (not type-only) 2026-04-30 08:27:07 +00:00
xiaomo e6093c35db docs: update knowledge cards for RFC-005 (ThreadContext, AgentFn) 2026-04-30 08:09:05 +00:00
xiaomo de8c7c5150 fix: address review — revert unrelated sense-worker change, restore isDryRun as deprecated 2026-04-30 08:00:46 +00:00
xiaomo f799cee51f refactor(cli,docs): RFC-005 Phase 4 — update templates, tests, docs (closes #271) 2026-04-30 07:24:11 +00:00
xiaomo d13b59e787 refactor(daemon): RFC-005 Phase 3 — workflow-worker uses ThreadContext (closes #270) 2026-04-30 07:10:58 +00:00
xiaomo 975f15c66d refactor(workflow-utils): RFC-005 Phase 2 — adapt to ThreadContext, new AgentFn signature (closes #269) 2026-04-30 06:59:15 +00:00
xiaomo 3e51335d91 refactor(core): RFC-005 Phase 1 — ThreadContext, AgentFn, Role signature (closes #268) 2026-04-30 06:54:03 +00:00
xiaoju 9c832b0e21 docs(knowledge): update cards via knowledge-extraction workflow (5q/round)
7 cards updated, 4 new cards added. Topics: signal-routing,
worker-isolation, storage-layer, adapter-isolation, sense contracts,
workflow runtime enforcement, coding conventions details.

小橘 <xiaoju@shazhou.work>
2026-04-30 05:56:29 +00:00
xiaoju 2387b73141 fix(daemon): remove stale exports openPeerDb, loadComputeFn from index
These functions were renamed/removed from sense-runtime.ts but index.ts
still re-exported them, causing rslib build to fail (no JS output).

小橘 <xiaoju@shazhou.work>
2026-04-30 05:56:24 +00:00
xiaoju 8824421f26 docs: remove Reflex concept from architecture docs and CLAUDE.md
Reflex was folded into Sense config (interval/on) and ComputeResult
(workflow trigger). Two extension points now: Sense + Workflow.

— 小橘 🍊(NEKO Team)
2026-04-30 00:43:04 +00:00
xiaoju b27a6aced8 feat: sense compute returns ComputeResult<T> with workflow trigger support
- SenseComputeFn returns ComputeResult<T> = null | { signal, workflow }
- sense-runtime persists result.signal, not result itself
- sense-worker sends workflow trigger message when workflow is non-null
- New SenseWorkflowTriggerMessage in IPC protocol
- Knowledge card updated to match

— 小橘 🍊(NEKO Team)
2026-04-30 00:37:16 +00:00
xiaoju bfd8fe729a docs: update sense knowledge card to match pure compute API
- No args (no db, no peers, no signal)
- Runtime handles db.insert
- Export { compute, table }

— 小橘 🍊(NEKO Team)
2026-04-30 00:28:23 +00:00
xiaoju 748df10e6a fix: remove AbortSignal from SenseComputeFn
Compute is truly zero-arg now: () => Promise<T | null>.
Runtime handles timeout via Promise.race, sense doesn't need signal.

— 小橘 🍊(NEKO Team)
2026-04-30 00:22:57 +00:00
xiaoju 3ef9cfcb27 Merge pull request 'refactor: pure sense compute — no db, no peers' (#265) from refactor/pure-sense-compute into main 2026-04-30 00:11:57 +00:00
xiaoju 8c9adf08c5 refactor: pure sense compute — no db, no peers
SenseComputeFn is now (signal: AbortSignal) => Promise<T | null>.
sense-runtime handles db.insert when compute returns non-null.
Senses export { compute, table } — SenseModule type added to core.

Closes #264
Refs #260

— 小橘 🍊(NEKO Team)
2026-04-30 00:07:49 +00:00
scottwei 08e8020cb6 Merge pull request 'feat: add sense contract types to nerve-core' (#263) from feat/sense-contract into main
Reviewed-on: #263
2026-04-29 23:44:01 +00:00
55 changed files with 1447 additions and 834 deletions
+171
View File
@@ -0,0 +1,171 @@
# Adapter Process Isolation
Describes sandboxing, process isolation, resource limits, and timeout enforcement for adapter invocations in the Nerve workflow system.
## Process Isolation Model
Adapters run in a **two-tier isolation** model:
1. **Workflow Worker Process** — Each workflow runs in a dedicated Node.js worker process (`workflow-worker.ts`) forked from the main daemon
2. **Adapter Child Process** — Each adapter spawns CLI tools as child processes via `spawnSafe()` with `shell: false`
## Resource Limits & Timeouts
### Adapter-Level Timeouts
- **Default timeout**: 300 seconds (300,000ms) for both cursor and hermes adapters
- **Configurable** via `AgentConfig.timeout` in adapter factory functions
- **Wall-clock enforcement** using `setTimeout()` — kills child process with `SIGTERM` on timeout
- **AbortSignal support** — external cancellation triggers immediate `SIGTERM`
### Timeout Behavior
```ts
// Timeout resolution priority (packages/core/src/spawn-safe.ts):
// 1. Explicit timeoutMs value
// 2. AbortSignal presence → no internal timer (relies on external abort)
// 3. DEFAULT_TIMEOUT_MS (300_000) fallback
```
- Child process terminated with `SIGTERM` on timeout/abort
- Returns `{ kind: "timeout", stdout, stderr }` error result
- **No grace period** — immediate kill
- **No SIGKILL escalation** — relies entirely on `SIGTERM` effectiveness
#### SIGTERM Limitations
If a child process **ignores or blocks `SIGTERM`** (e.g., signal handlers, blocked delivery):
- **No fallback to `SIGKILL`** — process may remain alive indefinitely
- **No escalation timer** — spawnSafe() does not implement progressive signal escalation
- **Potential zombie/orphan risk** — unresponsive processes continue consuming resources
- **OS-level cleanup only** — relies on parent process death or OS reaping mechanisms
## Sandboxing Characteristics
### What's Isolated
- **File system**: Child process runs in specified `cwd` (workflow working directory)
- **Environment**: Controlled env vars via `nerveCommandEnv()` + optional overrides
- **Network**: No explicit restrictions (inherits parent process network access)
- **Process tree**: Child processes are direct children, not containerized
### What's NOT Sandboxed
- **No resource quotas** (CPU, memory, disk I/O limits)
- **No filesystem chroot/containers** — full filesystem access within user permissions
- **No network isolation** — can make arbitrary network calls
- **No syscall filtering** — no seccomp or similar restrictions
#### Runtime Resource Enforcement
**No active resource monitoring or constraints**:
- **No cgroups** (Linux) — no CPU, memory, or I/O limits enforced
- **No job objects** (Windows) — no resource quotas or process tree limits
- **No worker_threads resource tracking** — Node.js worker processes run unrestricted
- **Pure timeout-based enforcement** — only wall-clock time limits via `setTimeout()`
- **OS-scheduled resource sharing** — relies entirely on operating system process scheduling
Adapters can consume unlimited:
- **CPU time** (until timeout)
- **Memory** (until OOM)
- **Disk I/O** (no quotas)
- **Network bandwidth** (no throttling)
- **File descriptors** (until ulimit)
#### Environment Variable Security
The `nerveCommandEnv()` function provides **minimal sanitization**:
```ts
// spawn-safe.ts lines 47-55
export function nerveCommandEnv(): SpawnEnv {
const home = homedir();
const pnpmHome = join(home, ".local/share/pnpm");
return {
...process.env, // ← Full parent environment inherited
PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
};
}
```
- **No filtering of sensitive keys** — `NODE_OPTIONS`, `LD_PRELOAD`, `PYTHONPATH` passed through unchanged
- **Full environment inheritance** — all parent process environment variables copied
- **Injection risk** — malicious env vars (e.g., `NODE_OPTIONS=--require=evil.js`) affect Node.js child processes
- **Path manipulation** — sensitive PATH entries remain accessible to adapters
## Security Model
### Execution Context
- Uses `shell: false` to prevent shell injection attacks
- Arguments passed as separate array elements (not shell-parsed)
- PATH includes `~/.local/share/pnpm` for tool discovery
- Inherits parent process user/group permissions
#### File Descriptor Management
```ts
// spawn-safe.ts line 122
stdio: ["ignore", "pipe", "pipe"]
```
- **stdin closed**: Child receives no input (`stdio[0]: "ignore"`)
- **stdout/stderr captured**: Piped to parent for collection (`stdio[1,2]: "pipe"`)
- **No explicit fd closing**: Node.js default behavior — inherits other file descriptors
- **Parent sockets/pipes accessible**: Child can access parent's open network connections, database handles, etc.
- **Security risk**: Adapter processes may access unintended parent file descriptors
### Attack Surface
- CLI tools have **full user-level filesystem access**
- Can spawn additional processes (not tracked/limited)
- Network requests unrestricted
- Resource consumption relies on OS-level limits
## Worker Process Management
### Workflow Isolation
- Each workflow type gets dedicated worker process
- Worker processes handle multiple concurrent threads (runIds)
- Kill flags enable per-thread cancellation without killing worker
- Graceful shutdown waits up to 10 seconds for in-flight operations
#### Cross-RunId Contamination Risks
**Shared mutable state** poses contamination risks between concurrent runIds:
- **`process.env` mutations**: Environment changes affect all subsequent runIds in same worker
- **`require.cache` pollution**: Module cache shared across all runIds — side effects persist
- **Global variables**: Any global state mutations from one runId visible to others
- **`process.cwd()` changes**: Working directory changes affect entire worker process
- **File descriptors**: Open files/sockets shared between runId executions
**No runId-specific scoping** implemented:
- Worker reuses single Node.js process for efficiency
- Each role execution sees cumulative environment from previous runIds
- **Mitigation relies on adapter discipline** — clean implementations avoid global mutations
### Error Handling
- Adapter failures don't crash the worker process
- Timeout/abort errors are isolated to specific role execution
- Worker process survives adapter failures and continues serving other threads
## Configuration
```yaml
# Example nerve.yaml configuration for timeout overrides
workflows:
my-workflow:
roles:
coder:
adapter:
type: cursor
timeout: 600000 # 10 minutes in milliseconds
```
Timeout configuration happens at the adapter creation level, not as a system-wide sandbox policy.
+24 -3
View File
@@ -5,13 +5,23 @@ Adapter = capability. Role = scenario. Workflows declare adapters directly via i
## AgentFn Protocol
```ts
type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>
```
- Input: prompt + context (start frame, messages, workdir, AbortSignal)
- Output: raw string — structured extraction is separate
- Input: thread context (`{ threadId, start, steps }`) + system prompt (role identity)
- Output: **single-shot `Promise<string>`** — no streaming support
- Adapter handles tool-specific details internally
### Streaming Limitations
The `AgentFn` protocol does **not** support streaming responses (`AsyncIterable<string>` or `ReadableStream`). It's strictly limited to single-shot `Promise<string>` returns.
For long-running or incremental agent outputs:
- CLI tools buffer full output until completion
- Timeout enforcement via `timeoutMs` (default 300s)
- No intermediate results exposed to workflow logic
- Progress indication happens at the CLI tool level only
## Available Adapters
| Package | Adapter | Tool |
@@ -45,3 +55,14 @@ extract:
```
Two-level merge: global → role override. Retry once on parse failure (feeds error back to LLM), then throw `ExtractError`.
## Error Handling
When adapters' underlying CLI tools (e.g., `cursor-agent` or `hermes`) fail, errors are surfaced **synchronously via rejection** with no fallback/retry logic:
- **Missing/unavailable tool**: `spawn_failed` error when CLI binary not found in `$PATH`
- **Non-zero exit code**: `non_zero_exit` error with captured stdout/stderr
- **Timeout**: `timeout` error when execution exceeds configured `timeoutMs`
- **Abort signal**: `aborted` error when `AbortSignal` triggers cancellation
All errors are immediately thrown as `Error` instances with descriptive messages (e.g., `"cursor-agent: exitCode=7 stdout=... stderr=..."`). No automatic retries or fallback adapters.
+19 -6
View File
@@ -5,20 +5,22 @@ Observation engine for autonomous agents — sense the world, react to changes,
## Core Pipeline
```
External World → Sense → Signal → Reflex → Workflow → Log
External World → Sense → Signal → Workflow → Log
compute() returns
{ signal, workflow }
```
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger Reflexes (prevents feedback loops).
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger further Senses (prevents feedback loops).
## Three Orthogonal Extension Points
## Two Extension Points
| Extension | Question | Nature |
|-----------|----------|--------|
| **Sense** | What to compute | `compute()` function |
| **Reflex** | When to compute | Declarative YAML (interval / on) |
| **Sense** | What to observe & when to react | `compute()` pure function + YAML config (interval / on) |
| **Workflow** | What to do | Roles + Moderator |
Each is independent. Reflex doesn't know compute internals, Sense doesn't know when it's triggered, Workflow doesn't know why it was started.
Senses own both the "what" (compute logic) and the "when" (config-driven scheduling). A Sense can trigger a Workflow directly by returning `{ signal, workflow: { name, prompt } }`.
## Two Event Types
@@ -31,3 +33,14 @@ Each is independent. Reflex doesn't know compute internals, Sense doesn't know w
- One worker per Workflow type (on-demand)
- Workers never talk to each other
- All user code runs in isolated Workers; kernel never loads user code directly
## Storage Systems
- **Log Store** — SQLite with WAL mode for audit trails and workflow state
- **Sense Databases** — Isolated SQLite per sense group for private data
- **Knowledge Store** — Vector search index for project context
- **Blob Store** — Content-addressable storage for large artifacts
## Signal Flow
Sense compute outputs are routed through signal routing logic that determines whether to emit a signal or trigger a workflow—never both simultaneously.
+10
View File
@@ -6,6 +6,8 @@
```bash
nerve init # scaffold a new workspace (nerve.yaml, senses/, workflows/)
nerve init --force # reinitialize workspace even if ~/.uncaged-nerve/ exists (preserves data/)
nerve init --from <git-url> # clone existing workspace from git repository
nerve validate # validate nerve.yaml config
nerve dev # run kernel foreground (development, Ctrl+C to stop)
nerve start # start daemon (background)
@@ -14,6 +16,14 @@ nerve status # check daemon health (uptime, senses, workflows)
nerve daemon # restart daemon (stop + start)
```
### Init Behavior
**Default `nerve init`**: Creates workspace at `~/.uncaged-nerve/`. If this directory already exists and is non-empty, **exits with error** requiring `--force` flag. No merge/overwrite logic — prevents accidental workspace destruction.
**Force mode `nerve init --force`**: Reinitializes workspace even if `~/.uncaged-nerve/` exists. **Preserves `data/` directory** (containing sense SQLite databases and logs) but overwrites all config files (`nerve.yaml`, `package.json`, etc.) and example senses.
**Git clone `nerve init --from <url>`**: Clones existing repository to `~/.uncaged-nerve/`. Requires empty target directory — fails if workspace already exists and is non-empty.
## Sense Management
```bash
+31
View File
@@ -24,6 +24,21 @@ type Config = { throttle?: string }
- `throw` only for programmer errors (bugs)
- No try-catch for flow control
### Result<T, E> Type
Defined in `@uncaged/nerve-core` (`packages/core/src/result.ts`):
```ts
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
**Discriminated union** with tagged `ok` field. Helper functions:
- `ok(value)``{ ok: true, value }`
- `err(error)``{ ok: false, error }`
**Exhaustive handling**: Pattern is `if (!result.ok) { handle error }` then access `result.value`.
No compiler enforcement - relies on manual discipline and TypeScript's flow control analysis.
## Naming
| Type | Style |
@@ -38,9 +53,25 @@ type Config = { throttle?: string }
- Always named exports, never default
- One module = one responsibility
### Module Naming Conventions
**Primary exports** use descriptive, unambiguous names:
- Functions: `createXxx()`, `parseXxx()`, `xxxAgent()` (e.g., `createCursorAdapter`, `cursorAgent`)
- Types: Domain-specific prefixes (e.g., `CursorAgentOptions`, `SenseComputeFn`, `ThreadContext`)
- Constants: `UPPER_SNAKE_CASE` with context (e.g., `DEFAULT_SENSE_SIGNAL_RETENTION`, `CURSOR_ADAPTER_DEFAULT_MS`)
**Avoiding ambiguity**:
- Package-scoped naming: `@uncaged/nerve-adapter-cursor` exports `cursorAgent`, `createCursorAdapter`
- Factory pattern: `createXxxAdapter()` for configurable instances, `xxxAdapter` for defaults
- Descriptive type prefixes prevent collision (e.g., `CursorAgentOptions` vs `HermesAgentOptions`)
## Async
- Always `async/await`, never `.then()` chains
- Use `AbortSignal` for cancellation: `AbortController` to create signals, pass to long-running operations
- `spawn-safe.ts` and adapter functions accept `abortSignal: AbortSignal | null` parameter
- On abort: child processes receive `SIGTERM`, async operations should check `signal.aborted`
- No enforced Biome/Vitest rules for AbortSignal usage (manual discipline required)
## No Dynamic Import
+14 -4
View File
@@ -19,10 +19,20 @@ nerve knowledge query --repo /path "query" # search specific repo
## Embedding
- Remote service: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
- Model: Dashscope text-embedding-v3 (1024 dims)
- Cache: content-addressable (sha256 of model+text), never expires
- Fallback: word-overlap scoring when embed service not configured
- **Default model**: Dashscope text-embedding-v3 (1024 dimensions)
- **Remote service**: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
- **Model configuration**: No mechanism to specify alternate models — hardcoded to text-embedding-v3 in remote service
- **Vector dimensions**: Fixed at 1024 (Float32Array, stored as 4096-byte Buffer blobs in SQLite)
- **Cache**: content-addressable (sha256 of model+text), never expires
- **Fallback**: word-overlap scoring when embed service not configured
### Configuration
The embedding model is **not configurable** through `knowledge.yaml` or other config files. The remote service at `embed.shazhou.workers.dev` uses Dashscope text-embedding-v3 exclusively. To use different models, you would need to:
1. Deploy your own embedding service compatible with the same API
2. Point `EMBED_SERVICE_URL` to your service
3. Ensure vector dimensions match (1024) or modify knowledge database schema
## Chunking
+40 -8
View File
@@ -2,18 +2,39 @@
A `compute()` function that samples or derives external data. The only first-class citizen in nerve.
## Behavior
## Contract
- Returns `T | null` — non-null emits a Signal, null is silent (no storage write, no signal, no downstream trigger)
- Each Sense has its own **independent SQLite database**
- Cross-sense reads are read-only via `peers` parameter
- Schema defined with Drizzle ORM (`schema.ts` is single source of truth)
Each sense module (`src/index.ts`) must export:
## Sense → Workflow
```ts
export { snapshots as table } from "./schema.ts"; // drizzle table for runtime to insert into
If `compute()` returns an object with `workflow: "name|maxRounds|prompt"`, the engine starts that workflow and does **not** emit a Signal. `workflow: null` or `""` means emit signal normally.
export async function compute(): Promise<ComputeResult<T>> { ... } // pure, no args
```
See `routeSenseComputeOutput` / `parseSenseWorkflowDirective` in `@uncaged/nerve-core`.
**Function Signature & Input Schema:**
- `compute()` is **parameterless** — no direct inputs, environment variables available
- No database access within compute — runtime provides isolated execution context
- Must be pure function (no side effects, no external API calls)
**Return Value Contract:**
- `ComputeResult<T>` = `null | { signal: T; workflow: WorkflowTrigger | null }`
- `null` → silent, no storage, no signal
- `{ signal: data, workflow: null }` → persist + emit signal
- `{ signal, workflow: WorkflowTrigger }` → persist + emit signal + trigger workflow
- Any other value → treated as `{ signal: value, workflow: null }`
**Error Handling & Serialization:**
- Exceptions caught by worker, logged as errors (no signal emitted)
- Signal payload must be JSON-serializable (passed via IPC)
- Invalid workflow triggers silently dropped (signal still emitted)
**Timeout & Scheduling Semantics:**
- Timeout priority: explicit config → AbortSignal → DEFAULT_TIMEOUT_MS (30s)
- Enforced via `Promise.race()` with timeout promise
- Grace period can trigger `process.exit(1)` after timeout (kills worker group)
- Interval translation: YAML config values used directly as milliseconds in `setInterval()`
- Jitter control: throttle mechanism prevents rapid-fire, single deferred trigger per throttle window
## Config (nerve.yaml)
@@ -27,3 +48,14 @@ senses:
interval: 30s # periodic trigger (optional)
on: [disk-pressure] # trigger on signals from other senses (optional)
```
## Manual Trigger Context
**`nerve sense trigger <name>`** sends IPC message to running daemon. The compute context is initialized as follows:
- **SQLite Database**: Opened in **read-write mode** at `data/senses/<name>.db`
- **Migrations**: All `*.sql` files in `senses/<name>/migrations/` applied in lexicographic order
- **Environment**: Inherits daemon process environment (no special secrets injection)
- **Arguments**: No runtime arguments or mock inputs supported — `compute()` is always pure function with no parameters
- **Isolation**: Runs in forked child process (worker) with full filesystem access within user permissions
- **Persistence**: Runtime automatically calls `db.insert(table).values(result.signal)` if compute returns non-null signal
+91
View File
@@ -0,0 +1,91 @@
# Signal Routing
Signal routing is the core mechanism that determines how Sense outputs flow through the Nerve system.
## Routing Logic
When a Sense `compute()` function returns non-null, the output goes through `routeSenseComputeOutput()` in `packages/core/src/sense-workflow-directive.ts`:
```
Sense compute() → non-null → routeSenseComputeOutput() → { signal, workflow }
kernel.ts → signal ALWAYS emitted + optional workflow start
```
## Two Output Formats
### 1. Explicit Format
```typescript
{
signal: any, // emitted as signal
workflow: { // optional workflow trigger
name: string,
maxRounds: number,
prompt: string,
dryRun: boolean
} | null
}
```
### 2. Shorthand Format
Any other value is treated as:
```typescript
{ signal: payload, workflow: null }
```
## Workflow Directive Parsing
## Concrete Routing Predicates
The routing decision is implemented in `routeSenseComputeOutput()` using these exact matching criteria:
### 1. Explicit Format Detection
```typescript
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal"))
```
- Payload must be a plain object
- Must have `signal` property (any value)
- Workflow extracted from `workflow` property or defaults to null
### 2. Workflow Validation
When workflow is non-null, it's validated via `parseWorkflowTrigger()`:
- `name`: non-empty string (trimmed)
- `maxRounds`: positive integer >= 1
- `prompt`: string
- `dryRun`: boolean
**Critical behavior**: Invalid workflows are silently dropped (become null) but signal emission continues. This prevents malformed workflow config from blocking signals.
### 3. Fallback to Shorthand
Any value that doesn't match explicit format becomes:
```typescript
{ signal: payload, workflow: null }
```
## Processing Flow
```typescript
// In kernel.ts handleSenseWorkerSignal()
const { signal: signalPayload, workflow } = routeResult.value;
// Signal is ALWAYS emitted when compute returns non-null
bus.emit({ id, senseId, payload: signalPayload, timestamp });
// Workflow is started ONLY if workflow is non-null
if (workflow !== null) {
workflowManager.startWorkflow(workflow.name, { ... });
}
```
## Legacy String Format (Deprecated)
The old `"name|maxRounds|prompt"` string format is converted to the structured format internally but should not be used in new code.
## Key Behaviors
1. **Signal priority**: Every non-null compute result emits a signal, regardless of workflow
2. **Additive behavior**: Valid workflow triggers are executed in addition to signal emission
3. **Failure tolerance**: Invalid workflow directives are silently ignored, signal still emits
4. **Structure-based routing**: No complex predicates - simply checks object structure and property existence
This routing mechanism ensures clean separation between perception (signals) and action (workflows) while maintaining backward compatibility.
+132
View File
@@ -0,0 +1,132 @@
# Storage Layer
Nerve uses multiple storage systems designed for different data types and access patterns.
## Core Storage Components
### 1. Log Store (`logs.db`)
Append-only audit trail implemented in SQLite with WAL mode.
**Schema:**
- `logs` — all system events (signals, workflow transitions, sense outputs)
- `meta` — key-value store for system metadata
- `workflow_runs` — materialized view of workflow execution state
**Key Features:**
- Atomic workflow state updates via transactions
- Thread message persistence for crash recovery
- Configurable log archival to JSONL files
- Full-text search across log entries
### 2. Sense Databases
Each sense group gets its own SQLite database for private state.
**Characteristics:**
- Isolated per sense group (e.g., `system-senses.db`)
- Managed by individual sense compute functions
- Drizzle ORM integration for schema management
- No cross-sense data sharing
### 3. Knowledge Store (`knowledge.db`)
Vector-enabled search index for project context.
**Contents:**
- Chunked source files with embeddings
- Curated knowledge cards from `.knowledge/`
- Semantic search capabilities
- Global vs. repo-scoped search modes
### 4. Blob Store (CAS)
Content-addressable storage for large artifacts.
**Design:**
- SHA-256 based file naming
- Automatic deduplication
- Used for workflow artifacts and large payloads
## Consistency & Isolation Mechanisms
### SQLite WAL Mode
All SQLite databases use `PRAGMA journal_mode=WAL` for:
- **Writer-reader concurrency** — readers don't block writers
- **Atomic writes** — each transaction is fully applied or rolled back
- **Crash recovery** — WAL provides consistent state after crashes
### Transaction Management
#### Log Store Transactions
Uses `BEGIN IMMEDIATE` transactions (`packages/store/src/log-store.ts`):
```typescript
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN IMMEDIATE"); // Exclusive write lock
try {
const result = fn();
db.exec("COMMIT");
return result;
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
```
**Key Operations:**
- `upsertWorkflowRun()` — atomically writes log entry + workflow state
- `archiveLogs()` — transactional export + delete + watermark update
#### Sense Database Isolation
- Each sense group has its own SQLite file (e.g., `system-senses.db`)
- No cross-sense transactions or coordination required
- Independent schema migrations per sense
- Private `_signals` table for signal history retention
### Process-Level Isolation
#### Worker Process Architecture
- **One worker per sense group** — prevents data races within group
- **One worker per workflow type** — isolated execution contexts
- **No shared memory** — all communication via IPC messages
#### Concurrency Control
Workflow manager enforces limits per workflow:
```yaml
workflows:
my-workflow:
concurrency: 2 # Max parallel threads
overflow: "queue" # or "drop"
maxQueue: 10 # Queue depth limit
```
### Consistency Guarantees & Failure Modes
**Strong Consistency (Single Database)**:
1. **Within Log Store** — ACID transactions with immediate consistency
2. **Within Sense DB** — WAL mode ensures atomic commits per database
3. **Workflow State**`upsertWorkflowRun()` atomically updates log + materialized view
**No Cross-Database Consistency**:
- No distributed transactions across multiple SQLite files
- Log Store and Sense Databases can temporarily diverge during failures
- Signal emission and workflow triggering are separate, non-atomic operations
**Failure Recovery Mechanisms**:
- **Sense worker crash**: State rebuilt from sense SQLite database on respawn
- **Workflow worker crash**: Thread state recovered from log store message history
- **Kernel crash**: All workers respawned, state recovered from persistent stores
- **Log Store corruption**: WAL recovery on database open
- **Sense DB corruption**: Migrations re-run, `_signals` table rebuilt if needed
**Rollback Scenarios**:
- **Log write failure**: Transaction rolled back, no state changes persisted
- **Sense compute failure**: Error logged, no signal/workflow emitted
- **Workflow failure**: Thread marked as failed in materialized view
- **IPC failure**: Worker respawned, pending operations lost (not rolled back)
## Archive Strategy
Logs older than retention window (default 30 days) are:
1. Exported to `data/archive/logs/YYYY-MM-DD.jsonl`
2. Deleted from active database
3. Watermark updated to prevent re-processing
This keeps the active database size bounded while preserving audit trails.
+152
View File
@@ -0,0 +1,152 @@
# Worker Isolation
Nerve's worker architecture ensures complete isolation between different types of user code while maintaining system stability.
## Process Architecture
```
Kernel (Main Process)
├── Sense Worker (Group A) ── sense-1, sense-2
├── Sense Worker (Group B) ── sense-3, sense-4
├── Workflow Worker (cleanup) ── cleanup workflow instances
└── Workflow Worker (review) ── review workflow instances
```
## Isolation Boundaries
### 1. Sense Workers
- **One worker per sense group** (configured in `nerve.yaml`)
- Groups share a child process but have isolated execution contexts
- Crash in one sense doesn't affect other groups
- Each group has its own SQLite database
### 2. Workflow Workers
- **One worker per workflow type** (spawned on-demand)
- Multiple threads of the same workflow share a worker process
- Concurrency limits enforced at the workflow level
- Workers terminate when no active threads remain
### 3. Kernel Protection
- **User code never runs in kernel process**
- All `compute()` and workflow role functions run in workers
- Kernel only handles IPC, scheduling, and coordination
- System remains stable even with infinite loops or crashes in user code
## Worker Lifecycle
### Sense Workers
```
nerve daemon start → spawn worker per group → long-lived process
→ hot reload on file changes
→ respawn on crash
```
### Workflow Workers
```
workflow trigger → check existing worker → reuse or spawn
→ execute thread
→ terminate when idle
```
## Communication Patterns
### Kernel ↔ Sense Worker
- IPC via child process stdio
- JSON-formatted messages
- Worker reports signals back to kernel
- Bidirectional: kernel can request immediate computes
### Kernel ↔ Workflow Worker
- Similar IPC protocol
- Workflow definition loaded in worker
- Role execution results streamed back
- Thread state managed in kernel
## Resource Limits & Control
### Timeout Enforcement
Configurable timeouts per sense (in `nerve.yaml`):
```yaml
senses:
my-sense:
timeout: 30000 # Execution timeout (ms)
gracePeriod: 5000 # Grace period before hard kill
```
**Timeout Implementation:**
- `AbortController` for async operations
- `Promise.race()` between compute and timeout
- Grace period triggers `process.exit(1)` to kill entire worker group
### Memory & CPU Limits
**No Application-Level Resource Quotas**:
- No memory caps, CPU throttling, or disk I/O limits enforced by Nerve
- Workers can consume arbitrary system resources until OS limits
- No cgroup/container isolation — full filesystem access within user permissions
- No syscall filtering (no seccomp restrictions)
**OS-Level Constraints Only**:
- Process memory limited by system `ulimit -m`
- CPU usage bounded by scheduler only
- Network requests unrestricted
- Can spawn additional processes (not tracked by Nerve)
### Concurrency Control
#### Sense Workers
- One active compute per sense at a time (serialized via promise chains)
- No memory sharing between sense groups
- Crash isolation: one sense crash doesn't affect other groups
#### Workflow Workers
Per-workflow limits configured in `nerve.yaml`:
```yaml
workflows:
my-workflow:
concurrency: 2 # Max parallel threads
overflow: "drop" # or "queue"
maxQueue: 10 # Queue size limit
```
### Process Management
#### Signal Handling
Workers ignore session broadcast signals (SIGINT/SIGTERM):
```typescript
// Workers ignore terminal signals; kernel coordinates shutdown
process.on("SIGINT", () => {});
process.on("SIGTERM", () => {});
```
#### Graceful Shutdown & State Handoff
**Sense Workers**:
- IPC `shutdown` message → `process.exit(0)` (immediate)
- No graceful termination period for senses
- State rebuilt from SQLite on respawn (no handoff needed)
**Workflow Workers**:
- IPC `shutdown` → wait for in-flight threads to complete
- Drain timeout: `WORKER_SHUTDOWN_TIMEOUT_MS` (10s)
- If threads don't complete → `SIGKILL` force termination
- Thread state preserved in log store for crash recovery
**State Handoff Mechanism**:
- No explicit state transfer between old/new workers
- Sense workers: SQLite database contains full state
- Workflow workers: Log store contains thread message history
- Kernel coordinates recovery via `recoverThreadsForWorker()`
## Failure Handling
### Worker Crashes
- **Sense workers**: Automatic respawn after 1s delay, state rebuilt from DB
- **Workflow workers**: Crash recovery from log store thread messages
- **Kernel protection**: Main process continues, marks affected runs as crashed
- **Crash limits**: Max 5 crashes per workflow in 60s window (prevents infinite respawn)
### Resource Exhaustion
- **Memory**: Worker process killed by OS, kernel respawns automatically
- **Compute timeout**: Grace period → hard kill → respawn
- **Infinite loops**: Timeout enforcement prevents hanging indefinitely
This architecture allows Nerve to run untrusted or experimental code safely while maintaining system availability.
+66 -3
View File
@@ -6,8 +6,8 @@ Stateful multi-step execution driven by Roles and a Moderator.
- **Workflow** — definition with concurrency strategy
- **Thread** — one execution instance, unique `runId`
- **Role** — executes actions (has side effects). `(start, messages) → { content, meta }`
- **Moderator** — pure routing function. `(context) → next role | END`
- **Role** — executes actions (has side effects). `(ctx: ThreadContext) → Promise<RoleResult<M>>`
- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END`
## Thread Lifecycle
@@ -54,6 +54,69 @@ const workflow: WorkflowDefinition<MyMeta> = {
```
- `adapter: AgentFn` — direct function reference
- `prompt: string | ((start, messages) => Promise<string>)` — static or dynamic
- `prompt: string | ((ctx: ThreadContext) => Promise<string>)` — static or dynamic
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
- `extract: LlmExtractorConfig` — provider for structured extraction
## Runtime Enforcement Mechanisms
### Role Authority & Validation
**Role Function Lookup**:
- Roles accessed via `def.roles[nextRole]` dictionary lookup
- Unknown roles trigger immediate workflow error (`Unknown role: ${nextRole}`)
- No dynamic role registration during execution
**Result Validation** (`validateRoleResult()`):
```typescript
// Required return shape from every role function
{ content: string, meta: Record<string, unknown> }
```
- `content` must be string (non-string → workflow error)
- `meta` must be plain object (array/null/primitive → workflow error)
- Validation failure terminates thread immediately
### Moderator Authority & Routing Control
**Next Role Selection**:
- Moderator must return role name from `roles` keys OR `END` symbol
- Called after every role completion (receives full context)
- No validation of role name until execution attempt
- Pure function constraint: cannot perform side effects
**Causal Chain Integrity**:
- Moderator receives immutable **ThreadContext**: `{ threadId, start, steps }`
- Steps array contains ALL role outputs in chronological order
- No role can modify prior steps or start metadata
- Thread context built from log store on crash recovery
### Unauthorized Command Event Prevention
**Message Flow Control**:
- Role functions have NO direct access to kernel IPC
- All outputs flow through `sendWorkflowMessage()` wrapper
- Worker process validates messages before kernel transmission
- No direct log store database access from roles
**Process Isolation**:
- Roles execute in forked worker processes (not kernel)
- File system access limited to user permissions
- No network isolation (roles can make arbitrary HTTP calls)
- Worker has read/write access to workflow workspace only
### Concurrent Thread Management
**Kill Flag Implementation**:
```typescript
type KillFlag = { value: boolean };
// Checked before role execution and after completion
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
```
**Concurrency Enforcement**:
- Workflow manager enforces per-workflow limits in kernel
- Excess threads queued/dropped per overflow policy
- No role can spawn additional threads (no access to workflow manager)
+34 -10
View File
@@ -3,7 +3,7 @@
## Core Concepts
```
External World → Sense → Signal → Reflex → Workflow → Log
External World → Sense → Signal → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
@@ -14,19 +14,18 @@ External World → Sense → Signal → Reflex → Workflow → Log
| Concept | What it is |
|---------|-----------|
| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
| **Sense** | A `compute()` function that samples or derives data. Returns `ComputeResult<T>` — non-null emits a Signal (and optionally triggers a Workflow), null is silent. Each Sense has its own SQLite database. Scheduling (interval, on) is configured in nerve.yaml. |
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. Cannot trigger Senses — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
- **Two extension points**: Sense (what to observe + when), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
- **Causality is one-directional**: External world → Sense → Signal → Workflow + Log. Logs are the end of the chain.
@@ -94,9 +93,34 @@ For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type ReflexConfig =
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
| { kind: "workflow"; workflow: string; on: string[] | null };
type ComputeResult<T> =
| null
| { signal: T; workflow: WorkflowTrigger | null };
```
### Workflow authoring (user modules)
Roles and moderators take **ThreadContext** (`threadId`, `start`, `steps`) — not separate `StartStep` / message arrays.
```typescript
import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
type MyMeta = { round: number };
async function planner(ctx: ThreadContext): Promise<RoleResult<MyMeta>> {
void ctx.start;
void ctx.steps;
return { content: "plan", meta: { round: ctx.steps.length } };
}
const workflow: WorkflowDefinition<Record<"planner", MyMeta>> = {
name: "example",
roles: { planner },
moderator(ctx: ThreadContext<Record<"planner", MyMeta>>) {
return ctx.steps.length === 0 ? "planner" : END;
},
};
```
## Modules & Exports
+5 -5
View File
@@ -1,4 +1,4 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
export type CursorAgentMode = "plan" | "ask" | "default";
@@ -96,16 +96,16 @@ export function createCursorAdapter(config: CursorAdapterConfig): AgentFn {
const timeoutMs = config.timeout;
const mode = config.mode ?? "default";
return async (prompt: string, context: WorkflowContext): Promise<string> => {
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
const run = await cursorAgent({
prompt,
mode,
model: config.model,
cwd: context.workdir,
cwd: process.cwd(),
env: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
dryRun: false,
abortSignal: null,
});
if (!run.ok) {
throwCursorSpawnError(run.error);
+4 -4
View File
@@ -1,4 +1,4 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
/**
@@ -96,7 +96,7 @@ export function createHermesAdapter(config: AgentConfig): AgentFn {
const modelFromConfig = config.model === "auto" ? null : config.model;
const timeoutMs = config.timeout;
return async (prompt: string, context: WorkflowContext): Promise<string> => {
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
const run = await hermesAgent({
prompt,
model: modelFromConfig,
@@ -106,8 +106,8 @@ export function createHermesAdapter(config: AgentConfig): AgentFn {
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
env: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
dryRun: false,
abortSignal: null,
});
if (!run.ok) {
throwHermesSpawnError(run.error);
+1
View File
@@ -17,6 +17,7 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build",
"test": "vitest run"
},
"dependencies": {
@@ -7,7 +7,6 @@ import { describe, expect, it } from "vitest";
import {
buildSenseIndexTs,
buildSenseMigrationSql,
buildSensePackageJson,
buildSenseSchemaTs,
validateResourceName,
} from "../commands/create.js";
@@ -46,20 +45,11 @@ describe("buildSenseMigrationSql", () => {
});
});
describe("buildSensePackageJson", () => {
it("includes esbuild script and sense name", () => {
const pkg = JSON.parse(buildSensePackageJson("my-sense"));
expect(pkg.name).toBe("nerve-sense-my-sense");
expect(pkg.scripts.build).toContain("esbuild");
expect(pkg.scripts.build).toContain("src/index.ts");
expect(pkg.devDependencies.esbuild).toBeTruthy();
});
});
describe("buildSenseIndexTs", () => {
it("embeds sense id in stub with TypeScript types", () => {
const ts = buildSenseIndexTs("my-sense");
expect(ts).toContain("my-sense");
expect(ts).toContain("export { mySense as table }");
expect(ts).toContain("export async function compute");
expect(ts).toContain("LibSQLDatabase");
expect(ts).toContain("Promise<SenseResult>");
@@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildWorkflowPackageJson, buildWorkflowScaffold } from "../commands/create.js";
import { buildWorkflowScaffold } from "../commands/create.js";
let tmpDir: string;
@@ -33,9 +33,11 @@ describe("buildWorkflowScaffold", () => {
expect(indexTs).toContain("@uncaged/nerve-core");
});
it("root index wires moderator and END", () => {
it("root index wires moderator with ThreadContext and END", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("moderator");
expect(indexTs).toContain("ThreadContext");
expect(indexTs).toContain("ctx.steps.length");
expect(indexTs).toContain("END");
});
@@ -46,9 +48,13 @@ describe("buildWorkflowScaffold", () => {
expect(indexTs).toContain("./roles/main/index.js");
});
it("main role module exports mainRole function", () => {
it("main role module exports mainRole with ThreadContext", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("test");
expect(roleMainIndexTs).toContain("export async function mainRole");
expect(roleMainIndexTs).toContain("ThreadContext");
expect(roleMainIndexTs).toContain("RoleResult");
expect(roleMainIndexTs).not.toContain("StartStep");
expect(roleMainIndexTs).not.toContain("WorkflowMessage");
});
it("uses different names per call", () => {
@@ -75,21 +81,6 @@ describe("buildWorkflowScaffold", () => {
const { roleMainPromptMd } = buildWorkflowScaffold("my-flow");
expect(roleMainPromptMd).toContain("# my-flow — main role");
});
it("package.json defines esbuild bundling to dist/", () => {
const pkg = JSON.parse(buildWorkflowPackageJson("my-flow")) as {
scripts: { build: string };
devDependencies: { esbuild: string };
};
expect(pkg.scripts.build).toContain("esbuild");
expect(pkg.scripts.build).toContain("--outdir=dist");
expect(pkg.devDependencies.esbuild).toBeTruthy();
});
it("buildWorkflowScaffold includes package.json body", () => {
const { packageJson } = buildWorkflowScaffold("wf");
expect(JSON.parse(packageJson).scripts.build).toContain("esbuild");
});
});
describe("workflow scaffold file writing (simulated)", () => {
+13 -18
View File
@@ -122,54 +122,49 @@ describe("e2e create", () => {
});
it(
"create workflow scaffolds sources and package.json with esbuild build",
{ timeout: 10_000 },
"create workflow scaffolds sources and root build emits dist/workflows/<name>/index.js",
{ timeout: 120_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
await runTestCli(fakeHome, ["init", "--force"]);
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
expect(wf.stdout).toContain("✅");
const pkgPath = join(nerveRoot, "workflows", "e2e-flow", "package.json");
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
const mainRolePath = join(nerveRoot, "workflows", "e2e-flow", "roles", "main", "index.ts");
expect(existsSync(pkgPath)).toBe(true);
expect(JSON.parse(readFileSync(pkgPath, "utf8")).scripts.build).toContain("esbuild");
const wfDir = join(nerveRoot, "workflows", "e2e-flow");
const indexPath = join(wfDir, "index.ts");
const mainRolePath = join(wfDir, "roles", "main", "index.ts");
expect(existsSync(join(wfDir, "package.json"))).toBe(false);
expect(existsSync(indexPath)).toBe(true);
expect(existsSync(mainRolePath)).toBe(true);
expect(readFileSync(indexPath, "utf8")).toContain('name: "e2e-flow"');
expect(readFileSync(mainRolePath, "utf8")).toContain("e2e-flow started");
expect(existsSync(join(nerveRoot, "dist", "workflows", "e2e-flow", "index.js"))).toBe(true);
},
);
it(
"create sense scaffolds src/index.ts, src/schema.ts, package.json and migration",
{ timeout: 60_000 },
"create sense scaffolds src/, migration, and root build emits dist/senses/<name>/index.js",
{ timeout: 120_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
await runTestCli(fakeHome, ["init", "--force"]);
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
expect(sense.exitCode).toBe(0);
expect(sense.stdout).toContain("✅");
const base = join(nerveRoot, "senses", "e2e-sense");
expect(existsSync(join(base, "package.json"))).toBe(true);
expect(existsSync(join(base, "package.json"))).toBe(false);
expect(existsSync(join(base, "src", "index.ts"))).toBe(true);
expect(existsSync(join(base, "src", "schema.ts"))).toBe(true);
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
const pkg = JSON.parse(readFileSync(join(base, "package.json"), "utf8"));
expect(pkg.scripts.build).toContain("esbuild");
// pnpm install + build should produce index.js
expect(existsSync(join(base, "index.js"))).toBe(true);
expect(existsSync(join(nerveRoot, "dist", "senses", "e2e-sense", "index.js"))).toBe(true);
},
);
+79 -22
View File
@@ -37,7 +37,15 @@
* ```
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -61,6 +69,27 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
function resolveDrizzleOrmPackageRoot(): string {
const requireFromDaemon = createRequire(join(nerveDaemonRoot, "package.json"));
const entry = requireFromDaemon.resolve("drizzle-orm");
let dir = dirname(entry);
for (let i = 0; i < 12; i += 1) {
const pkgPath = join(dir, "package.json");
if (existsSync(pkgPath)) {
try {
const name = (JSON.parse(readFileSync(pkgPath, "utf8")) as { name: string }).name;
if (name === "drizzle-orm") return dir;
} catch {
// keep walking
}
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error("Could not resolve drizzle-orm package root for e2e harness");
}
const nerveYamlTemplate = `senses:
counter:
group: e2e
@@ -88,9 +117,9 @@ const echoWorkflowIndexJs = `const END = "__end__";
export default {
name: "echo",
roles: {
echo: async (start, _messages) => {
echo: async (ctx) => {
await new Promise((r) => setTimeout(r, 350));
const p = typeof start.content === "string" ? start.content : "";
const p = typeof ctx.start.content === "string" ? ctx.start.content : "";
return {
content: p.length > 0 ? "echo:" + p : "echo:empty",
meta: {},
@@ -121,17 +150,30 @@ api:
host: 127.0.0.1
`;
/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */
const counterMigration = `-- no-op migration for e2e counter sense
SELECT 1;
/** Schema for sense signal rows persisted via \`db.insert(table)\` (see sense-runtime). */
const counterMigration = `CREATE TABLE IF NOT EXISTS counter_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
count INTEGER,
launched INTEGER,
idle INTEGER
);
`;
/**
* Minimal counter sense — each compute returns an incrementing count.
* Does NOT touch the DB directly; signal persistence is handled by the daemon
* (`runtime.persistSignal`) which writes to `_signals` automatically.
* Does NOT touch the DB directly in compute(); the daemon inserts into \`table\`
* and persistSignal handles \`_signals\`.
*/
const counterIndexJs = `let _count = 0;
const counterIndexJs = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("counter_signals", {
id: integer("id").primaryKey({ autoIncrement: true }),
count: integer("count"),
launched: integer("launched"),
idle: integer("idle"),
});
let _count = 0;
export async function compute(_db, _peers, _options) {
_count += 1;
return { signal: { count: _count }, workflow: null };
@@ -139,12 +181,21 @@ export async function compute(_db, _peers, _options) {
`;
/** First trigger launches local noop workflow; later triggers emit a plain signal. */
const counterIndexJsWithNoopWorkflow = `let _launched = false;
const counterIndexJsWithNoopWorkflow = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("counter_signals", {
id: integer("id").primaryKey({ autoIncrement: true }),
count: integer("count"),
launched: integer("launched"),
idle: integer("idle"),
});
let _launched = false;
export async function compute(_db, _peers, _options) {
if (!_launched) {
_launched = true;
return {
signal: { launched: true },
signal: { launched: 1 },
workflow: {
name: "noop",
maxRounds: 3,
@@ -153,7 +204,7 @@ export async function compute(_db, _peers, _options) {
},
};
}
return { signal: { idle: true }, workflow: null };
return { signal: { idle: 1 }, workflow: null };
}
`;
@@ -209,7 +260,8 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true });
mkdirSync(join(nerveRoot, "senses", "counter", "migrations"), { recursive: true });
mkdirSync(join(nerveRoot, "workflows", "echo", "dist"), { recursive: true });
mkdirSync(join(nerveRoot, "dist", "senses", "counter"), { recursive: true });
mkdirSync(join(nerveRoot, "dist", "workflows", "echo"), { recursive: true });
writeFileSync(
join(nerveRoot, "nerve.yaml"),
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
@@ -221,20 +273,19 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
"utf8",
);
writeFileSync(
join(nerveRoot, "senses", "counter", "index.js"),
join(nerveRoot, "dist", "senses", "counter", "index.js"),
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
"utf8",
);
writeFileSync(
join(nerveRoot, "workflows", "echo", "dist", "index.js"),
join(nerveRoot, "dist", "workflows", "echo", "index.js"),
echoWorkflowIndexJs,
"utf8",
);
if (withNoopWorkflow) {
mkdirSync(join(nerveRoot, "workflows", "noop", "dist"), { recursive: true });
mkdirSync(join(nerveRoot, "workflows", "noop", "migrations"), { recursive: true });
mkdirSync(join(nerveRoot, "dist", "workflows", "noop"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "noop", "dist", "index.js"),
join(nerveRoot, "dist", "workflows", "noop", "index.js"),
noopWorkflowIndexJs,
"utf8",
);
@@ -267,11 +318,17 @@ function useNoopWorkflow(opts: StartTestDaemonOpts): boolean {
*/
export function linkWorkspaceDaemonIntoNerveRoot(nerveRoot: string): void {
const daemonPkgRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
const linkDir = join(nerveRoot, "node_modules", "@uncaged");
const linkPath = join(linkDir, "nerve-daemon");
const nm = join(nerveRoot, "node_modules");
mkdirSync(nm, { recursive: true });
const linkDir = join(nm, "@uncaged");
mkdirSync(linkDir, { recursive: true });
if (existsSync(linkPath)) return;
symlinkSync(daemonPkgRoot, linkPath);
const linkPath = join(linkDir, "nerve-daemon");
if (!existsSync(linkPath)) symlinkSync(daemonPkgRoot, linkPath);
const drizzlePkgRoot = resolveDrizzleOrmPackageRoot();
const drizzleLink = join(nm, "drizzle-orm");
if (!existsSync(drizzleLink)) symlinkSync(drizzlePkgRoot, drizzleLink);
}
/**
@@ -202,10 +202,9 @@ describe("e2e init", () => {
// Verify key files exist
expect(existsSync(join(nerveRoot, "nerve.yaml"))).toBe(true);
expect(existsSync(join(nerveRoot, "package.json"))).toBe(true);
expect(existsSync(join(nerveRoot, "pnpm-workspace.yaml"))).toBe(true);
expect(existsSync(join(nerveRoot, "scripts", "build.mjs"))).toBe(true);
expect(existsSync(join(nerveRoot, "biome.json"))).toBe(true);
expect(existsSync(join(nerveRoot, ".gitignore"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "package.json"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe(
@@ -214,19 +213,14 @@ describe("e2e init", () => {
expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true);
const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8");
expect(pkgJson).toContain('"@uncaged/nerve-skills": "latest"');
expect(pkgJson).toContain('"build": "pnpm -r build"');
expect(pkgJson).not.toContain("nerve-skills");
expect(pkgJson).toContain('"build": "node scripts/build.mjs"');
expect(pkgJson).toContain('"esbuild": "^0.27.0"');
const workspaceYaml = readFileSync(join(nerveRoot, "pnpm-workspace.yaml"), "utf8");
expect(workspaceYaml).toContain("workflows/*");
expect(workspaceYaml).toContain("senses/*");
const sensePkgJson = readFileSync(
join(nerveRoot, "senses", "cpu-usage", "package.json"),
"utf8",
);
expect(sensePkgJson).toContain("nerve-sense-cpu-usage");
expect(sensePkgJson).toContain("esbuild");
const buildScript = readFileSync(join(nerveRoot, "scripts", "build.mjs"), "utf8");
expect(buildScript).toContain('path.join(root, "senses")');
expect(buildScript).toContain('path.join(root, "workflows")');
expect(buildScript).toContain("dist");
});
it("generated nerve.yaml passes validate", { timeout: 10_000 }, async () => {
+33 -66
View File
@@ -20,39 +20,18 @@ export type WorkflowScaffoldFiles = {
indexTs: string;
roleMainIndexTs: string;
roleMainPromptMd: string;
packageJson: string;
};
export function buildWorkflowPackageJson(name: string): string {
return `${JSON.stringify(
{
name: `nerve-workflow-${name}`,
private: true,
type: "module",
scripts: {
build:
"esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external",
},
devDependencies: {
esbuild: "^0.27.0",
},
},
null,
2,
)}\n`;
}
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
return {
indexTs: buildWorkflowIndexTs(name),
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
packageJson: buildWorkflowPackageJson(name),
};
}
function buildWorkflowIndexTs(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-core";
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { mainRole } from "./roles/main/index.js";
@@ -64,8 +43,8 @@ const workflow: WorkflowDefinition<Record<"main", MainMeta>> = {
roles: {
main: mainRole,
},
moderator({ steps }) {
if (steps.length === 0) {
moderator(ctx: ThreadContext<Record<"main", MainMeta>>) {
if (ctx.steps.length === 0) {
return "main";
}
return END;
@@ -77,18 +56,16 @@ export default workflow;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
/**
* Main role — implement LLM calls, scripts, HTTP, etc.
* Optional: align behavior with \`prompt.md\` in this directory.
*/
export async function mainRole(
start: StartStep,
messages: WorkflowMessage[],
ctx: ThreadContext,
): Promise<RoleResult<Record<string, unknown>>> {
void start;
void messages;
void ctx;
// TODO: implement your role logic here
return {
content: "${name} started",
@@ -134,32 +111,14 @@ export const ${exportName} = sqliteTable("${table}", {
`;
}
export function buildSensePackageJson(name: string): string {
return `${JSON.stringify(
{
name: `nerve-sense-${name}`,
private: true,
type: "module",
scripts: {
build:
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
},
devDependencies: {
esbuild: "^0.27.0",
"drizzle-orm": "*",
},
},
null,
2,
)}\n`;
}
export function buildSenseIndexTs(senseId: string): string {
const exportName = senseIdToSchemaExportName(senseId);
return `import type { LibSQLDatabase } from "drizzle-orm/libsql";
import { ${exportName} } from "./schema.js";
export { ${exportName} as table } from "./schema.js";
type SenseResult = {
signal: { label: string; ts: number };
workflow: null;
@@ -247,30 +206,39 @@ const createWorkflowCommand = defineCommand({
mkdirSync(workflowDir, { recursive: true });
const scaffold = buildWorkflowScaffold(args.name);
writeFile(join(workflowDir, "package.json"), scaffold.packageJson);
writeFile(join(workflowDir, "index.ts"), scaffold.indexTs);
writeFile(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs);
writeFile(join(workflowDir, "roles", "main", "prompt.md"), scaffold.roleMainPromptMd);
process.stdout.write("✅ Workflow scaffolded:\n");
process.stdout.write(` ${join(workflowDir, "package.json")}\n`);
process.stdout.write(` ${join(workflowDir, "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "prompt.md")}\n`);
process.stdout.write("\nBuilding workspace (workflows + senses)…\n");
try {
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
process.stdout.write(
`✅ Build complete — ${join("dist", "workflows", args.name, "index.js")} ready.\n`,
);
} catch {
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
}
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(
` 1. In ${workflowDir}, run \`npm install\` then \`npm run build\` (bundles to dist/index.js).\n`,
);
process.stdout.write(" 2. Add to nerve.yaml:\n");
process.stdout.write(" 1. Add to nerve.yaml:\n");
process.stdout.write(" workflows:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" concurrency: 1\n");
process.stdout.write(" overflow: drop\n");
process.stdout.write(
` 3. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
` 2. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
);
process.stdout.write(
` 4. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
` 3. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
);
process.stdout.write(
` 4. After edits, run \`pnpm run build\` from the workspace root (${nerveRoot}); output is dist/workflows/<name>/index.js.\n`,
);
process.stdout.write(" 5. Run `nerve start` to launch the daemon.\n");
},
@@ -311,26 +279,23 @@ const createSenseCommand = defineCommand({
mkdirSync(join(senseDir, "src"), { recursive: true });
mkdirSync(join(senseDir, "migrations"), { recursive: true });
writeFile(join(senseDir, "package.json"), buildSensePackageJson(args.name));
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name));
writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name));
process.stdout.write("✅ Sense scaffolded:\n");
process.stdout.write(` ${join(senseDir, "package.json")}\n`);
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`);
process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`);
process.stdout.write("\nInstalling sense dependencies and building…\n");
process.stdout.write("\nBuilding workspace (senses + workflows)…\n");
try {
await spawnAsync("pnpm", ["install", "--no-cache", "--ignore-workspace"], senseDir);
await spawnAsync("pnpm", ["run", "build"], senseDir);
process.stdout.write("✅ Build complete — index.js ready.\n");
} catch {
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
process.stdout.write(
`⚠️ Build failed. Run manually:\n cd ${senseDir} && pnpm install --no-cache --ignore-workspace && pnpm run build\n`,
` Build complete — ${join("dist", "senses", args.name, "index.js")} ready.\n`,
);
} catch {
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
}
process.stdout.write("\n💡 Next steps:\n");
@@ -343,7 +308,9 @@ const createSenseCommand = defineCommand({
process.stdout.write(
` 2. Edit ${join(senseDir, "src", "index.ts")} to implement ${args.name}.\n`,
);
process.stdout.write(` 3. Re-run \`pnpm run build\` in ${senseDir} after edits.\n`);
process.stdout.write(
` 3. Re-run \`pnpm run build\` from the workspace root (${nerveRoot}) after edits.\n`,
);
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
},
});
+66 -42
View File
@@ -17,11 +17,6 @@ senses:
interval: 10s
`;
const PNPM_WORKSPACE_YAML = `packages:
- 'workflows/*'
- 'senses/*'
`;
const BIOME_JSON = `{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"formatter": {
@@ -54,17 +49,20 @@ const PACKAGE_JSON = `${JSON.stringify(
private: true,
type: "module",
scripts: {
build: "pnpm -r build",
build: "node scripts/build.mjs",
},
dependencies: {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"@uncaged/nerve-skills": "latest",
"drizzle-orm": "latest",
zod: "^4.3.6",
},
devDependencies: {
"@biomejs/biome": "latest",
"@types/node": "^22.0.0",
"drizzle-kit": "latest",
esbuild: "^0.27.0",
typescript: "^5.7.0",
},
pnpm: {
onlyBuiltDependencies: ["esbuild"],
@@ -74,6 +72,54 @@ const PACKAGE_JSON = `${JSON.stringify(
2,
)}\n`;
const BUILD_MJS = `import * as esbuild from "esbuild";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const dist = path.join(root, "dist");
const opts = {
bundle: true,
platform: "node",
format: "esm",
packages: "external",
};
function listDirs(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((name) => !name.startsWith(".") && !name.startsWith("_"))
.map((name) => ({ name, full: path.join(dir, name) }))
.filter(({ full }) => fs.statSync(full).isDirectory());
}
async function main() {
// Clean dist/
fs.rmSync(dist, { recursive: true, force: true });
for (const { name, full } of listDirs(path.join(root, "senses"))) {
const entry = path.join(full, "src", "index.ts");
if (!fs.existsSync(entry)) continue;
const outfile = path.join(dist, "senses", name, "index.js");
fs.mkdirSync(path.dirname(outfile), { recursive: true });
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
}
for (const { name, full } of listDirs(path.join(root, "workflows"))) {
const entry = path.join(full, "index.ts");
if (!fs.existsSync(entry)) continue;
const outfile = path.join(dist, "workflows", name, "index.js");
fs.mkdirSync(path.dirname(outfile), { recursive: true });
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
}
}
await main();
`;
const GITIGNORE = `data/
logs/
nerve.pid
@@ -83,31 +129,26 @@ knowledge.db
const NERVE_SKILLS_MDC = `---
description: >-
Nerve skills package — where bundled Agent Skills live in this workspace and how to use them
Where Agent Skills live in this Nerve workspace and how to use them with Cursor
alwaysApply: true
---
# Nerve skills (\`@uncaged/nerve-skills\`)
# Nerve Agent Skills
This workspace lists **@uncaged/nerve-skills** in \`package.json\`. It ships **Agent Skills** (one directory per skill, each with a \`SKILL.md\`) for Nerve development and related tasks.
**Agent Skills** are directories that contain a \`SKILL.md\` (with YAML frontmatter). Cursor loads them from **Project Skills** paths (for example \`.cursor/skills/\` or your global skills directory).
## After install
## Getting Nerve-oriented skills
Run your package manager in this workspace (e.g. \`pnpm install\`, \`npm install\` — whatever \`nerve init\` used). Then skills are on disk at:
There is no separate npm package for skills in the default workspace. To align with Nerve CLI, daemon, and monorepo conventions:
- \`node_modules/@uncaged/nerve-skills/<skill-id>/SKILL.md\`
Example (current catalog):
- **nerve-dev** — Nerve architecture, CLI, sense/workflow patterns, \`nerve.yaml\`, and conventions: read \`node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`.
1. Copy or symlink skill folders from the **Nerve** repository (e.g. \`packages/skills/*/\`) into \`.cursor/skills/\`, **or**
2. Follow project documentation and \`CLAUDE.md\` / \`.cursor/rules/\` in this repo.
## How to use in an agent
1. For tasks that match a skill’s **description** (in the \`SKILL.md\` frontmatter), open that \`SKILL.md\` and follow its structure and checklists.
2. Prefer the skill as the **source of truth** for Nerve-specific conventions over generic assumptions.
3. If the catalog grows, new skills appear as new sibling directories under \`node_modules/@uncaged/nerve-skills/\`.
Do not commit \`node_modules\`; the dependency is the supported way to get and update skills to match \`@uncaged/nerve-skills\` on npm.
1. When a task matches a skill’s **description** (in \`SKILL.md\` frontmatter), open that file and follow its steps.
2. Prefer those conventions for sense/workflow layout, \`nerve.yaml\`, and tooling over generic guesses.
3. Keep skills versioned with your dotfiles or project; update them when you upgrade Nerve.
`;
const execFileAsync = promisify(execFile);
@@ -124,6 +165,8 @@ export const cpuUsage = sqliteTable("cpu_usage", {
const CPU_INDEX_TS = `import { cpus } from "node:os";
export { cpuUsage as table } from "./schema.js";
type SenseResult = {
signal: { model: string; loadPercent: number; ts: number };
workflow: null;
@@ -154,24 +197,6 @@ export async function compute(): Promise<SenseResult> {
}
`;
const CPU_SENSE_PACKAGE_JSON = `${JSON.stringify(
{
name: "nerve-sense-cpu-usage",
private: true,
type: "module",
scripts: {
build:
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
},
devDependencies: {
esbuild: "^0.27.0",
"drizzle-orm": "*",
},
},
null,
2,
)}\n`;
const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
@@ -334,10 +359,9 @@ async function runInitWorkspace(force: boolean, skipInstall = false): Promise<vo
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
writeFile(join(nerveRoot, "pnpm-workspace.yaml"), PNPM_WORKSPACE_YAML);
writeFile(join(nerveRoot, "scripts", "build.mjs"), BUILD_MJS);
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
writeFile(join(nerveRoot, "senses", "cpu-usage", "package.json"), CPU_SENSE_PACKAGE_JSON);
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"), CPU_INDEX_TS);
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"), CPU_SCHEMA_TS);
writeFile(
+1
View File
@@ -20,6 +20,7 @@
"test": "vitest run"
},
"dependencies": {
"drizzle-orm": "1.0.0-beta.23-c10d10c",
"yaml": "^2.8.3"
},
"devDependencies": {
+2 -6
View File
@@ -12,12 +12,7 @@ export type {
ComputeResult,
} from "./config.js";
export type { Signal, SenseInfo } from "./sense.js";
export type {
SenseBlobStore,
SenseComputeOptions,
SensePeerMap,
SenseComputeFn,
} from "./sense-contract.js";
export type { SenseComputeFn, SenseModule } from "./sense-contract.js";
export { labelSenseTrigger, senseTriggerLabels } from "./sense-trigger-labels.js";
export type {
WorkflowMessage,
@@ -25,6 +20,7 @@ export type {
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
+13 -37
View File
@@ -1,46 +1,22 @@
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import type { ComputeResult } from "./config.js";
/**
* Minimal read/write/exists interface for the CAS blob store injected into
* sense compute functions (RFC-001 §8). Matches BlobStore from @uncaged/nerve-store.
*/
export type SenseBlobStore = {
write: (content: string | Uint8Array | Buffer) => string;
read: (hash: string) => Buffer | null;
exists: (hash: string) => boolean;
};
/**
* Options injected by the engine into every compute invocation.
* `signal` is always present; `blobs` is only available when running
* inside the sense worker (RFC-001 §8).
*/
export type SenseComputeOptions = {
signal: AbortSignal;
blobs: SenseBlobStore | null;
};
/**
* Read-only map of peer sense name → their Drizzle DB.
* `TDb` defaults to `unknown` so callers that don't need peer queries can
* leave it unspecified.
*/
export type SensePeerMap<TDb = unknown> = Readonly<Record<string, TDb>>;
/**
* The function signature every sense `src/index.ts` must export as a named
* `compute` export.
*
* - `db` — the sense's own Drizzle DB instance (read-write).
* - `peers` — read-only map of peer sense name → Drizzle DB.
* - `options` — injected options; `options.blobs` is available inside the
* sense worker; `options.signal` carries the AbortSignal.
*
* Pure: no DB, no peers.
* Return `null` to stay silent, or `{ signal, workflow }` to emit a Signal
* (and optionally trigger a Workflow).
* The runtime handles persistence via `db.insert(table).values(result.signal)`.
*/
export type SenseComputeFn<T = unknown, TDb = unknown> = (
db: TDb,
peers: SensePeerMap<TDb>,
options: SenseComputeOptions,
) => Promise<ComputeResult<T>>;
export type SenseComputeFn<T = unknown> = () => Promise<ComputeResult<T>>;
/**
* The full shape a sense module (`src/index.ts`) must export.
* `compute` provides the data; `table` tells the runtime where to persist it.
*/
export type SenseModule<T = unknown> = {
compute: SenseComputeFn<T>;
table: SQLiteTable;
};
+28 -32
View File
@@ -21,39 +21,41 @@ export type WorkflowMessage = {
/** The typed output of a Role execution. */
export type RoleResult<Meta> = { content: string; meta: Meta };
/**
* A Role is a pure async function: receives the engine start frame plus prior
* role messages only (the start frame is not included in `messages`).
* Returns typed content + meta. Implementation can be an agent, LLM call,
* script, HTTP request, etc.
*/
export type Role<Meta> = (
start: StartStep,
messages: WorkflowMessage[],
) => Promise<RoleResult<Meta>>;
/** Maps role names to their meta types — the single generic that drives all inference. */
export type RoleMeta = Record<string, Record<string, unknown>>;
/** Engine start frame: prompt, max rounds cap, dry-run flag, and timestamps for the thread. */
/** Engine start frame: initial prompt, max rounds cap, and thread identity. */
export type StartStep = {
role: START;
content: string;
/** Thread identity (same as workflow `runId`); for role prompts and CLI `nerve thread` context. */
meta: { maxRounds: number; dryRun: boolean; threadId: string };
meta: { maxRounds: number; threadId: string };
timestamp: number;
};
/** Thread context passed to agent adapters (RFC-003): conversation frame, repo root, cancellation. */
export type WorkflowContext = {
/**
* Thread-scoped context for roles, moderator, and agent adapters (RFC-005).
* `workdir` and `signal` are adapter/engine config — not part of this shape.
*/
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
start: StartStep;
messages: WorkflowMessage[];
workdir: string;
signal: AbortSignal;
steps: RoleStep<M>[];
};
/**
* @deprecated Use {@link ThreadContext}.
*/
export type WorkflowContext = ThreadContext;
/**
* A Role receives the full thread context (start frame + prior role steps) and returns
* typed content + meta. Implementation can be an agent, LLM call, script, HTTP request, etc.
*/
export type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
/** Unified agent invocation — raw string output; structured meta uses the extract layer. */
export type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>;
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
/** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */
export type RoleStep<M extends RoleMeta> = {
@@ -61,23 +63,17 @@ export type RoleStep<M extends RoleMeta> = {
}[keyof M & string];
/**
* Moderator input: the complete workflow history.
* Contains the start frame and all role steps so far.
* On initial call, `steps` is empty — moderator can check `steps.length === 0`.
* Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`.
* @deprecated Use {@link ThreadContext}.
*/
export type ModeratorContext<M extends RoleMeta> = {
start: StartStep;
steps: RoleStep<M>[];
};
export type ModeratorContext<M extends RoleMeta> = ThreadContext<M>;
/**
* The moderator — a pure routing function. Receives the full workflow context
* (start frame + all prior steps). Returns the next role name or END.
* The moderator — a pure routing function. Receives the full thread context
* (start frame + all prior steps). On initial call, `steps` is empty.
* Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`.
* Returns the next role name or END.
*/
export type Moderator<M extends RoleMeta> = (
context: ModeratorContext<M>,
) => (keyof M & string) | END;
export type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;
/** The complete definition of a workflow, as authored by users. */
export type WorkflowDefinition<M extends RoleMeta> = {
@@ -7,10 +7,9 @@ import { drizzle } from "drizzle-orm/node-sqlite";
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
import { describe, expect, it } from "vitest";
import { createBlobStore } from "@uncaged/nerve-store";
import { parseParentMessage } from "../ipc.js";
import { executeCompute, openPeerDb, openSenseDb, runMigrations } from "../sense-runtime.js";
import type { ComputeFn, DrizzleDB, PeerMap, SenseRuntime } from "../sense-runtime.js";
import { executeCompute, openSenseDb, runMigrations } from "../sense-runtime.js";
import type { DrizzleDB, SenseRuntime } from "../sense-runtime.js";
// ---------------------------------------------------------------------------
// Helpers
@@ -151,76 +150,50 @@ describe("openSenseDb", () => {
});
});
// ---------------------------------------------------------------------------
// openPeerDb
// ---------------------------------------------------------------------------
describe("openPeerDb", () => {
it("opens an existing db in read-only mode", () => {
// Create a writable db first
const dbPath = makeTempDbPath();
const sqlite = new DatabaseSync(dbPath);
sqlite.exec(INIT_SQL);
sqlite.prepare("INSERT INTO samples (ts, value) VALUES (1, 42.0)").run();
sqlite.close();
const result = openPeerDb(dbPath);
expect(result.ok).toBe(true);
if (!result.ok) return;
// Should be able to read
const peerDb = result.value;
const rows = peerDb.select().from(samples).all();
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(42.0);
});
it("returns err when db path does not exist", () => {
const result = openPeerDb("/nonexistent/path/to/peer.db");
expect(result.ok).toBe(false);
});
});
// ---------------------------------------------------------------------------
// executeCompute
// ---------------------------------------------------------------------------
describe("executeCompute", () => {
function makeRuntime(computeFn: ComputeFn): {
runtime: SenseRuntime;
sqlite: DatabaseSync;
} {
const sqlite = new DatabaseSync(":memory:");
sqlite.exec(INIT_SQL);
const db = drizzle({ client: sqlite }) as DrizzleDB;
function makeRuntime(
computeFn: SenseRuntime["compute"],
sqlite?: DatabaseSync,
): { runtime: SenseRuntime; sqlite: DatabaseSync } {
const db_sqlite = sqlite ?? new DatabaseSync(":memory:");
if (!sqlite) db_sqlite.exec(INIT_SQL);
const db = drizzle({ client: db_sqlite }) as DrizzleDB;
return {
runtime: { name: "test-sense", db, compute: computeFn, persistSignal: () => {} },
sqlite,
runtime: {
name: "test-sense",
db,
compute: computeFn,
table: samples,
persistSignal: () => {},
},
sqlite: db_sqlite,
};
}
const emptyPeers: PeerMap = {};
it("returns non-null and inserts into table when compute returns data", async () => {
const { runtime, sqlite } = makeRuntime(async () => ({
signal: { ts: 1000, value: 0.5 },
workflow: null,
}));
it("returns the compute result when compute returns a non-null value", async () => {
const { runtime, sqlite } = makeRuntime(async (db) => {
await db.insert(samples).values({ ts: Date.now(), value: 0.5 });
return { signal: 0.5, workflow: null };
});
const result = await executeCompute(runtime, emptyPeers);
const result = await executeCompute(runtime);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({ signal: 0.5, workflow: null });
expect(result.value).toEqual({ signal: { ts: 1000, value: 0.5 }, workflow: null });
const rows = sqlite.prepare("SELECT * FROM samples").all();
expect(rows).toHaveLength(1);
sqlite.close();
});
it("returns null (no signal) when compute returns null", async () => {
it("returns null and does not insert when compute returns null", async () => {
const { runtime, sqlite } = makeRuntime(async () => null);
const result = await executeCompute(runtime, emptyPeers);
const result = await executeCompute(runtime);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBeNull();
@@ -235,60 +208,14 @@ describe("executeCompute", () => {
throw new Error("something went wrong");
});
const result = await executeCompute(runtime, emptyPeers);
const result = await executeCompute(runtime);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toContain("something went wrong");
sqlite.close();
});
it("compute can read from peers", async () => {
// Set up a peer db with data
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
peerSqlite.prepare("INSERT INTO samples (ts, value) VALUES (100, 3.14)").run();
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "other-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (_db, p) => {
const rows = await p["other-sense"].select().from(samples).all();
return rows.length > 0 ? { signal: rows[0].value, workflow: null } : null;
});
const result = await executeCompute(runtime, peers);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({ signal: 3.14, workflow: null });
peerSqlite.close();
sqlite.close();
});
it("write to own db does not affect peer db (isolation)", async () => {
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "peer-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (db) => {
await db.insert(samples).values({ ts: 999, value: 9.9 });
return { signal: 9.9, workflow: null };
});
await executeCompute(runtime, peers);
const peerRows = peerSqlite.prepare("SELECT * FROM samples").all();
expect(peerRows).toHaveLength(0);
const ownRows = sqlite.prepare("SELECT * FROM samples").all();
expect(ownRows).toHaveLength(1);
peerSqlite.close();
sqlite.close();
});
it("inserts correctly into the sense db directory path", async () => {
it("inserts correctly into the sense db from openSenseDb", async () => {
const dbPath = makeTempDbPath();
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const dbResult = openSenseDb(dbPath, migrationsDir);
@@ -301,14 +228,12 @@ describe("executeCompute", () => {
const runtime: SenseRuntime = {
name: "cpu-usage",
db,
compute: async (d) => {
await d.insert(samples).values({ ts: 1000, value: 1.23 });
return { signal: 1.23, workflow: null };
},
compute: async () => ({ signal: { ts: 1000, value: 1.23 }, workflow: null }),
table: samples,
persistSignal: () => {},
};
const result = await executeCompute(runtime, {});
const result = await executeCompute(runtime);
expect(result.ok).toBe(true);
const rows = dbSqlite.prepare("SELECT * FROM samples").all() as Array<{
@@ -323,17 +248,10 @@ describe("executeCompute", () => {
it("returns err when compute exceeds timeoutMs", async () => {
const { runtime, sqlite } = makeRuntime(
(_db, _peers, options) =>
new Promise<null>((resolve, reject) => {
const t = setTimeout(() => resolve(null), 5_000);
options?.signal.addEventListener("abort", () => {
clearTimeout(t);
reject(new Error("aborted"));
});
}),
() => new Promise<null>((resolve) => setTimeout(() => resolve(null), 5_000)),
);
const result = await executeCompute(runtime, emptyPeers, 50);
const result = await executeCompute(runtime, 50);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/timed out/i);
@@ -341,37 +259,14 @@ describe("executeCompute", () => {
});
it("completes within timeout when compute is fast", async () => {
const { runtime, sqlite } = makeRuntime(async () => ({ signal: 42, workflow: null }));
const result = await executeCompute(runtime, emptyPeers, 5_000);
const { runtime, sqlite } = makeRuntime(async () => ({
signal: { ts: 1, value: 42 },
workflow: null,
}));
const result = await executeCompute(runtime, 5_000);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({ signal: 42, workflow: null });
sqlite.close();
});
it("passes AbortSignal to compute fn", async () => {
let capturedSignal: AbortSignal | undefined;
const { runtime, sqlite } = makeRuntime(async (_db, _peers, options) => {
capturedSignal = options?.signal;
return null;
});
await executeCompute(runtime, emptyPeers, 1_000);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
sqlite.close();
});
it("passes BlobStore as options.blobs when blobStore argument is provided", async () => {
const blobsRoot = mkdtempSync(join(tmpdir(), "nerve-blobs-"));
const blobStore = createBlobStore(blobsRoot);
let seen: ReturnType<typeof createBlobStore> | undefined;
const { runtime, sqlite } = makeRuntime(async (_db, _peers, options) => {
seen = options?.blobs;
return null;
});
await executeCompute(runtime, emptyPeers, undefined, blobStore);
expect(seen).toBe(blobStore);
expect(result.value).toEqual({ signal: { ts: 1, value: 42 }, workflow: null });
sqlite.close();
});
});
@@ -429,19 +324,14 @@ describe("runMigrations journal", () => {
const first = runMigrations(sqlite, dir);
expect(first.ok).toBe(true);
// Insert a row so we can verify second run doesn't fail on CREATE TABLE
sqlite.exec("INSERT INTO samples (ts, value) VALUES (1, 1.0)");
// Run again — migration must NOT re-run (would fail without IF NOT EXISTS but
// the journal prevents it even for non-idempotent SQL)
const nonIdempotentSql = "CREATE TABLE samples2 (id INTEGER PRIMARY KEY)";
writeFileSync(join(dir, "0002_samples2.sql"), nonIdempotentSql);
// First time: creates samples2
const second = runMigrations(sqlite, dir);
expect(second.ok).toBe(true);
// Second time: 0002 already in journal, must not re-run
const third = runMigrations(sqlite, dir);
expect(third.ok).toBe(true);
+2 -2
View File
@@ -1,9 +1,9 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
/**
* Echo adapter (`type: "echo"`) — returns the assembled prompt unchanged.
* Used for tests and dry-run wiring before real adapters exist.
*/
export function createEchoAgent(_config: AgentConfig): AgentFn {
return async (prompt: string, _context: WorkflowContext) => prompt;
return async (_ctx: ThreadContext, prompt: string) => prompt;
}
+2 -3
View File
@@ -17,13 +17,12 @@ export type {
export type { SignalBus, SignalHandler, Unsubscribe } from "./signal-bus.js";
export type { ComputeFn, DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
export type { DrizzleDB, SenseRuntime } from "./sense-runtime.js";
export {
runMigrations,
openSenseDb,
openPeerDb,
loadComputeFn,
loadSenseModule,
executeCompute,
} from "./sense-runtime.js";
+42 -2
View File
@@ -3,7 +3,7 @@
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
*/
import type { Result } from "@uncaged/nerve-core";
import type { Result, WorkflowTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** Parent → Worker: trigger one compute cycle for a sense */
@@ -72,6 +72,13 @@ export type SignalMessage = {
payload: unknown;
};
/** Worker → Parent: sense compute result includes a workflow to start */
export type SenseWorkflowTriggerMessage = {
type: "sense-workflow-trigger";
sense: string;
workflow: WorkflowTrigger;
};
/** Worker → Parent: compute threw or returned an unexpected error */
export type ErrorMessage = {
type: "error";
@@ -139,7 +146,8 @@ export type WorkerToParentMessage =
| HealthResponseMessage
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessageMessage;
| ThreadWorkflowMessageMessage
| SenseWorkflowTriggerMessage;
const PARENT_MSG_TYPES = new Set([
"compute",
@@ -340,6 +348,7 @@ const WORKER_MSG_TYPES = new Set([
"thread-event",
"workflow-error",
"thread-workflow-message",
"sense-workflow-trigger",
]);
function parseThreadWorkflowMessageMsg(
@@ -377,6 +386,36 @@ function parseThreadWorkflowMessageMsg(
});
}
function parseSenseWorkflowTriggerMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("Worker 'sense-workflow-trigger' message missing string 'sense' field"));
}
if (!isPlainRecord(obj.workflow)) {
return err(
new Error("Worker 'sense-workflow-trigger' message missing object 'workflow' field"),
);
}
const wf = obj.workflow;
if (typeof wf.name !== "string")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'name'"));
if (typeof wf.maxRounds !== "number")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing number 'maxRounds'"));
if (typeof wf.prompt !== "string")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'prompt'"));
if (typeof wf.dryRun !== "boolean")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing boolean 'dryRun'"));
return ok({
type: "sense-workflow-trigger",
sense: obj.sense,
workflow: {
name: wf.name,
maxRounds: wf.maxRounds,
prompt: wf.prompt,
dryRun: wf.dryRun,
},
});
}
/** Validate and parse an unknown IPC message received from a worker process. */
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
if (!isPlainRecord(raw)) {
@@ -395,5 +434,6 @@ export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage>
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj);
if (obj.type === "sense-workflow-trigger") return parseSenseWorkflowTriggerMsg(obj);
return ok({ type: "ready" });
}
+36 -59
View File
@@ -4,43 +4,20 @@ import { DatabaseSync } from "node:sqlite";
import { drizzle } from "drizzle-orm/node-sqlite";
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import type { ComputeResult, Result } from "@uncaged/nerve-core";
import type { ComputeResult, Result, SenseComputeFn } from "@uncaged/nerve-core";
import { DEFAULT_SENSE_SIGNAL_RETENTION, err, isPlainRecord, ok } from "@uncaged/nerve-core";
import type { BlobStore } from "@uncaged/nerve-store";
/** A Drizzle DB instance (schema-generic) */
export type DrizzleDB = NodeSQLiteDatabase<Record<string, never>>;
/** Read-only map of peer sense name → their Drizzle DB */
export type PeerMap = Readonly<Record<string, DrizzleDB>>;
/** Options passed to a compute function */
export type ComputeOptions = {
signal: AbortSignal;
/** CAS under `data/blobs/`; injected by the sense worker when available. */
blobs?: BlobStore;
};
/**
* The shape every sense's index.ts must export.
* Engine injects `db` (read-write), `peers` (read-only), and `options`
* (`signal`, and `blobs` when running in the sense worker — RFC-001 §8 CAS).
* Returns a structured result when a signal should be emitted (and optionally a workflow),
* or null for silence.
*/
export type ComputeFn<T = unknown> = (
db: DrizzleDB,
peers: PeerMap,
options?: ComputeOptions,
) => Promise<ComputeResult<T>>;
/** All state held for one sense inside a worker */
export type SenseRuntime = {
name: string;
db: DrizzleDB;
compute: ComputeFn;
compute: SenseComputeFn;
table: SQLiteTable;
persistSignal: (payload: unknown) => void;
};
@@ -126,13 +103,13 @@ export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Resu
return ok(undefined);
}
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
const SIGNAL_INSERTS_PER_PRUNE = 100;
/**
* Open (or create) the SQLite file at `dbPath`, run all migrations in
* `migrationsDir`, and wrap with Drizzle ORM.
*/
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
const SIGNAL_INSERTS_PER_PRUNE = 100;
export function openSenseDb(
dbPath: string,
migrationsDir: string,
@@ -184,27 +161,12 @@ export function openSenseDb(
}
/**
* Open a peer sense DB in read-only mode (no migrations).
* Dynamically import the compute function and table from a sense's index.ts/js.
* The module must export a named `compute` function and a named `table` (SQLiteTable).
*/
export function openPeerDb(dbPath: string): Result<DrizzleDB> {
let sqlite: DatabaseSync;
try {
sqlite = new DatabaseSync(dbPath, { readOnly: true });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to open peer database "${dbPath}" (readonly): ${msg}`));
}
// Same schema-agnostic Drizzle wrapper as openSenseDb.
return ok(drizzle({ client: sqlite }) as DrizzleDB);
}
/**
* Dynamically import the compute function from a sense's index.ts/js.
* The module must export a named `compute` function.
*/
export async function loadComputeFn(senseIndexPath: string): Promise<Result<ComputeFn>> {
export async function loadSenseModule(
senseIndexPath: string,
): Promise<Result<{ compute: SenseComputeFn; table: SQLiteTable }>> {
let mod: unknown;
try {
@@ -221,26 +183,34 @@ export async function loadComputeFn(senseIndexPath: string): Promise<Result<Comp
);
}
return ok(mod.compute as ComputeFn);
if (!("table" in mod) || mod.table === null || typeof mod.table !== "object") {
return err(
new Error(
`Sense module "${senseIndexPath}" must export a named "table" (drizzle SQLiteTable)`,
),
);
}
return ok({
compute: mod.compute as SenseComputeFn,
table: mod.table as SQLiteTable,
});
}
/**
* Execute a sense's compute function with an optional soft timeout.
* If timeoutMs is provided and compute takes longer, the AbortSignal is
* triggered and an error Result is returned.
* When `blobStore` is set, it is exposed as `options.blobs` (see RFC-001 §8).
* When compute returns non-null, `result.signal` is persisted to the sense's
* table via `db.insert(table).values(result.signal)` and `persistSignal` is
* called with `result.signal`. Returns the full `ComputeResult` so callers
* can inspect the `workflow` field.
*/
export async function executeCompute(
runtime: SenseRuntime,
peers: PeerMap,
timeoutMs?: number,
blobStore?: BlobStore,
): Promise<Result<ComputeResult<unknown>>> {
const controller = new AbortController();
const options: ComputeOptions =
blobStore !== undefined
? { signal: controller.signal, blobs: blobStore }
: { signal: controller.signal };
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise =
@@ -254,10 +224,17 @@ export async function executeCompute(
: null;
try {
const computePromise = runtime.compute(runtime.db, peers, options);
const computePromise = runtime.compute();
const result = timeoutPromise
? await Promise.race([computePromise, timeoutPromise])
: await computePromise;
if (result !== null) {
// Cast required: DrizzleDB is schema-agnostic; the sense module guarantees shape compatibility.
await runtime.db.insert(runtime.table).values(result.signal as Record<string, unknown>);
runtime.persistSignal(result.signal);
}
return ok(result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
+22 -77
View File
@@ -3,14 +3,13 @@
*
* Entry point for `nerve worker sense --group <name>`.
* Receives the group name via CLI args, reads nerve.yaml, initialises one
* SenseRuntime per sense in the group, builds peer read-only connections,
* then signals ready and enters the IPC event loop.
* SenseRuntime per sense in the group, then signals ready and enters the
* IPC event loop.
*
* Layout assumptions (nerve user config at `~/.uncaged-nerve/`):
* senses/<name>/index.js ← compiled compute
* dist/senses/<name>/index.js ← bundled compute (esbuild)
* senses/<name>/migrations/ ← SQL migration files
* data/senses/<name>.db ← SQLite data file
* data/blobs/<aa>/<hashrest> ← CAS (sha256), via options.blobs in compute
* nerve.yaml ← config
*/
@@ -19,14 +18,13 @@ import "./experimental-warning-suppression.js";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseNerveConfig, routeSenseComputeOutput } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import { createBlobStore } from "@uncaged/nerve-store";
import type { WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
import { executeCompute, loadComputeFn, openPeerDb, openSenseDb } from "./sense-runtime.js";
import type { DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
import { executeCompute, loadSenseModule, openSenseDb } from "./sense-runtime.js";
import type { SenseRuntime } from "./sense-runtime.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
// ---------------------------------------------------------------------------
@@ -52,7 +50,7 @@ function sendError(sense: string, error: string): void {
}
// ---------------------------------------------------------------------------
// Initialisation helpers (each extracted to keep bootstrap complexity low)
// Initialisation helpers
// ---------------------------------------------------------------------------
function readConfig(nerveRoot: string): NerveConfig {
@@ -78,65 +76,30 @@ async function initSense(
nerveRoot: string,
senseName: string,
retention: number,
): Promise<{ db: DrizzleDB; runtime: SenseRuntime }> {
): Promise<SenseRuntime> {
const dbPath = join(nerveRoot, "data", "senses", `${senseName}.db`);
const migrationsDir = join(nerveRoot, "senses", senseName, "migrations");
const senseIndexPath = resolve(join(nerveRoot, "senses", senseName, "index.js"));
const senseIndexPath = resolve(join(nerveRoot, "dist", "senses", senseName, "index.js"));
const dbResult = openSenseDb(dbPath, migrationsDir, retention);
if (!dbResult.ok) {
throw new Error(`Failed to init DB for "${senseName}": ${dbResult.error.message}`);
}
const computeResult = await loadComputeFn(senseIndexPath);
if (!computeResult.ok) {
throw new Error(`Failed to load compute for "${senseName}": ${computeResult.error.message}`);
const moduleResult = await loadSenseModule(senseIndexPath);
if (!moduleResult.ok) {
throw new Error(`Failed to load module for "${senseName}": ${moduleResult.error.message}`);
}
const { db } = dbResult.value;
return {
db,
runtime: {
name: senseName,
db,
compute: computeResult.value,
persistSignal: dbResult.value.persistSignal,
},
name: senseName,
db: dbResult.value.db,
compute: moduleResult.value.compute,
table: moduleResult.value.table,
persistSignal: dbResult.value.persistSignal,
};
}
function buildPeers(
nerveRoot: string,
allSenseNames: string[],
ownDbs: Map<string, DrizzleDB>,
groupSenseNames: Set<string>,
): PeerMap {
const entries: [string, DrizzleDB][] = [];
for (const peerName of allSenseNames) {
// Exclude senses that belong to this worker's own group — they are not peers
if (groupSenseNames.has(peerName)) continue;
const own = ownDbs.get(peerName);
if (own !== undefined) {
entries.push([peerName, own]);
continue;
}
const peerDbPath = join(nerveRoot, "data", "senses", `${peerName}.db`);
const peerResult = openPeerDb(peerDbPath);
if (!peerResult.ok) {
process.stderr.write(
`[sense-worker] Warning: could not open peer DB for "${peerName}": ${peerResult.error.message}\n`,
);
continue;
}
entries.push([peerName, peerResult.value]);
}
return Object.fromEntries(entries);
}
// ---------------------------------------------------------------------------
// Grace period: hard kill after soft timeout
//
@@ -173,13 +136,11 @@ function clearGracePeriodTimer(senseName: string): void {
async function runCompute(
senseName: string,
runtime: SenseRuntime,
peers: PeerMap,
timeoutMs: number,
gracePeriodMs: number | null,
blobStore: ReturnType<typeof createBlobStore>,
): Promise<void> {
try {
const result = await executeCompute(runtime, peers, timeoutMs, blobStore);
const result = await executeCompute(runtime, timeoutMs);
if (!result.ok) {
sendError(senseName, result.error.message);
if (gracePeriodMs !== null && result.error.message.includes("timed out")) {
@@ -189,12 +150,7 @@ async function runCompute(
}
clearGracePeriodTimer(senseName);
if (result.value != null) {
const routeResult = routeSenseComputeOutput(result.value);
if (!routeResult.ok) {
sendError(senseName, routeResult.error.message);
return;
}
runtime.persistSignal(routeResult.value.signal);
// Single IPC message: kernel uses routeSenseComputeOutput(payload) for signal + optional workflow.
sendSignal(senseName, result.value);
}
} catch (e: unknown) {
@@ -210,11 +166,9 @@ async function runCompute(
function handleMessage(
raw: unknown,
runtimes: Map<string, SenseRuntime>,
peers: PeerMap,
group: string,
senseConfigs: Map<string, { timeout: number | null; gracePeriod: number | null }>,
inFlight: Map<string, Promise<void>>,
blobStore: ReturnType<typeof createBlobStore>,
): void {
const parseResult = parseParentMessage(raw);
if (!parseResult.ok) {
@@ -245,14 +199,13 @@ function handleMessage(
return;
}
// Look up timeout/gracePeriod per-sense at compute time (RFC §5.3: these are per-sense)
const sc = senseConfigs.get(msg.sense);
const timeoutMs = sc?.timeout ?? DEFAULT_TIMEOUT_MS;
const gracePeriodMs = sc?.gracePeriod ?? null;
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
const next = previous
.then(() => runCompute(msg.sense, runtime, peers, timeoutMs, gracePeriodMs, blobStore))
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(msg.sense, errMsg);
@@ -279,14 +232,12 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
}
const runtimes = new Map<string, SenseRuntime>();
const ownDbs = new Map<string, DrizzleDB>();
const failedSenses: string[] = [];
for (const senseName of groupSenses) {
try {
const retention = config.senses[senseName].retention;
const { db, runtime } = await initSense(nerveRoot, senseName, retention);
ownDbs.set(senseName, db);
const runtime = await initSense(nerveRoot, senseName, retention);
runtimes.set(senseName, runtime);
} catch (e: unknown) {
const eMsg = e instanceof Error ? e.message : String(e);
@@ -297,16 +248,11 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
}
}
// If ALL senses failed, exit with error so kernel respawns
if (runtimes.size === 0) {
process.stderr.write(`[sense-worker] All senses in group "${group}" failed to load, exiting\n`);
process.exit(1);
}
const groupSenseNames = new Set(groupSenses);
const peers = buildPeers(nerveRoot, Object.keys(config.senses), ownDbs, groupSenseNames);
// Build per-sense timeout/gracePeriod map (RFC §5.3: these are per-sense, not per-group)
const senseConfigs = new Map<string, { timeout: number | null; gracePeriod: number | null }>();
for (const senseName of groupSenses) {
const sc = config.senses[senseName];
@@ -317,12 +263,11 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
}
const inFlight = new Map<string, Promise<void>>();
const blobStore = createBlobStore(join(nerveRoot, "data", "blobs"));
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, runtimes, peers, group, senseConfigs, inFlight, blobStore);
handleMessage(raw, runtimes, group, senseConfigs, inFlight);
});
}
+2 -4
View File
@@ -60,7 +60,7 @@ export type WorkflowManager = {
updateConfig: (newConfig: NerveConfig) => void;
/**
* Drain active threads for a workflow, then respawn its worker process.
* Used for hot reload when bundled workflow output under workflows/<name>/dist/ changes.
* Used for hot reload when bundled workflow output under dist/workflows/<name>/ changes.
* Waits up to `drainTimeoutMs` for threads to complete before force-killing.
*/
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise<void>;
@@ -127,7 +127,6 @@ function ensureThreadMessagesWithStart(
threadId: string,
fallbackPrompt: string,
fallbackMaxRounds: number,
fallbackDryRun: boolean,
): WorkflowMessage[] {
const mapped: WorkflowMessage[] = messages.map((m) => ({
role: m.role,
@@ -141,7 +140,7 @@ function ensureThreadMessagesWithStart(
const start: WorkflowMessage = {
role: START,
content: fallbackPrompt,
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun, threadId },
meta: { maxRounds: fallbackMaxRounds, threadId },
timestamp: Date.now(),
};
return [start, ...mapped];
@@ -408,7 +407,6 @@ export function createWorkflowManager(
runId,
launch.prompt,
launch.maxRounds,
launch.dryRun,
);
state.active.add(runId);
const msg: ResumeThreadMessage = {
+45 -40
View File
@@ -14,7 +14,14 @@ import "./experimental-warning-suppression.js";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type { RoleMeta, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core";
import type {
RoleMeta,
RoleStep,
StartStep,
ThreadContext,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
import type {
@@ -87,15 +94,14 @@ function normalizeStartMeta(
threadIdFallback: string,
): StartStep["meta"] {
if (!isPlainRecord(meta)) {
return { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback };
return { maxRounds: maxRoundsFallback, threadId: threadIdFallback };
}
const maxRounds = typeof meta.maxRounds === "number" ? meta.maxRounds : maxRoundsFallback;
const dryRun = typeof meta.dryRun === "boolean" ? meta.dryRun : false;
const threadId =
typeof meta.threadId === "string" && meta.threadId.length > 0
? meta.threadId
: threadIdFallback;
return { maxRounds, dryRun, threadId };
return { maxRounds, threadId };
}
function startStepFromWorkflowMessage(
@@ -107,7 +113,7 @@ function startStepFromWorkflowMessage(
return {
role: START,
content: "",
meta: { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback },
meta: { maxRounds: maxRoundsFallback, threadId: threadIdFallback },
timestamp: Date.now(),
};
}
@@ -124,8 +130,30 @@ type ThreadMessagesState = {
start: StartStep;
/** Role outputs only; never includes the `__start__` frame. */
messages: WorkflowMessage[];
/** From IPC (`start-thread` / `resume-thread`); not part of `StartStep.meta`. */
dryRun: boolean;
};
function workflowMessagesToRoleSteps(messages: WorkflowMessage[]): RoleStep<RoleMeta>[] {
return messages.map((m) => ({
role: m.role,
meta: m.meta as Record<string, unknown>,
content: m.content,
timestamp: m.timestamp,
})) as RoleStep<RoleMeta>[];
}
function buildThreadContext(
start: StartStep,
roleMessages: WorkflowMessage[],
): ThreadContext<RoleMeta> {
return {
threadId: start.meta.threadId,
start,
steps: workflowMessagesToRoleSteps(roleMessages),
};
}
function initThreadMessages(
runId: string,
resumeMessages: WorkflowMessage[],
@@ -139,6 +167,7 @@ function initThreadMessages(
return {
start: startStepFromWorkflowMessage(first, maxRounds, runId),
messages: [...rest],
dryRun,
};
}
const prompt = freshPrompt ?? "";
@@ -146,17 +175,18 @@ function initThreadMessages(
start: {
role: START,
content: prompt,
meta: { maxRounds, dryRun, threadId: runId },
meta: { maxRounds, threadId: runId },
timestamp: Date.now(),
},
messages: [...resumeMessages],
dryRun,
};
}
const prompt = freshPrompt ?? "";
const start: StartStep = {
role: START,
content: prompt,
meta: { maxRounds, dryRun, threadId: runId },
meta: { maxRounds, threadId: runId },
timestamp: Date.now(),
};
sendWorkflowMessage(runId, {
@@ -165,14 +195,13 @@ function initThreadMessages(
meta: start.meta,
timestamp: start.timestamp,
});
return { start, messages: [] };
return { start, messages: [], dryRun };
}
async function executeRole(
def: WorkflowDefinition<RoleMeta>,
nextRole: string,
start: StartStep,
messages: WorkflowMessage[],
ctx: ThreadContext<RoleMeta>,
runId: string,
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
const role = def.roles[nextRole];
@@ -183,7 +212,7 @@ async function executeRole(
let result: { content: string; meta: Record<string, unknown> };
try {
result = await role(start, messages);
result = await role(ctx);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
sendThreadEvent(runId, "failed", { error: errMsg, exitCode: 1 });
@@ -213,37 +242,20 @@ async function runThread(
dryRun,
);
const steps: Array<{
role: string;
meta: Record<string, unknown>;
content: string;
timestamp: number;
}> = [];
// Rebuild steps from any resumed messages
for (const msg of roleMessages) {
steps.push({
role: msg.role,
meta: msg.meta as Record<string, unknown>,
content: msg.content,
timestamp: msg.timestamp,
});
}
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
let nextRole = def.moderator({ start, steps });
let nextRole = def.moderator(buildThreadContext(start, roleMessages));
if (nextRole === END) {
sendThreadEvent(runId, "completed", { exitCode: 0 });
return;
}
while (steps.length < maxRounds) {
const result = await executeRole(def, nextRole, start, roleMessages, runId);
while (roleMessages.length < maxRounds) {
const result = await executeRole(def, nextRole, buildThreadContext(start, roleMessages), runId);
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
@@ -261,14 +273,7 @@ async function runThread(
roleMessages.push(message);
sendWorkflowMessage(runId, message);
steps.push({
role: nextRole,
meta: result.meta,
content: result.content,
timestamp: message.timestamp,
});
nextRole = def.moderator({ start, steps });
nextRole = def.moderator(buildThreadContext(start, roleMessages));
if (nextRole === END) {
sendThreadEvent(runId, "completed", { exitCode: 0 });
@@ -298,7 +303,7 @@ async function loadWorkflowDefinition(
nerveRoot: string,
workflowName: string,
): Promise<WorkflowDefinition<RoleMeta>> {
const indexPath = resolve(join(nerveRoot, "workflows", workflowName, "dist", "index.js"));
const indexPath = resolve(join(nerveRoot, "dist", "workflows", workflowName, "index.js"));
if (!existsSync(indexPath)) {
throw new Error(
`Workflow definition not found for "${workflowName}". Expected:\n ${indexPath}`,
+7 -3
View File
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole, decorateRole, onFail, withDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -43,13 +43,17 @@ export function createCommitterRole(
): Role<CommitterMeta> {
const inner = createRole(
adapter,
async (start: StartStep) => committerPrompt(start.meta.threadId),
async (ctx: ThreadContext) => committerPrompt(ctx.threadId),
committerMetaSchema,
extract,
);
return decorateRole(inner, [
withDryRun({ label: "committer", meta: { committed: true } as CommitterMeta }),
withDryRun({
label: "committer",
meta: { committed: true } as CommitterMeta,
dryRun: extract.dryRun === true,
}),
onFail({ label: "committer", meta: { committed: false } as CommitterMeta }),
]) as Role<CommitterMeta>;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -84,7 +84,7 @@ export function createReviewerRole(
const resolved: ReviewerConfig = { ...defaults, ...config };
return createRole(
adapter,
async (start: StartStep) => reviewerPrompt({ threadId: start.meta.threadId, config: resolved }),
async (ctx: ThreadContext) => reviewerPrompt({ threadId: ctx.threadId, config: resolved }),
reviewerMetaSchema,
extract,
);
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -43,7 +43,7 @@ Return \`done: false\` if you made progress but there is still work to do.`;
export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CoderMeta> {
return createRole(
adapter,
async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }),
coderMetaSchema,
extract,
);
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -32,7 +32,7 @@ export function createPlannerRole(
): Role<PlannerMeta> {
return createRole(
adapter,
async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }),
plannerMetaSchema,
extract,
);
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -53,7 +53,7 @@ export function createTesterRole(
): Role<TesterMeta> {
return createRole(
adapter,
async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }),
async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }),
testerMetaSchema,
extract,
);
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -62,7 +62,7 @@ Return \`done: false\` if you made progress but there is still work to do, or if
export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CoderMeta> {
return createRole(
adapter,
async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }),
coderMetaSchema,
extract,
);
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -61,7 +61,7 @@ export function createPlannerRole(
): Role<PlannerMeta> {
return createRole(
adapter,
async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }),
plannerMetaSchema,
extract,
);
@@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@@ -54,7 +54,7 @@ export function createTesterRole(
): Role<TesterMeta> {
return createRole(
adapter,
async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }),
async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }),
testerMetaSchema,
extract,
);
@@ -2,9 +2,8 @@ import type {
AgentFn,
ModeratorContext,
RoleMeta,
WorkflowContext,
ThreadContext,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END, START } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -50,17 +49,25 @@ function toolCallResponse(argsJson: string): {
function makeStart(threadId: string): {
role: typeof START;
content: string;
meta: { maxRounds: number; dryRun: boolean; threadId: string };
meta: { maxRounds: number; threadId: string };
timestamp: number;
} {
return {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: false, threadId },
meta: { maxRounds: 10, threadId },
timestamp: Date.now(),
};
}
function makeCtx(threadId: string): ThreadContext {
return {
threadId,
start: makeStart(threadId),
steps: [],
};
}
describe("createRole", () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -71,88 +78,83 @@ describe("createRole", () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 3 }))));
const schema = z.object({ n: z.number() });
const adapter: AgentFn = async (prompt) => prompt;
const role = createRole(adapter, "hello", schema, { provider });
const adapter: AgentFn = async (_ctx, prompt) => prompt;
const role = createRole(adapter, "hello", schema, { provider, dryRun: null });
const out = await role(makeStart("t1"), []);
const out = await role(makeCtx("t1"));
expect(out.content).toBe("hello");
expect(out.meta).toEqual({ n: 3 });
});
it("passes WorkflowContext with workdir defaulting to process.cwd()", async () => {
it("passes ThreadContext to AgentFn", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 0 }))));
const seen: WorkflowContext[] = [];
const adapter: AgentFn = async (_prompt, ctx) => {
const seen: ThreadContext[] = [];
const adapter: AgentFn = async (ctx, _prompt) => {
seen.push(ctx);
return "x";
};
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider });
await role(makeStart("t1"), []);
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider, dryRun: null });
await role(makeCtx("t1"));
expect(seen).toHaveLength(1);
expect(seen[0].workdir).toBe(process.cwd());
expect(seen[0].threadId).toBe("t1");
expect(seen[0].steps).toEqual([]);
});
it("resolves dynamic prompt functions before AgentFn", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 99 }))));
const schema = z.object({ n: z.number() });
const adapter: AgentFn = async (prompt) => prompt;
const adapter: AgentFn = async (_ctx, prompt) => prompt;
const role = createRole(
adapter,
async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
async (ctx) => `tid=${ctx.threadId} n=${ctx.steps.length}`,
schema,
{ provider },
{ provider, dryRun: null },
);
const start = makeStart("thread-x");
const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }];
const out = await role(start, msgs);
expect(out.content).toBe("tid=thread-x n=1");
const ctx: ThreadContext = {
threadId: "thread-x",
start: makeStart("thread-x"),
steps: [],
};
const out = await role(ctx);
expect(out.content).toBe("tid=thread-x n=0");
expect(out.meta).toEqual({ n: 99 });
});
it("uses start.meta.dryRun when extract.dryRun is omitted", async () => {
const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const adapter: AgentFn = async () => "raw";
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider });
const start = {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: true, threadId: "x" },
timestamp: 1,
};
await role(start, []);
expect(spy).toHaveBeenCalledWith(
"raw",
expect.anything(),
expect.objectContaining({ provider, dryRun: true }),
);
});
it("prefers extract.dryRun over start.meta.dryRun", async () => {
it("uses extract dryRun null as live extract", async () => {
const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const adapter: AgentFn = async () => "raw";
const role = createRole(adapter, "p", z.object({ n: z.number() }), {
provider,
dryRun: false,
dryRun: null,
});
const start = {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: true, threadId: "x" },
timestamp: 1,
};
await role(start, []);
await role(makeCtx("x"));
expect(spy).toHaveBeenCalledWith(
"raw",
expect.anything(),
expect.objectContaining({ dryRun: false }),
expect.objectContaining({ provider, dryRun: false }),
);
});
it("uses extract.dryRun true for structured extract", async () => {
const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const adapter: AgentFn = async () => "raw";
const role = createRole(adapter, "p", z.object({ n: z.number() }), {
provider,
dryRun: true,
});
await role(makeCtx("x"));
expect(spy).toHaveBeenCalledWith(
"raw",
expect.anything(),
expect.objectContaining({ dryRun: true }),
);
});
});
@@ -164,7 +166,7 @@ describe("WorkflowDefinition compatibility", () => {
const manual: WorkflowDefinition<M> = {
name: "legacy",
roles: {
legacy: async (_start, _messages) => ({
legacy: async (_ctx) => ({
content: "hi",
meta: { id: "a" },
}),
@@ -172,8 +174,8 @@ describe("WorkflowDefinition compatibility", () => {
moderator: (_ctx: ModeratorContext<M>) => END,
};
const start = makeStart("t1");
const out = await manual.roles.legacy(start, []);
const ctx = makeCtx("t1");
const out = await manual.roles.legacy(ctx);
expect(out.content).toBe("hi");
expect(out.meta.id).toBe("a");
});
@@ -1,22 +1,25 @@
import { describe, expect, it } from "vitest";
import type { Role, StartStep } from "@uncaged/nerve-core";
import type { Role, ThreadContext } from "@uncaged/nerve-core";
import { START } from "@uncaged/nerve-core";
import { decorateRole, onFail, withDryRun } from "../role-decorators.js";
type TestMeta = Record<string, unknown> & { ok: boolean };
function fakeStart(dryRun: boolean): StartStep {
function fakeCtx(): ThreadContext {
return {
role: START,
content: "",
meta: {
threadId: "t1",
dryRun,
maxRounds: 10,
threadId: "t1",
start: {
role: START,
content: "",
meta: {
threadId: "t1",
maxRounds: 10,
},
timestamp: Date.now(),
},
timestamp: Date.now(),
steps: [],
};
}
@@ -38,18 +41,19 @@ const failNonErrorRole: Role<TestMeta> = async () => {
// ---------------------------------------------------------------------------
describe("withDryRun", () => {
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true } });
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: true });
it("short-circuits on dry-run", async () => {
const role = dec(successRole);
const result = await role(fakeStart(true), []);
const result = await role(fakeCtx());
expect(result.content).toBe("[dry-run] test skipped");
expect(result.meta).toEqual({ ok: true });
});
it("delegates when not dry-run", async () => {
const role = dec(successRole);
const result = await role(fakeStart(false), []);
const innerDec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: false });
const role = innerDec(successRole);
const result = await role(fakeCtx());
expect(result.content).toBe("done");
expect(result.meta).toEqual({ ok: true });
});
@@ -64,21 +68,21 @@ describe("onFail", () => {
it("passes through on success", async () => {
const role = dec(successRole);
const result = await role(fakeStart(false), []);
const result = await role(fakeCtx());
expect(result.content).toBe("done");
expect(result.meta).toEqual({ ok: true });
});
it("catches Error and returns structured failure", async () => {
const role = dec(failRole);
const result = await role(fakeStart(false), []);
const result = await role(fakeCtx());
expect(result.content).toBe("test failed: boom");
expect(result.meta).toEqual({ ok: false });
});
it("catches non-Error throws", async () => {
const role = dec(failNonErrorRole);
const result = await role(fakeStart(false), []);
const result = await role(fakeCtx());
expect(result.content).toBe("test failed: string error");
expect(result.meta).toEqual({ ok: false });
});
@@ -91,21 +95,21 @@ describe("onFail", () => {
describe("decorateRole", () => {
it("applies decorators left-to-right", async () => {
const role = decorateRole(failRole, [
withDryRun({ label: "x", meta: { ok: true } }),
onFail({ label: "x", meta: { ok: false } }),
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: false }),
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
]);
// Not dry-run, so withDryRun passes through → failRole throws → onFail catches
const result = await role(fakeStart(false), []);
const result = await role(fakeCtx());
expect(result.content).toBe("x failed: boom");
expect(result.meta).toEqual({ ok: false });
});
it("dry-run short-circuits before onFail", async () => {
const role = decorateRole(failRole, [
withDryRun({ label: "x", meta: { ok: true } }),
onFail({ label: "x", meta: { ok: false } }),
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: true }),
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
]);
const result = await role(fakeStart(true), []);
const result = await role(fakeCtx());
expect(result.content).toBe("[dry-run] x skipped");
expect(result.meta).toEqual({ ok: true });
});
@@ -1,20 +1,24 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { START } from "@uncaged/nerve-core";
import { START, type ThreadContext } from "@uncaged/nerve-core";
import { createCursorRole } from "../role-cursor.js";
import { createHermesRole } from "../role-hermes.js";
import { createLlmRole } from "../role-llm.js";
import { createReActRole } from "../role-react.js";
function startFrame(dryRun: boolean, threadId: string) {
function threadCtx(threadId: string): ThreadContext {
return {
role: START,
content: "user prompt",
meta: { maxRounds: 10, dryRun, threadId },
timestamp: 1,
} as const;
threadId,
start: {
role: START,
content: "user prompt",
meta: { maxRounds: 10, threadId },
timestamp: 1,
},
steps: [],
};
}
describe("createCursorRole", () => {
@@ -27,13 +31,14 @@ describe("createCursorRole", () => {
const schema = z.object({ done: z.boolean() });
const role = createCursorRole({
cwd: process.cwd(),
prompt: async (tid) => {
expect(tid).toBe("run-1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("run-1");
return "task";
},
extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema },
dryRun: true,
});
const out = await role(startFrame(true, "run-1"), []);
const out = await role(threadCtx("run-1"));
expect(out.content).toContain("dryRun");
expect(out.meta).toEqual({ done: false });
});
@@ -48,13 +53,14 @@ describe("createHermesRole", () => {
it("uses dry run stub and extract defaults", async () => {
const schema = z.object({ done: z.boolean() });
const role = createHermesRole({
prompt: async (tid) => {
expect(tid).toBe("h1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("h1");
return "hermes task";
},
extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema },
dryRun: true,
});
const out = await role(startFrame(true, "h1"), []);
const out = await role(threadCtx("h1"));
expect(out.content).toContain("hermes");
expect(out.meta).toEqual({ done: false });
});
@@ -99,13 +105,13 @@ describe("createLlmRole", () => {
const role = createLlmRole({
provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" },
prompt: async (tid) => {
expect(tid).toBe("llm1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("llm1");
return [{ role: "user" as const, content: "hi" }];
},
extract: { provider: { baseUrl: "https://ext", apiKey: "k2", model: "small" }, schema },
});
const out = await role(startFrame(false, "llm1"), []);
const out = await role(threadCtx("llm1"));
expect(out.content).toBe("hello from model");
expect(out.meta).toEqual({ n: 7 });
});
@@ -183,8 +189,8 @@ describe("createReActRole", () => {
const role = createReActRole({
provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" },
tools: [tool],
prompt: async (tid) => {
expect(tid).toBe("r1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("r1");
return [{ role: "user" as const, content: "go" }];
},
extract: {
@@ -193,7 +199,7 @@ describe("createReActRole", () => {
},
maxIterations: 5,
});
const out = await role(startFrame(false, "r1"), []);
const out = await role(threadCtx("r1"));
expect(out.content).toBe("final answer");
expect(out.meta).toEqual({ done: true });
});
+10 -30
View File
@@ -1,32 +1,19 @@
import type {
AgentFn,
Role,
StartStep,
WorkflowContext,
WorkflowMessage,
} from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { z } from "zod";
import { extractMetaOrThrow } from "./shared/extract-fn.js";
import type { LlmProvider } from "./shared/llm-extract.js";
type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
type PromptInput = string | ((ctx: ThreadContext) => Promise<string>);
export type LlmExtractorConfig = {
provider: LlmProvider;
/** When omitted, uses `start.meta.dryRun` at runtime. */
dryRun?: boolean;
/** When null, structured extract runs in live mode (not dry-run). */
dryRun: boolean | null;
};
type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null };
function resolveWorkdir(start: StartStep): string {
const m = start.meta as StartMetaWithWorkdir;
return m.workdir ?? process.cwd();
}
function resolveDryRun(extract: LlmExtractorConfig, start: StartStep): boolean {
return extract.dryRun ?? start.meta.dryRun;
function resolveExtractDryRun(extract: LlmExtractorConfig): boolean {
return extract.dryRun === true;
}
/** Builds a Role from an AgentFn, prompt, Zod meta schema, and LLM extract config. */
@@ -36,19 +23,12 @@ export function createRole<M extends Record<string, unknown>>(
meta: z.ZodType<M>,
extract: LlmExtractorConfig,
): Role<M> {
return async (start: StartStep, messages: WorkflowMessage[]) => {
const ctx: WorkflowContext = {
start,
messages,
workdir: resolveWorkdir(start),
signal: new AbortController().signal,
};
const promptText = typeof prompt === "string" ? prompt : await prompt(start, messages);
const raw = await adapter(promptText, ctx);
return async (ctx: ThreadContext) => {
const promptText = typeof prompt === "string" ? prompt : await prompt(ctx);
const raw = await adapter(ctx, promptText);
const result = await extractMetaOrThrow(raw, meta, {
provider: extract.provider,
dryRun: resolveDryRun(extract, start),
dryRun: resolveExtractDryRun(extract),
});
return { content: raw, meta: result };
};
+2 -1
View File
@@ -19,7 +19,6 @@ export {
type NerveYamlError,
type ReadNerveYamlOptions,
} from "./shared/context.js";
export { isDryRun } from "./role-types.js";
export {
decorateRole,
withDryRun,
@@ -37,6 +36,7 @@ export {
type SpawnSafeOptions,
} from "@uncaged/nerve-core";
export type { LlmError, LlmProvider } from "./shared/llm-extract.js";
export { isDryRun } from "./role-types.js";
export type {
CliPromptFn,
CursorRoleDefaults,
@@ -45,6 +45,7 @@ export type {
HermesRoleRequired,
LlmMessage,
LlmPromptFn,
LlmRoleDefaults,
LlmRoleRequired,
MetaExtractConfig,
ReActRoleDefaults,
+4 -4
View File
@@ -2,7 +2,6 @@ import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor
import type { Role, SpawnEnv } from "@uncaged/nerve-core";
import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { llmExtract } from "./shared/llm-extract.js";
@@ -11,6 +10,7 @@ const CURSOR_DEFAULTS: CursorRoleDefaults = {
model: "auto",
env: {},
timeoutMs: 300_000,
dryRun: false,
};
function pick<T>(opts: Record<string, unknown>, key: string, fallback: T): T {
@@ -27,14 +27,14 @@ function pick<T>(opts: Record<string, unknown>, key: string, fallback: T): T {
export function createCursorRole<T>(
options: CursorRoleRequired<T> & Partial<CursorRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
return async (ctx) => {
const d = CURSOR_DEFAULTS;
const mode = pick<CursorAgentMode>(options as Record<string, unknown>, "mode", d.mode);
const model = pick<string>(options as Record<string, unknown>, "model", d.model);
const env = pick<SpawnEnv>(options as Record<string, unknown>, "env", d.env);
const timeoutMs = pick<number>(options as Record<string, unknown>, "timeoutMs", d.timeoutMs);
const prompt = await options.prompt(start.meta.threadId);
const dry = pick<boolean>(options as Record<string, unknown>, "dryRun", d.dryRun);
const prompt = await options.prompt(ctx);
const run = await cursorAgent({
prompt,
mode,
@@ -1,6 +1,4 @@
import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { isDryRun } from "./role-types.js";
import type { Role, ThreadContext } from "@uncaged/nerve-core";
// ---------------------------------------------------------------------------
// Decorator types
@@ -38,23 +36,25 @@ export type WithDryRunOptions<M> = {
label: string;
/** Meta returned when dry-run skips execution. */
meta: M;
/** Adapter-level dry-run flag (e.g. from extract / wiring config). */
dryRun: boolean;
};
/**
* Returns a decorator that short-circuits with a stable result when
* `start.meta.dryRun` is true.
* `dryRun` is true.
*/
export function withDryRun<M extends Record<string, unknown>>(
opts: WithDryRunOptions<M>,
): RoleDecorator<M> {
return (role) => async (start: StartStep, messages: WorkflowMessage[]) => {
if (isDryRun(start)) {
return (role) => async (ctx: ThreadContext) => {
if (opts.dryRun) {
return {
content: `[dry-run] ${opts.label} skipped`,
meta: opts.meta,
};
}
return role(start, messages);
return role(ctx);
};
}
@@ -76,9 +76,9 @@ export type OnFailOptions<M> = {
export function onFail<M extends Record<string, unknown>>(
opts: OnFailOptions<M>,
): RoleDecorator<M> {
return (role) => async (start: StartStep, messages: WorkflowMessage[]) => {
return (role) => async (ctx: ThreadContext) => {
try {
return await role(start, messages);
return await role(ctx);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
+4 -6
View File
@@ -1,7 +1,6 @@
import type { Role } from "@uncaged/nerve-core";
import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { hermesAgent, resolveHermesOptions } from "./shared/hermes-agent.js";
import { llmExtract } from "./shared/llm-extract.js";
@@ -9,10 +8,9 @@ import { llmExtract } from "./shared/llm-extract.js";
export function createHermesRole<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
return async (ctx) => {
const h = resolveHermesOptions(options);
const prompt = await options.prompt(start.meta.threadId);
const prompt = await options.prompt(ctx);
const run = await hermesAgent({
prompt,
model: h.model,
@@ -22,7 +20,7 @@ export function createHermesRole<T>(
maxTurns: h.maxTurns,
env: Object.keys(h.env).length === 0 ? null : h.env,
timeoutMs: h.timeoutMs,
dryRun: dry,
dryRun: h.dryRun,
abortSignal: null,
});
if (!run.ok) {
@@ -43,7 +41,7 @@ export function createHermesRole<T>(
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
dryRun: h.dryRun,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
+10 -6
View File
@@ -1,15 +1,19 @@
import type { Role } from "@uncaged/nerve-core";
import type { LlmMessage, LlmRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import type { LlmMessage, LlmRoleDefaults, LlmRoleRequired } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { chatCompletionText } from "./shared/llm-chat.js";
import { llmExtract } from "./shared/llm-extract.js";
export function createLlmRole<T>(options: LlmRoleRequired<T>): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const LLM_DEFAULTS: LlmRoleDefaults = {
dryRun: false,
};
export function createLlmRole<T>(options: LlmRoleRequired<T> & Partial<LlmRoleDefaults>): Role<T> {
return async (ctx) => {
const dry =
"dryRun" in options && options.dryRun !== undefined ? options.dryRun : LLM_DEFAULTS.dryRun;
const messages: LlmMessage[] = await options.prompt(ctx);
const result = await chatCompletionText({ provider: options.provider, messages });
if (!result.ok) {
throw new Error(`llm: ${formatLlmError(result.error)}`);
+4 -4
View File
@@ -1,26 +1,26 @@
import type { Role } from "@uncaged/nerve-core";
import type { LlmMessage, ReActRoleDefaults, ReActRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { reActIterativeChat } from "./shared/llm-chat.js";
import { llmExtract } from "./shared/llm-extract.js";
const REACT_DEFAULTS: ReActRoleDefaults = {
maxIterations: 10,
dryRun: false,
};
export function createReActRole<T>(
options: ReActRoleRequired<T> & Partial<ReActRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
return async (ctx) => {
const def = REACT_DEFAULTS;
const maxIt =
"maxIterations" in options && options.maxIterations !== undefined
? options.maxIterations
: def.maxIterations;
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const dry = "dryRun" in options && options.dryRun !== undefined ? options.dryRun : def.dryRun;
const messages: LlmMessage[] = await options.prompt(ctx);
const result = await reActIterativeChat({
provider: options.provider,
tools: options.tools,
+16 -6
View File
@@ -1,18 +1,21 @@
import type { SpawnEnv, StartStep } from "@uncaged/nerve-core";
import type { SpawnEnv, StartStep, ThreadContext } from "@uncaged/nerve-core";
import type { z } from "zod";
import type { LlmProvider } from "./shared/llm-extract.js";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartStep): boolean {
return start.meta.dryRun;
/**
* @deprecated `dryRun` has been removed from `StartStep.meta` (RFC-005).
* Use adapter/role-level `dryRun` config instead.
*/
export function isDryRun(_start: StartStep): boolean {
return false;
}
export type CliPromptFn = (threadId: string) => Promise<string>;
export type CliPromptFn = (ctx: ThreadContext) => Promise<string>;
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
export type LlmPromptFn = (threadId: string) => Promise<LlmMessage[]>;
export type LlmPromptFn = (ctx: ThreadContext) => Promise<LlmMessage[]>;
export type MetaExtractConfig<T> = {
provider: LlmProvider;
@@ -37,6 +40,7 @@ export type CursorRoleDefaults = {
model: string;
env: SpawnEnv;
timeoutMs: number;
dryRun: boolean;
};
export type HermesRoleRequired<T> = {
@@ -52,6 +56,7 @@ export type HermesRoleDefaults = {
maxTurns: number;
env: SpawnEnv;
timeoutMs: number;
dryRun: boolean;
};
export type LlmRoleRequired<T> = {
@@ -60,6 +65,10 @@ export type LlmRoleRequired<T> = {
extract: MetaExtractConfig<T>;
};
export type LlmRoleDefaults = {
dryRun: boolean;
};
export type ReActRoleRequired<T> = {
provider: LlmProvider;
tools: ReActTool[];
@@ -69,4 +78,5 @@ export type ReActRoleRequired<T> = {
export type ReActRoleDefaults = {
maxIterations: number;
dryRun: boolean;
};
@@ -13,22 +13,30 @@ const HERMES_DEFAULTS: HermesRoleDefaults = {
maxTurns: 90,
env: {},
timeoutMs: 600_000,
dryRun: false,
};
const HERMES_OPTION_KEYS = [
"model",
"provider",
"skills",
"quiet",
"maxTurns",
"env",
"timeoutMs",
"dryRun",
] as const satisfies readonly (keyof HermesRoleDefaults)[];
export function resolveHermesOptions<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): HermesRoleDefaults {
const d = HERMES_DEFAULTS;
return {
model: "model" in options && options.model !== undefined ? options.model : d.model,
provider:
"provider" in options && options.provider !== undefined ? options.provider : d.provider,
skills: "skills" in options && options.skills !== undefined ? options.skills : d.skills,
quiet: "quiet" in options && options.quiet !== undefined ? options.quiet : d.quiet,
maxTurns:
"maxTurns" in options && options.maxTurns !== undefined ? options.maxTurns : d.maxTurns,
env: "env" in options && options.env !== undefined ? options.env : d.env,
timeoutMs:
"timeoutMs" in options && options.timeoutMs !== undefined ? options.timeoutMs : d.timeoutMs,
};
const o = options as Partial<HermesRoleDefaults>;
let resolved: HermesRoleDefaults = { ...d };
for (const k of HERMES_OPTION_KEYS) {
if (k in o && o[k] !== undefined) {
resolved = { ...resolved, [k]: o[k] } as HermesRoleDefaults;
}
}
return resolved;
}
+4
View File
@@ -86,6 +86,9 @@ importers:
packages/core:
dependencies:
drizzle-orm:
specifier: 1.0.0-beta.23-c10d10c
version: 1.0.0-beta.23-c10d10c(@cloudflare/workers-types@4.20260425.1)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1)(zod@4.3.6)
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -2033,6 +2036,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
vite@8.0.9: