feat: RFC-003 Adapter Plugin Architecture + Dynamic Prompts #243

Merged
xiaomo merged 2 commits from feat/rfc-003-adapter-packages into main 2026-04-29 07:33:46 +00:00
30 changed files with 978 additions and 120 deletions
+67
View File
@@ -103,6 +103,73 @@ nerve.yaml#extract → ExtractFn(string, schema) → T (typed meta)
`AgentRegistry` reads config, instantiates adapters, and returns `AgentFn` by name. Role assembly is handled by the runtime — users never call Role factories directly.
### Adapter Packages
Each agent adapter lives in its own package to avoid pulling unnecessary dependencies:
```
packages/
adapter-cursor/ # @nerve/adapter-cursor — cursor-agent CLI
adapter-hermes/ # @nerve/adapter-hermes — hermes CLI subagent
adapter-claude/ # @nerve/adapter-claude — claude-code CLI (future)
adapter-codex/ # @nerve/adapter-codex — codex CLI (future)
```
Each adapter exports a single factory function:
```ts
// @nerve/adapter-cursor
import type { AgentConfig, AgentFn } from "@nerve/core";
export function createCursorAdapter(config: AgentConfig): AgentFn;
```
The factory receives the full `AgentConfig` (type, model, timeout) and returns an `AgentFn` that spawns the CLI tool, passes the prompt, and returns raw output.
**Registration**`AgentRegistry` accepts adapter factories at construction:
```ts
import { createCursorAdapter } from "@nerve/adapter-cursor";
import { createHermesAdapter } from "@nerve/adapter-hermes";
const registry = createAgentRegistry(config.agents, {
cursor: createCursorAdapter,
hermes: createHermesAdapter,
});
```
The daemon's entry point wires installed adapters; adapters not installed are not imported. `nerve validate` checks that referenced adapter types have a registered factory.
**Workspace `package.json`** only lists the adapters it actually uses:
```json
{
"dependencies": {
"@nerve/adapter-cursor": "workspace:*",
"@nerve/adapter-hermes": "workspace:*"
}
}
```
**Migration from `workflow-utils`** — the existing `role-cursor.ts` / `shared/cursor-agent.ts` spawn logic moves to `@nerve/adapter-cursor`. `role-hermes.ts` / `shared/hermes-agent.ts` moves to `@nerve/adapter-hermes`. `workflow-utils` retains only extract, prompt utilities, and shared spawn infrastructure.
### Dynamic Prompts
`RoleSpec.prompt` supports both static strings and async functions:
```ts
type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
type RoleSpec<M> = {
agent: string;
prompt: PromptInput;
meta: Schema<M>;
timeout: string | null;
};
```
Static prompts cover simple cases. Dynamic prompts (functions) are needed when the prompt depends on thread context — e.g. reading issue content, injecting prior step results, or resolving repo paths at runtime.
### Timeout Resolution
Two-layer with role override:
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/nerve-adapter-cursor",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
@@ -1,3 +1,4 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import { type Result, ok } from "@uncaged/nerve-core";
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
@@ -12,6 +13,7 @@ export type CursorAgentOptions = {
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
abortSignal: AbortSignal | null;
};
type CursorAgentOptionsInput = CursorAgentOptions | Omit<CursorAgentOptions, "dryRun">;
@@ -20,6 +22,10 @@ function resolveCursorAgentDryRun(options: CursorAgentOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: CursorAgentOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
/**
* Invokes `cursor-agent` with the prompt passed as a single argv slot (`shell: false`).
*/
@@ -52,6 +58,7 @@ export async function cursorAgent(
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
abortSignal: normalizeAbortSignal(options),
});
if (!run.ok) {
@@ -60,3 +67,40 @@ export async function cursorAgent(
return ok(run.value.stdout);
}
function throwCursorSpawnError(error: SpawnError): never {
if (error.kind === "non_zero_exit") {
throw new Error(
`cursor-agent: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
);
}
if (error.kind === "timeout") {
throw new Error("cursor-agent: timeout");
}
if (error.kind === "aborted") {
throw new Error("cursor-agent: aborted");
}
throw new Error(`cursor-agent: ${error.message}`);
}
/**
* Factory for RFC-003 `AgentRegistry`: runs `cursor-agent` using config + per-invocation context.
*/
export function createCursorAdapter(config: AgentConfig): AgentFn {
return async (prompt: string, context: WorkflowContext): Promise<string> => {
const run = await cursorAgent({
prompt,
mode: "default",
model: config.model,
cwd: context.workdir,
env: null,
timeoutMs: null,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
});
if (!run.ok) {
throwCursorSpawnError(run.error);
}
return run.value;
};
}
+186
View File
@@ -0,0 +1,186 @@
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { type Result, err, ok } from "@uncaged/nerve-core";
/** Compatible with `process.env` for `child_process.spawn`. */
export type SpawnEnv = Record<string, string | undefined>;
export type SpawnResult = {
stdout: string;
stderr: string;
exitCode: number;
/** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */
signal: string | null;
};
export type SpawnError =
| {
kind: "non_zero_exit";
stdout: string;
stderr: string;
exitCode: number;
signal: string | null;
}
| { kind: "timeout"; stdout: string; stderr: string }
| { kind: "spawn_failed"; message: string }
| { kind: "aborted" };
export type SpawnSafeOptions = {
cwd: string | null;
/** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
/** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */
abortSignal: AbortSignal | null;
};
type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">;
const DEFAULT_TIMEOUT_MS = 300_000;
export function nerveCommandEnv(): SpawnEnv {
const home = homedir();
const pnpmHome = join(home, ".local/share/pnpm");
return {
...process.env,
PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
};
}
function mergeEnv(user: SpawnEnv | null): SpawnEnv {
const base = nerveCommandEnv();
if (user === null) {
return base;
}
return { ...base, ...user };
}
function resolveWallClockMs(
timeoutMs: number | null,
abortSignal: AbortSignal | null,
): number | null {
if (timeoutMs === null) {
if (abortSignal !== null) {
return null;
}
return DEFAULT_TIMEOUT_MS;
}
return timeoutMs;
}
function resolveDryRun(options: SpawnSafeOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
export function spawnSafe(
command: string,
args: ReadonlyArray<string>,
options: SpawnSafeOptionsInput,
): Promise<Result<SpawnResult, SpawnError>> {
const dryRun = resolveDryRun(options);
if (dryRun) {
return Promise.resolve(
ok({
stdout: "[dryRun] skipped",
stderr: "",
exitCode: 0,
signal: null,
}),
);
}
const abortSignal = normalizeAbortSignal(options);
if (abortSignal?.aborted) {
return Promise.resolve(err({ kind: "aborted" }));
}
return new Promise((resolve) => {
const cwd = options.cwd === null ? process.cwd() : options.cwd;
const env = mergeEnv(options.env);
const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal);
const child = spawn(command, args, {
cwd,
env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (outcome: Result<SpawnResult, SpawnError>) => {
if (settled) {
return;
}
settled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
if (abortSignal !== null) {
abortSignal.removeEventListener("abort", onAbort);
}
resolve(outcome);
};
function onAbort() {
child.kill("SIGTERM");
finish(err({ kind: "aborted" }));
}
if (abortSignal !== null) {
abortSignal.addEventListener("abort", onAbort);
}
if (wallClockMs !== null) {
timer = setTimeout(() => {
child.kill("SIGTERM");
finish(err({ kind: "timeout", stdout, stderr }));
}, wallClockMs);
}
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.on("error", (cause: Error) => {
finish(err({ kind: "spawn_failed", message: cause.message }));
});
child.on("close", (code, signal) => {
const exitCode = code ?? 1;
const sig = signal === undefined || signal === null ? null : String(signal);
const result: SpawnResult = {
stdout: stdout.trimEnd(),
stderr: stderr.trimEnd(),
exitCode,
signal: sig,
};
if (exitCode !== 0) {
finish(
err({
kind: "non_zero_exit",
stdout: result.stdout,
stderr: result.stderr,
exitCode,
signal: sig,
}),
);
return;
}
finish(ok(result));
});
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/nerve-adapter-hermes",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
+115
View File
@@ -0,0 +1,115 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import { type Result, ok } from "@uncaged/nerve-core";
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
/**
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
* (shell: false) following the Nerve issue #208 contract.
*/
export type HermesAgentOptions = {
prompt: string;
model: string | null;
provider: string | null;
skills: string[];
/** When true, suppresses interactive UI noise. */
quiet: boolean;
maxTurns: number;
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
abortSignal: AbortSignal | null;
};
type HermesAgentOptionsInput = HermesAgentOptions | Omit<HermesAgentOptions, "dryRun">;
function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: HermesAgentOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
export async function hermesAgent(
options: HermesAgentOptionsInput,
): Promise<Result<string, SpawnError>> {
const dryRun = resolveHermesDryRun(options);
if (dryRun) {
return ok("[dryRun] hermes stub");
}
const args: string[] = [
"chat",
"-q",
options.prompt,
"--yolo",
"--max-turns",
String(options.maxTurns),
];
if (options.model) {
args.push("--model", options.model);
}
if (options.provider) {
args.push("--provider", options.provider);
}
for (const s of options.skills) {
args.push("-s", s);
}
if (options.quiet) {
args.push("--quiet");
}
const run = await spawnSafe("hermes", args, {
cwd: null,
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
abortSignal: normalizeAbortSignal(options),
});
if (!run.ok) {
return run;
}
return ok(run.value.stdout);
}
function throwHermesSpawnError(error: SpawnError): never {
if (error.kind === "non_zero_exit") {
throw new Error(
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
);
}
if (error.kind === "timeout") {
throw new Error("hermes: timeout");
}
if (error.kind === "aborted") {
throw new Error("hermes: aborted");
}
throw new Error(`hermes: ${error.message}`);
}
const HERMES_ADAPTER_DEFAULT_MAX_TURNS = 90;
/**
* Factory for RFC-003 `AgentRegistry`: runs `hermes chat` using config + per-invocation context.
*/
export function createHermesAdapter(config: AgentConfig): AgentFn {
const modelFromConfig = config.model === "auto" ? null : config.model;
return async (prompt: string, context: WorkflowContext): Promise<string> => {
const run = await hermesAgent({
prompt,
model: modelFromConfig,
provider: null,
skills: [],
quiet: true,
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
env: null,
timeoutMs: null,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
});
if (!run.ok) {
throwHermesSpawnError(run.error);
}
return run.value;
};
}
+186
View File
@@ -0,0 +1,186 @@
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { type Result, err, ok } from "@uncaged/nerve-core";
/** Compatible with `process.env` for `child_process.spawn`. */
export type SpawnEnv = Record<string, string | undefined>;
export type SpawnResult = {
stdout: string;
stderr: string;
exitCode: number;
/** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */
signal: string | null;
};
export type SpawnError =
| {
kind: "non_zero_exit";
stdout: string;
stderr: string;
exitCode: number;
signal: string | null;
}
| { kind: "timeout"; stdout: string; stderr: string }
| { kind: "spawn_failed"; message: string }
| { kind: "aborted" };
export type SpawnSafeOptions = {
cwd: string | null;
/** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
/** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */
abortSignal: AbortSignal | null;
};
type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">;
const DEFAULT_TIMEOUT_MS = 300_000;
export function nerveCommandEnv(): SpawnEnv {
const home = homedir();
const pnpmHome = join(home, ".local/share/pnpm");
return {
...process.env,
PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
};
}
function mergeEnv(user: SpawnEnv | null): SpawnEnv {
const base = nerveCommandEnv();
if (user === null) {
return base;
}
return { ...base, ...user };
}
function resolveWallClockMs(
timeoutMs: number | null,
abortSignal: AbortSignal | null,
): number | null {
if (timeoutMs === null) {
if (abortSignal !== null) {
return null;
}
return DEFAULT_TIMEOUT_MS;
}
return timeoutMs;
}
function resolveDryRun(options: SpawnSafeOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
export function spawnSafe(
command: string,
args: ReadonlyArray<string>,
options: SpawnSafeOptionsInput,
): Promise<Result<SpawnResult, SpawnError>> {
const dryRun = resolveDryRun(options);
if (dryRun) {
return Promise.resolve(
ok({
stdout: "[dryRun] skipped",
stderr: "",
exitCode: 0,
signal: null,
}),
);
}
const abortSignal = normalizeAbortSignal(options);
if (abortSignal?.aborted) {
return Promise.resolve(err({ kind: "aborted" }));
}
return new Promise((resolve) => {
const cwd = options.cwd === null ? process.cwd() : options.cwd;
const env = mergeEnv(options.env);
const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal);
const child = spawn(command, args, {
cwd,
env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (outcome: Result<SpawnResult, SpawnError>) => {
if (settled) {
return;
}
settled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
if (abortSignal !== null) {
abortSignal.removeEventListener("abort", onAbort);
}
resolve(outcome);
};
function onAbort() {
child.kill("SIGTERM");
finish(err({ kind: "aborted" }));
}
if (abortSignal !== null) {
abortSignal.addEventListener("abort", onAbort);
}
if (wallClockMs !== null) {
timer = setTimeout(() => {
child.kill("SIGTERM");
finish(err({ kind: "timeout", stdout, stderr }));
}, wallClockMs);
}
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.on("error", (cause: Error) => {
finish(err({ kind: "spawn_failed", message: cause.message }));
});
child.on("close", (code, signal) => {
const exitCode = code ?? 1;
const sig = signal === undefined || signal === null ? null : String(signal);
const result: SpawnResult = {
stdout: stdout.trimEnd(),
stderr: stderr.trimEnd(),
exitCode,
signal: sig,
};
if (exitCode !== 0) {
finish(
err({
kind: "non_zero_exit",
stdout: result.stdout,
stderr: result.stderr,
exitCode,
signal: sig,
}),
);
return;
}
finish(ok(result));
});
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
+3 -4
View File
@@ -1,7 +1,6 @@
/**
* Agent adapter types that have a daemon implementation (RFC-003).
* Keep in sync with `packages/daemon/src/agent-registry.ts` adapter dispatch.
* When adding a new adapter (e.g. cursor, hermes, codex), add it here AND
* add the corresponding factory branch in `createAgentFnForConfig`.
* Agent adapter ids referenced by tooling / docs (RFC-003).
* Daemon wiring registers factories via `createAgentRegistry(..., adapterFactories)`;
* echo is built-in; others must be supplied for runtime use.
*/
export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const;
+1 -1
View File
@@ -27,7 +27,7 @@ export type {
WorkflowDefinition,
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type { RoleSpec, WorkflowSpec } from "./workflow-spec.js";
export type { PromptInput, RoleSpec, WorkflowSpec } from "./workflow-spec.js";
export { resolveRoleTimeoutMs } from "./workflow-spec.js";
export { parseDurationStringToMs } from "./duration.js";
export type { Schema, ExtractFn } from "./extract-layer.js";
+7 -2
View File
@@ -2,7 +2,12 @@ import { parseDurationStringToMs } from "./duration.js";
import type { Schema } from "./extract-layer.js";
import type { Result } from "./result.js";
import { ok } from "./result.js";
import type { Moderator, RoleMeta } from "./workflow.js";
import type { Moderator, RoleMeta, StartStep, WorkflowMessage } from "./workflow.js";
/** Static string or async prompt built from thread context (RFC-003 dynamic prompts). */
export type PromptInput =
| string
| ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
/**
* Authoring-time role: references a named agent, prompt, extract schema, and optional timeout.
@@ -10,7 +15,7 @@ import type { Moderator, RoleMeta } from "./workflow.js";
*/
export type RoleSpec<Meta extends Record<string, unknown>> = {
agent: string;
prompt: string;
prompt: PromptInput;
meta: Schema<Meta>;
/** Override agent default; `null` uses the agent's configured timeout from `nerve.yaml`. */
timeout: string | null;
+3 -1
View File
@@ -22,10 +22,12 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build",
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-adapter-cursor run build && pnpm --filter @uncaged/nerve-adapter-hermes run build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-adapter-cursor": "workspace:*",
"@uncaged/nerve-adapter-hermes": "workspace:*",
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"drizzle-orm": "1.0.0-beta.23-c10d10c",
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import type { AgentConfig, AgentFn, StartStep, WorkflowContext } from "@uncaged/nerve-core";
import { START } from "@uncaged/nerve-core";
import { createAgentRegistry } from "../agent-registry.js";
import { type AgentAdapterFactories, createAgentRegistry } from "../agent-registry.js";
function makeContext(overrides: Partial<WorkflowContext> = {}): WorkflowContext {
const start: StartStep = {
@@ -27,7 +27,7 @@ function echoAgent(model = "auto"): AgentConfig {
describe("createAgentRegistry", () => {
it("get() returns AgentFn for a defined agent", async () => {
const registry = createAgentRegistry({ dev: echoAgent() });
const registry = createAgentRegistry({ dev: echoAgent() }, {});
const fn = registry.get("dev");
expect(typeof fn).toBe("function");
const out = await fn("hello", makeContext());
@@ -35,32 +35,35 @@ describe("createAgentRegistry", () => {
});
it("get() throws for an undefined agent and the message includes the name", () => {
const registry = createAgentRegistry({ dev: echoAgent() });
const registry = createAgentRegistry({ dev: echoAgent() }, {});
expect(() => registry.get("missing-agent")).toThrow(/missing-agent/);
});
it("getAgentConfig returns the original AgentConfig", () => {
const cfg = echoAgent();
const registry = createAgentRegistry({ dev: cfg });
const registry = createAgentRegistry({ dev: cfg }, {});
expect(registry.getAgentConfig("dev")).toEqual(cfg);
});
it("getAgentConfig throws for an undefined agent", () => {
const registry = createAgentRegistry({ dev: echoAgent() });
const registry = createAgentRegistry({ dev: echoAgent() }, {});
expect(() => registry.getAgentConfig("missing-agent")).toThrow(/missing-agent/);
});
it("echo adapter returns the prompt unchanged", async () => {
const registry = createAgentRegistry({ e: echoAgent() });
const registry = createAgentRegistry({ e: echoAgent() }, {});
const prompt = "exact copy\n\tunicode: 你好";
await expect(registry.get("e")(prompt, makeContext())).resolves.toBe(prompt);
});
it("multiple agents have independent instances", async () => {
const registry = createAgentRegistry({
"agent-a": echoAgent(),
"agent-b": echoAgent(),
});
const registry = createAgentRegistry(
{
"agent-a": echoAgent(),
"agent-b": echoAgent(),
},
{},
);
const a = registry.get("agent-a");
const b = registry.get("agent-b");
expect(a).not.toBe(b);
@@ -69,7 +72,7 @@ describe("createAgentRegistry", () => {
});
it("AbortSignal is accessible in context", async () => {
const registry = createAgentRegistry({ dev: echoAgent() });
const registry = createAgentRegistry({ dev: echoAgent() }, {});
const inner = registry.get("dev");
const seen: WorkflowContext[] = [];
const trace: AgentFn = async (prompt, ctx) => {
@@ -82,4 +85,21 @@ describe("createAgentRegistry", () => {
expect(seen).toHaveLength(1);
expect(seen[0].signal).toBe(ac.signal);
});
it("invokes plugin adapter factories for non-echo types", async () => {
const factories: AgentAdapterFactories = {
mirror: (cfg) => async (prompt, _ctx) => `${cfg.type}:${prompt}`,
};
const registry = createAgentRegistry(
{ dev: { type: "mirror", model: "auto", timeout: null } },
factories,
);
await expect(registry.get("dev")("ping", makeContext())).resolves.toBe("mirror:ping");
});
it("throws when adapter type is missing from factories (message lists available)", () => {
expect(() =>
createAgentRegistry({ dev: { type: "codex", model: "auto", timeout: null } }, {}),
).toThrow(/Unknown agent adapter type: "codex" \(available: echo\)/);
});
});
@@ -14,7 +14,7 @@ import type {
import { END, START } from "@uncaged/nerve-core";
import { createAgentRegistry } from "../agent-registry.js";
import { compileWorkflowSpec } from "../compile-workflow-spec.js";
import { type CompileWorkflowSpecDeps, compileWorkflowSpec } from "../compile-workflow-spec.js";
type DemoMeta = { n: number };
@@ -58,10 +58,10 @@ describe("compileWorkflowSpec", () => {
moderator: (_ctx: ModeratorContext<{ main: DemoMeta }>) => END,
};
const registry = createAgentRegistry({ dev: echoAgent() });
const registry = createAgentRegistry({ dev: echoAgent() }, {});
const def = compileWorkflowSpec(spec, {
registry,
extractFn: async (raw, _s) => ({ n: raw.length }),
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
createContext: makeContext,
});
@@ -76,13 +76,19 @@ describe("compileWorkflowSpec", () => {
const order: string[] = [];
const registry = createAgentRegistry({
dev: { type: "echo", model: "auto", timeout: null },
});
const registry = createAgentRegistry(
{
dev: { type: "echo", model: "auto", timeout: null },
},
{},
);
const extractFn = async (raw: string, _sch: Schema<DemoMeta>): Promise<DemoMeta> => {
const extractFn: CompileWorkflowSpecDeps["extractFn"] = async <T>(
raw: string,
_sch: Schema<T>,
) => {
order.push("extract");
return { n: raw.length };
return { n: raw.length } as T;
};
const orig = registry.get("dev");
@@ -130,9 +136,12 @@ describe("compileWorkflowSpec", () => {
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
const registry = createAgentRegistry({
slow: { type: "echo", model: "auto", timeout: 400_000 },
});
const registry = createAgentRegistry(
{
slow: { type: "echo", model: "auto", timeout: 400_000 },
},
{},
);
const specDefault: WorkflowSpec<{ main: DemoMeta }> = {
name: "def",
@@ -149,7 +158,7 @@ describe("compileWorkflowSpec", () => {
await compileWorkflowSpec(specDefault, {
registry,
extractFn: async () => ({ n: 0 }),
extractFn: async <T>(_raw: string, _s: Schema<T>) => ({ n: 0 }) as T,
createContext: makeContext,
}).roles.main(makeStart(), []);
@@ -172,13 +181,48 @@ describe("compileWorkflowSpec", () => {
await compileWorkflowSpec(specOverride, {
registry,
extractFn: async () => ({ n: 0 }),
extractFn: async <T>(_raw: string, _s: Schema<T>) => ({ n: 0 }) as T,
createContext: makeContext,
}).roles.main(makeStart(), []);
expect(timeoutSpy).toHaveBeenCalledWith(60_000);
timeoutSpy.mockRestore();
});
it("resolves dynamic prompt functions before AgentFn", async () => {
const witness: DemoMeta | null = null;
const schema: Schema<DemoMeta> = { witness };
const registry = createAgentRegistry(
{ dev: { type: "echo", model: "auto", timeout: null } },
{},
);
const spec: WorkflowSpec<{ main: DemoMeta }> = {
name: "dyn",
roles: {
main: {
agent: "dev",
prompt: async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
meta: schema,
timeout: null,
},
},
moderator: () => END,
};
const def = compileWorkflowSpec(spec, {
registry,
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
createContext: makeContext,
});
const start = makeStart("thread-x");
const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }];
const out = await def.roles.main(start, msgs);
expect(out.content).toBe("tid=thread-x n=1");
expect(out.meta.n).toBe(out.content.length);
});
});
describe("backward compatibility", () => {
@@ -108,7 +108,14 @@ describe("kernel — AgentRegistry hot-reload", () => {
await vi.runAllTimersAsync();
expect(mockCreateAgentRegistry).toHaveBeenCalledTimes(1);
expect(mockCreateAgentRegistry.mock.calls[0][0]).toEqual(a.agents);
expect(mockCreateAgentRegistry).toHaveBeenNthCalledWith(
1,
a.agents,
expect.objectContaining({
cursor: expect.any(Function),
hermes: expect.any(Function),
}),
);
const b = makeConfig({
dev: { type: "echo", model: "auto", timeout: null },
@@ -117,7 +124,14 @@ describe("kernel — AgentRegistry hot-reload", () => {
kernel.reloadConfig(b);
expect(mockCreateAgentRegistry).toHaveBeenCalledTimes(2);
expect(mockCreateAgentRegistry.mock.calls[1][0]).toEqual(b.agents);
expect(mockCreateAgentRegistry).toHaveBeenNthCalledWith(
2,
b.agents,
expect.objectContaining({
cursor: expect.any(Function),
hermes: expect.any(Function),
}),
);
const reloadLogs = logStore.query({ source: "system", type: "agent_registry_reload" });
expect(reloadLogs.length).toBe(1);
+25 -7
View File
@@ -1,28 +1,46 @@
import type { AgentConfig, AgentFn } from "@uncaged/nerve-core";
import { KNOWN_AGENT_ADAPTER_IDS } from "@uncaged/nerve-core";
import { createEchoAgent } from "./agent-adapters/echo.js";
export type AgentAdapterFactory = (config: AgentConfig) => AgentFn;
export type AgentAdapterFactories = Record<string, AgentAdapterFactory>;
export type AgentRegistry = {
get(name: string): AgentFn;
/** Resolved agent defaults from `nerve.yaml` (e.g. timeout for WorkflowSpec compile). */
getAgentConfig(name: string): AgentConfig;
};
function createAgentFnForConfig(config: AgentConfig): AgentFn {
function formatAvailableAdapters(adapterFactories: AgentAdapterFactories): string {
const pluginIds = Object.keys(adapterFactories).sort();
return ["echo", ...pluginIds].join(", ");
}
function createAgentFnForConfig(
config: AgentConfig,
adapterFactories: AgentAdapterFactories,
): AgentFn {
if (config.type === "echo") {
return createEchoAgent(config);
}
throw new Error(
`Unknown agent adapter type: "${config.type}" (known: ${KNOWN_AGENT_ADAPTER_IDS.join(", ")})`,
);
const factory = adapterFactories[config.type];
if (factory === undefined) {
throw new Error(
`Unknown agent adapter type: "${config.type}" (available: ${formatAvailableAdapters(adapterFactories)})`,
);
}
return factory(config);
}
export function createAgentRegistry(agents: Record<string, AgentConfig>): AgentRegistry {
export function createAgentRegistry(
agents: Record<string, AgentConfig>,
adapterFactories: AgentAdapterFactories,
): AgentRegistry {
const byName = new Map<string, AgentFn>();
const configs = new Map<string, AgentConfig>();
for (const [name, config] of Object.entries(agents)) {
byName.set(name, createAgentFnForConfig(config));
byName.set(name, createAgentFnForConfig(config, adapterFactories));
configs.set(name, config);
}
+6 -1
View File
@@ -51,7 +51,12 @@ function compileRoleForSpec<Meta extends Record<string, unknown>>(
signal,
};
const raw = await agentFn(roleSpec.prompt, ctx);
const promptText =
typeof roleSpec.prompt === "string"
? roleSpec.prompt
: await roleSpec.prompt(start, messages);
const raw = await agentFn(promptText, ctx);
const meta = await deps.extractFn(raw, roleSpec.meta);
return { content: raw, meta };
};
+5 -1
View File
@@ -59,7 +59,11 @@ export { createWorkflowManager } from "./workflow-manager.js";
export type { WorkflowManager } from "./workflow-manager.js";
export { createAgentRegistry } from "./agent-registry.js";
export type { AgentRegistry } from "./agent-registry.js";
export type {
AgentAdapterFactories,
AgentAdapterFactory,
AgentRegistry,
} from "./agent-registry.js";
export { compileWorkflowSpec } from "./compile-workflow-spec.js";
export type { CompileWorkflowSpecDeps } from "./compile-workflow-spec.js";
export { createEchoAgent } from "./agent-adapters/echo.js";
+13 -2
View File
@@ -8,6 +8,8 @@ import { hostname } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { createHermesAdapter } from "@uncaged/nerve-adapter-hermes";
import {
type HealthInfo,
type NerveConfig,
@@ -44,6 +46,15 @@ import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js";
import { createWorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "./workflow-manager.js";
import type { AgentAdapterFactories } from "./agent-registry.js";
function defaultAgentAdapterFactories(): AgentAdapterFactories {
return {
cursor: createCursorAdapter,
hermes: createHermesAdapter,
};
}
export type KernelHealth = {
uptime: number;
activeSenses: number;
@@ -130,7 +141,7 @@ export function createKernel(
});
let config = initialConfig;
let agentRegistry = createAgentRegistry(config.agents);
let agentRegistry = createAgentRegistry(config.agents, defaultAgentAdapterFactories());
let _signalIdCounter = 0;
function nextSignalId(): number {
@@ -310,7 +321,7 @@ export function createKernel(
const oldConfig = config;
const oldWorkflows = config.workflows;
config = newConfig;
agentRegistry = createAgentRegistry(newConfig.agents);
agentRegistry = createAgentRegistry(newConfig.agents, defaultAgentAdapterFactories());
logStore.append({
source: "system",
type: "agent_registry_reload",
+8
View File
@@ -4,6 +4,12 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"publishConfig": {
"access": "public"
@@ -14,6 +20,8 @@
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-adapter-cursor": "workspace:*",
"@uncaged/nerve-adapter-hermes": "workspace:*",
"@uncaged/nerve-core": "workspace:*",
"zod": "^4.3.6"
},
@@ -8,7 +8,7 @@ describe("spawnSafe", () => {
const result = await spawnSafe(
process.execPath,
["-e", "process.stdout.write(process.argv[1] ?? '')", injection],
{ cwd: null, env: null, timeoutMs: 10_000 },
{ cwd: null, env: null, timeoutMs: 10_000, abortSignal: null },
);
expect(result.ok).toBe(true);
@@ -24,6 +24,7 @@ describe("spawnSafe", () => {
cwd: null,
env: null,
timeoutMs: 10_000,
abortSignal: null,
});
expect(result.ok).toBe(false);
@@ -43,6 +44,7 @@ describe("spawnSafe", () => {
env: null,
timeoutMs: 10_000,
dryRun: true,
abortSignal: null,
});
expect(result.ok).toBe(true);
-1
View File
@@ -3,7 +3,6 @@ export { createCursorRole } from "./role-cursor.js";
export { createHermesRole } from "./role-hermes.js";
export { createLlmRole } from "./role-llm.js";
export { createReActRole } from "./role-react.js";
export { cursorAgent } from "./shared/cursor-agent.js";
export { llmExtract, llmExtractWithRetry } from "./shared/llm-extract.js";
export { mergeExtractConfig, type ExtractConfigLayer } from "./shared/merge-extract-config.js";
export {
+5 -1
View File
@@ -1,8 +1,8 @@
import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor";
import type { Role } from "@uncaged/nerve-core";
import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { type CursorAgentMode, cursorAgent } from "./shared/cursor-agent.js";
import { formatLlmError } from "./shared/format-error.js";
import { llmExtract } from "./shared/llm-extract.js";
import type { SpawnEnv } from "./shared/spawn-safe.js";
@@ -44,6 +44,7 @@ export function createCursorRole<T>(
env: Object.keys(env).length === 0 ? null : env,
timeoutMs,
dryRun: dry,
abortSignal: null,
});
if (!run.ok) {
const e = run.error;
@@ -55,6 +56,9 @@ export function createCursorRole<T>(
if (e.kind === "timeout") {
throw new Error("cursor-agent: timeout");
}
if (e.kind === "aborted") {
throw new Error("cursor-agent: aborted");
}
throw new Error(`cursor-agent: ${e.message}`);
}
const text = run.value;
@@ -23,6 +23,7 @@ export function createHermesRole<T>(
env: Object.keys(h.env).length === 0 ? null : h.env,
timeoutMs: h.timeoutMs,
dryRun: dry,
abortSignal: null,
});
if (!run.ok) {
const e = run.error;
@@ -32,6 +33,9 @@ export function createHermesRole<T>(
if (e.kind === "timeout") {
throw new Error("hermes: timeout");
}
if (e.kind === "aborted") {
throw new Error("hermes: aborted");
}
throw new Error(`hermes: ${e.message}`);
}
const text = run.value;
@@ -1,70 +1,7 @@
import { type Result, ok } from "@uncaged/nerve-core";
import type { HermesRoleDefaults, HermesRoleRequired } from "../role-types.js";
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
/**
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
* (shell: false) following the Nerve issue #208 contract.
* Adjust argv here if the upstream CLI surface changes.
*/
export type HermesAgentOptions = {
prompt: string;
model: string | null;
provider: string | null;
skills: string[];
/** When true, suppresses interactive UI noise. */
quiet: boolean;
maxTurns: number;
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
};
type HermesAgentOptionsInput = HermesAgentOptions | Omit<HermesAgentOptions, "dryRun">;
function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
export async function hermesAgent(
options: HermesAgentOptionsInput,
): Promise<Result<string, SpawnError>> {
const dryRun = resolveHermesDryRun(options);
if (dryRun) {
return ok("[dryRun] hermes stub");
}
const args: string[] = [
"chat",
"-q",
options.prompt,
"--yolo",
"--max-turns",
String(options.maxTurns),
];
if (options.model) {
args.push("--model", options.model);
}
if (options.provider) {
args.push("--provider", options.provider);
}
for (const s of options.skills) {
args.push("-s", s);
}
if (options.quiet) {
args.push("--quiet");
}
const run = await spawnSafe("hermes", args, {
cwd: null,
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
});
if (!run.ok) {
return run;
}
return ok(run.value.stdout);
}
export { hermesAgent } from "@uncaged/nerve-adapter-hermes";
export type { HermesAgentOptions } from "@uncaged/nerve-adapter-hermes";
// --- Hermes options resolution (absorbed from hermes-options.ts) ---
@@ -23,7 +23,8 @@ export type SpawnError =
signal: string | null;
}
| { kind: "timeout"; stdout: string; stderr: string }
| { kind: "spawn_failed"; message: string };
| { kind: "spawn_failed"; message: string }
| { kind: "aborted" };
export type SpawnSafeOptions = {
cwd: string | null;
@@ -31,6 +32,8 @@ export type SpawnSafeOptions = {
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
/** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */
abortSignal: AbortSignal | null;
};
type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">;
@@ -59,8 +62,15 @@ function mergeEnv(user: SpawnEnv | null): SpawnEnv {
return { ...base, ...user };
}
function resolveTimeout(timeoutMs: number | null): number {
/** Internal timer duration; `null` means rely on abort or process exit only. */
function resolveWallClockMs(
timeoutMs: number | null,
abortSignal: AbortSignal | null,
): number | null {
if (timeoutMs === null) {
if (abortSignal !== null) {
return null;
}
return DEFAULT_TIMEOUT_MS;
}
return timeoutMs;
@@ -70,6 +80,10 @@ function resolveDryRun(options: SpawnSafeOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
/**
* Spawn a process with `shell: false` (argv only), default {@link nerveCommandEnv}, and optional timeout.
* Returns `ok` only when the process exits with code 0.
@@ -91,10 +105,15 @@ export function spawnSafe(
);
}
const abortSignal = normalizeAbortSignal(options);
if (abortSignal?.aborted) {
return Promise.resolve(err({ kind: "aborted" }));
}
return new Promise((resolve) => {
const cwd = options.cwd === null ? process.cwd() : options.cwd;
const env = mergeEnv(options.env);
const timeoutMs = resolveTimeout(options.timeoutMs);
const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal);
const child = spawn(command, args, {
cwd,
@@ -107,19 +126,36 @@ export function spawnSafe(
let stderr = "";
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (outcome: Result<SpawnResult, SpawnError>) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (timer !== undefined) {
clearTimeout(timer);
}
if (abortSignal !== null) {
abortSignal.removeEventListener("abort", onAbort);
}
resolve(outcome);
};
const timer = setTimeout(() => {
function onAbort() {
child.kill("SIGTERM");
finish(err({ kind: "timeout", stdout, stderr }));
}, timeoutMs);
finish(err({ kind: "aborted" }));
}
if (abortSignal !== null) {
abortSignal.addEventListener("abort", onAbort);
}
if (wallClockMs !== null) {
timer = setTimeout(() => {
child.kill("SIGTERM");
finish(err({ kind: "timeout", stdout, stderr }));
}, wallClockMs);
}
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
+44
View File
@@ -21,6 +21,38 @@ importers:
specifier: ^5.5.0
version: 5.9.3
packages/adapter-cursor:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
devDependencies:
'@rslib/core':
specifier: ^0.21.3
version: 0.21.3(typescript@5.9.3)
'@types/node':
specifier: ^22.0.0
version: 22.19.17
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))
packages/adapter-hermes:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
devDependencies:
'@rslib/core':
specifier: ^0.21.3
version: 0.21.3(typescript@5.9.3)
'@types/node':
specifier: ^22.0.0
version: 22.19.17
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))
packages/cli:
dependencies:
'@uncaged/nerve-core':
@@ -70,6 +102,12 @@ importers:
packages/daemon:
dependencies:
'@uncaged/nerve-adapter-cursor':
specifier: workspace:*
version: link:../adapter-cursor
'@uncaged/nerve-adapter-hermes':
specifier: workspace:*
version: link:../adapter-hermes
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
@@ -144,6 +182,12 @@ importers:
packages/workflow-utils:
dependencies:
'@uncaged/nerve-adapter-cursor':
specifier: workspace:*
version: link:../adapter-cursor
'@uncaged/nerve-adapter-hermes':
specifier: workspace:*
version: link:../adapter-hermes
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core