feat: RFC-003 Adapter Plugin Architecture + Dynamic Prompts #243
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
+44
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Generated
+44
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user