feat(core): RFC-003 Phase 1 — agent config types + nerve.yaml schema

- Add AgentFn, WorkflowContext (workdir + AbortSignal), ExtractFn, ExtractError
- Add AgentConfig, ExtractConfig types to NerveConfig
- Extend parseNerveConfig: agents (kebab-case keys) + extract sections
- Export all new types from @nerve/core
- Add config parse tests (7 new tests)
- Update all existing test fixtures with agents/extract fields

Closes #235
Ref: #234
This commit is contained in:
2026-04-29 04:43:08 +00:00
parent 7a4e16381c
commit 36e5aed1b1
19 changed files with 339 additions and 0 deletions
@@ -200,6 +200,8 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
...(withNoopWorkflow ? { noop: { concurrency: 1, overflow: "drop" as const } } : {}),
},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
}
+126
View File
@@ -52,6 +52,8 @@ describe("parseNerveConfig", () => {
overflow: "queue",
maxQueue: 10,
});
expect(result.value.agents).toEqual({});
expect(result.value.extract).toBe(null);
expect(result.value.api).toEqual({ port: null, token: null, host: "127.0.0.1" });
});
@@ -220,6 +222,58 @@ senses:
expect(result.value.senses.cpu.interval).toBe(5000);
expect(result.value.senses.cpu.on).toEqual(["memory"]);
});
it("parses agents and extract sections", () => {
const yaml = `
senses:
cpu:
group: system
agents:
developer:
type: cursor
model: auto
timeout: 300s
my-custom-agent:
type: hermes
model: auto
extract:
provider: dashscope
model: qwen-plus
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.agents.developer).toEqual({
type: "cursor",
model: "auto",
timeout: 300_000,
});
expect(result.value.agents["my-custom-agent"]).toEqual({
type: "hermes",
model: "auto",
timeout: null,
});
expect(result.value.extract).toEqual({ provider: "dashscope", model: "qwen-plus" });
});
it("allows arbitrary kebab-case agent names including multi-segment keys", () => {
const yaml = `
senses:
cpu:
group: system
agents:
a:
type: x
model: auto
bb-cc-dd:
type: y
model: z
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(Object.keys(result.value.agents).sort()).toEqual(["a", "bb-cc-dd"]);
});
});
describe("invalid configs", () => {
@@ -449,5 +503,77 @@ workflows:
if (result.ok) return;
expect(result.error.message).toMatch(/max_queue.*not allowed.*drop/);
});
it("returns error when agent key is not kebab-case", () => {
const yaml = `
senses:
cpu:
group: system
agents:
Developer:
type: cursor
model: auto
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/invalid key "Developer"/);
});
it("returns error when agent key uses underscores", () => {
const yaml = `
senses:
cpu:
group: system
agents:
my_agent:
type: cursor
model: auto
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/invalid key "my_agent"/);
});
it("returns error when agents section is not an object", () => {
const yaml = `
senses:
cpu:
group: system
agents: []
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/agents: must be an object/);
});
it("returns error when extract section is not an object", () => {
const yaml = `
senses:
cpu:
group: system
extract: "dashscope"
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/extract: must be an object/);
});
it("returns error when extract.provider is missing", () => {
const yaml = `
senses:
cpu:
group: system
extract:
model: qwen-plus
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/extract\.provider/);
});
});
});
+19
View File
@@ -36,6 +36,21 @@ export type NerveApiConfig = {
host: string;
};
/** Agent adapter defaults keyed by arbitrary kebab-case names in `nerve.yaml` (RFC-003). */
export type AgentConfig = {
/** Adapter id (e.g. `cursor`, `hermes`, `codex`). */
type: string;
/** Model id or `"auto"` for adapter defaults. */
model: string;
timeout: number | null;
};
/** Global extract provider for typed meta from agent raw output (RFC-003). */
export type ExtractConfig = {
provider: string;
model: string;
};
/** Parameters for starting a workflow from a Sense compute result (or CLI trigger). */
export type WorkflowTrigger = {
name: string;
@@ -56,4 +71,8 @@ export type NerveConfig = {
senses: Record<string, SenseConfig>;
workflows: Record<string, WorkflowConfig>;
api: NerveApiConfig;
/** Named agent adapters; keys must be kebab-case (RFC-003). */
agents: Record<string, AgentConfig>;
/** Global extract defaults; `null` when the section is omitted. */
extract: ExtractConfig | null;
};
+23
View File
@@ -0,0 +1,23 @@
/**
* Extract layer types — parses agent raw string output into typed meta (RFC-003).
*/
/** Structured meta validation descriptor for `ExtractFn`; concrete validators are provider-defined. */
export type Schema<T> = {
readonly witness: T | null;
};
export type ExtractFn<T> = (raw: string, schema: Schema<T>) => Promise<T>;
export class ExtractError extends Error {
readonly raw: string;
readonly causeError: Error | null;
constructor(message: string, detail: { raw: string; causeError: Error | null }) {
super(message);
this.name = "ExtractError";
this.raw = detail.raw;
this.causeError = detail.causeError;
Object.setPrototypeOf(this, new.target.prototype);
}
}
+6
View File
@@ -5,6 +5,8 @@ export type {
QueueOverflowConfig,
WorkflowConfig,
NerveApiConfig,
AgentConfig,
ExtractConfig,
NerveConfig,
WorkflowTrigger,
ComputeResult,
@@ -17,12 +19,16 @@ export type {
Role,
RoleMeta,
StartStep,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type { Schema, ExtractFn } from "./extract-layer.js";
export { ExtractError } from "./extract-layer.js";
export type { Result } from "./result.js";
export { ok, err } from "./result.js";
export { parseNerveConfig } from "./parse-nerve-config.js";
+90
View File
@@ -1,7 +1,9 @@
import { parse } from "yaml";
import {
type AgentConfig,
DEFAULT_SENSE_SIGNAL_RETENTION,
type ExtractConfig,
type NerveApiConfig,
type NerveConfig,
type SenseConfig,
@@ -30,6 +32,11 @@ function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
/** Agent map keys in nerve.yaml — arbitrary kebab-case labels (RFC-003). */
function isValidAgentKebabName(name: string): boolean {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
}
function parseRetentionField(name: string, field: unknown): Result<number> {
if (field === undefined || field === null) {
return ok(DEFAULT_SENSE_SIGNAL_RETENTION);
@@ -281,6 +288,81 @@ function parseWorkflows(obj: Record<string, unknown>): Result<Record<string, Wor
return ok(workflows);
}
function validateAgentConfig(agentKey: string, raw: unknown): Result<AgentConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`agents.${agentKey}: must be an object`));
}
const obj = raw;
if (typeof obj.type !== "string" || obj.type.trim() === "") {
return err(new Error(`agents.${agentKey}.type: required non-empty string`));
}
if (typeof obj.model !== "string" || obj.model.trim() === "") {
return err(new Error(`agents.${agentKey}.model: required non-empty string`));
}
const timeoutResult = parseDurationField(obj.timeout, `agents.${agentKey}.timeout`);
if (!timeoutResult.ok) return timeoutResult;
return ok({
type: obj.type,
model: obj.model,
timeout: timeoutResult.value,
});
}
function parseAgents(obj: Record<string, unknown>): Result<Record<string, AgentConfig>> {
if (obj.agents === undefined || obj.agents === null) {
return ok({});
}
if (!isPlainRecord(obj.agents)) {
return err(new Error("agents: must be an object if provided"));
}
const agents: Record<string, AgentConfig> = {};
for (const [name, agentRaw] of Object.entries(obj.agents)) {
if (!isValidAgentKebabName(name)) {
return err(
new Error(
`agents: invalid key "${name}" (expected kebab-case: lowercase letters, digits, single hyphens between segments)`,
),
);
}
const result = validateAgentConfig(name, agentRaw);
if (!result.ok) return result;
agents[name] = result.value;
}
return ok(agents);
}
function parseExtract(obj: Record<string, unknown>): Result<ExtractConfig | null> {
if (obj.extract === undefined || obj.extract === null) {
return ok(null);
}
if (!isPlainRecord(obj.extract)) {
return err(new Error("extract: must be an object if provided"));
}
const ext = obj.extract;
if (typeof ext.provider !== "string" || ext.provider.trim() === "") {
return err(new Error("extract.provider: required non-empty string"));
}
if (typeof ext.model !== "string" || ext.model.trim() === "") {
return err(new Error("extract.model: required non-empty string"));
}
return ok({ provider: ext.provider, model: ext.model });
}
export function parseNerveConfig(raw: string): Result<NerveConfig> {
let parsed: unknown;
@@ -319,10 +401,18 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
const apiResult = parseApiConfig(obj);
if (!apiResult.ok) return apiResult;
const agentsResult = parseAgents(obj);
if (!agentsResult.ok) return agentsResult;
const extractResult = parseExtract(obj);
if (!extractResult.ok) return extractResult;
return ok({
maxRounds: maxRoundsResult.value,
senses,
workflows: workflowsResult.value,
api: apiResult.value,
agents: agentsResult.value,
extract: extractResult.value,
});
}
+11
View File
@@ -44,6 +44,17 @@ export type StartStep = {
timestamp: number;
};
/** Thread context passed to agent adapters (RFC-003): conversation frame, repo root, cancellation. */
export type WorkflowContext = {
start: StartStep;
messages: WorkflowMessage[];
workdir: string;
signal: AbortSignal;
};
/** Unified agent invocation — raw string output; structured meta uses the extract layer. */
export type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>;
/** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */
export type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
@@ -64,6 +64,8 @@ function makeConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig
senses: {},
workflows,
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
}
@@ -70,6 +70,8 @@ function makeWfConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConf
senses: {},
workflows,
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
}
@@ -459,6 +461,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
senses: {},
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -494,6 +498,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
senses: {},
workflows: { "old-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -515,6 +521,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
senses: {},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
kernel.reloadConfig(newConfig);
@@ -537,6 +545,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
senses: {},
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -553,6 +563,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
senses: {},
workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } },
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
kernel.reloadConfig(newConfig);
@@ -37,6 +37,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -85,6 +85,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -244,6 +246,8 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
});
@@ -277,6 +281,8 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
const kernel = createKernel(config, nerveRoot);
@@ -300,6 +306,8 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
});
@@ -339,6 +347,8 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
});
@@ -105,6 +105,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -117,6 +117,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -455,6 +457,8 @@ describe("kernel + workflowManager integration", () => {
},
workflows: { "new-workflow": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
kernel.reloadConfig(newConfig);
@@ -531,6 +535,8 @@ describe("kernel + workflowManager integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
kernel.reloadConfig(newConfig);
@@ -74,6 +74,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -285,6 +287,8 @@ describe("kernel — groupForSense mapping", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
const kernel = createKernel(config, nerveRoot);
@@ -38,6 +38,8 @@ describe("LogStore + SenseScheduler integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
const bus = createSignalBus();
@@ -74,6 +76,8 @@ describe("LogStore + SenseScheduler integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
const bus = createSignalBus();
@@ -113,6 +117,8 @@ describe("LogStore + SenseScheduler integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
const bus = createSignalBus();
@@ -34,6 +34,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -169,6 +171,8 @@ describe("phase6 — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -205,6 +209,8 @@ describe("phase6 — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
kernel = createKernel(config, nerveRoot, {
@@ -228,6 +234,8 @@ describe("phase6 — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -281,6 +289,8 @@ describe("phase6 — error isolation", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -431,6 +441,8 @@ describe("phase6 — getHealth", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
kernel.reloadConfig(newConfig);
@@ -19,6 +19,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -41,6 +41,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
};
@@ -89,6 +89,8 @@ function makeConfig(overrides: Partial<NerveConfig["workflows"]> = {}): NerveCon
maxRounds: 10,
senses: {},
workflows: overrides as NerveConfig["workflows"],
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
}