Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfadfffd40 | |||
| e6093c35db | |||
| de8c7c5150 | |||
| f799cee51f | |||
| d13b59e787 | |||
| 975f15c66d | |||
| 3e51335d91 | |||
| 9c832b0e21 | |||
| 2387b73141 | |||
| 8824421f26 | |||
| b27a6aced8 | |||
| bfd8fe729a | |||
| 748df10e6a | |||
| 3ef9cfcb27 | |||
| 8c9adf08c5 | |||
| 08e8020cb6 | |||
| e287e07dab | |||
| 239dfffb28 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
|
||||
}
|
||||
|
||||
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 +64,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 +77,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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "1.0.0-beta.23-c10d10c",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
ComputeResult,
|
||||
} from "./config.js";
|
||||
export type { Signal, SenseInfo } from "./sense.js";
|
||||
export type { SenseComputeFn, SenseModule } from "./sense-contract.js";
|
||||
export { labelSenseTrigger, senseTriggerLabels } from "./sense-trigger-labels.js";
|
||||
export type {
|
||||
WorkflowMessage,
|
||||
@@ -19,6 +20,7 @@ export type {
|
||||
Role,
|
||||
RoleMeta,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowContext,
|
||||
AgentFn,
|
||||
RoleStep,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
import type { ComputeResult } from "./config.js";
|
||||
|
||||
/**
|
||||
* The function signature every sense `src/index.ts` must export as a named
|
||||
* `compute` export.
|
||||
*
|
||||
* 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> = () => 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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
* 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 type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig, WorkflowTrigger } 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -51,8 +49,12 @@ function sendError(sense: string, error: string): void {
|
||||
send({ type: "error", sense, error });
|
||||
}
|
||||
|
||||
function sendWorkflowTrigger(sense: string, workflow: WorkflowTrigger): void {
|
||||
send({ type: "sense-workflow-trigger", sense, workflow });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialisation helpers (each extracted to keep bootstrap complexity low)
|
||||
// Initialisation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readConfig(nerveRoot: string): NerveConfig {
|
||||
@@ -78,7 +80,7 @@ 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"));
|
||||
@@ -88,55 +90,20 @@ async function initSense(
|
||||
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 +140,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,13 +154,10 @@ async function runCompute(
|
||||
}
|
||||
clearGracePeriodTimer(senseName);
|
||||
if (result.value != null) {
|
||||
const routeResult = routeSenseComputeOutput(result.value);
|
||||
if (!routeResult.ok) {
|
||||
sendError(senseName, routeResult.error.message);
|
||||
return;
|
||||
sendSignal(senseName, result.value.signal);
|
||||
if (result.value.workflow !== null) {
|
||||
sendWorkflowTrigger(senseName, result.value.workflow);
|
||||
}
|
||||
runtime.persistSignal(routeResult.value.signal);
|
||||
sendSignal(senseName, result.value);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
@@ -210,11 +172,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 +205,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 +238,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 +254,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 +269,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+4
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user