feat: real embedding integration + remove AgentRegistry (#244, #245) #246

Merged
xiaomo merged 6 commits from feat/244-phase-c into main 2026-04-29 09:04:42 +00:00
46 changed files with 624 additions and 954 deletions
+70 -76
View File
@@ -6,29 +6,30 @@
## Summary
Introduce a top-level `agents` and `extract` configuration in `nerve.yaml`, separating agent infrastructure from workflow business logic. Workflows define Roles (prompt + schema) that reference named Agents by domain expertise, not by implementation detail.
Define a minimal agent abstraction where **adapter = capability** and **role = scenario**. Workflows directly declare which adapter each role uses — no intermediate registry or `nerve.yaml` agent config. `nerve.yaml` only holds `extract` config and `knowledge` settings.
## Motivation
Currently, Role definitions in workflows are tightly coupled with agent implementation details (type, model, timeout). This leads to:
The original design introduced a `nerve.yaml` agents registry to map logical names (e.g. `developer`) to adapter implementations. In practice this added an unnecessary layer of indirection:
- **Duplication** — multiple workflows using the same agent config repeat it everywhere
- **Fragility** — switching from `cursor` to `codex` requires touching every workflow
- **Leaky abstraction** — workflow authors need to know agent internals
- **Agent names are arbitrary** — `developer` vs `coder` vs `engineer` is a naming exercise, not architecture
- **One more config to maintain** — adding/changing an adapter requires editing both `nerve.yaml` and the workflow
- **Same adapter, same config** — in reality, most workflows just need "use cursor" or "use hermes", not a named abstraction on top
The simpler model: **workflow roles declare their adapter directly**. The adapter *is* the capability.
## Key Concepts
### Agent vs Role
### Adapter vs Role
| | Agent | Role |
| | Adapter | Role |
|---|---|---|
| **What** | Professional domain capability | Scenario-specific persona |
| **Granularity** | Few (2–4) | Many (per workflow step) |
| **Examples** | `developer`, `ops`, `writer` | `architect`, `coder`, `reviewer`, `deployer` |
| **Defines** | Tool, model, timeout defaults | Prompt, meta schema, timeout override |
| **Layer** | Infrastructure (`nerve.yaml`) | Business logic (TypeScript) |
| **What** | Capability — what tools are available | Scenario — what to do with those tools |
| **Granularity** | Few (cursor, hermes, claude, codex) | Many (per workflow step) |
| **Defines** | How to spawn an agent, tool access | Prompt, schema, timeout |
| **Layer** | Infrastructure (packages) | Business logic (WorkflowSpec) |
A `developer` agent becomes an architect, coder, or reviewer depending on the prompt it receives. The agent defines *what it's good at*; the role defines *what it does right now*.
A `cursor` adapter becomes an architect, coder, or reviewer depending on the role's prompt. The adapter defines *what it can do*; the role defines *what it does right now*.
### Agent Protocol
@@ -52,7 +53,7 @@ A separate concern that parses agent output (raw string) into typed meta:
type ExtractFn<T> = (raw: string, schema: Schema<T>) => Promise<T>
```
Configured globally in `nerve.yaml`, overridable per agent and per role (three-level merge: global → agent → role).
Configured globally in `nerve.yaml`, overridable per role (two-level merge: global → role).
**Error handling**: retry once (feed raw output + parse error back to LLM for correction), then throw `ExtractError`. The workflow moderator decides the recovery strategy (retry role, skip, or terminate) — extract never makes workflow-level decisions.
@@ -60,17 +61,9 @@ Configured globally in `nerve.yaml`, overridable per agent and per role (three-l
### Configuration (`nerve.yaml`)
```yaml
agents:
developer:
type: cursor # adapter: cursor | hermes | codex | ...
model: auto # "auto" = delegate to adapter's default strategy
timeout: 300s
ops:
type: hermes
model: auto # each adapter interprets "auto" independently
timeout: 600s
`nerve.yaml` holds only extract and knowledge config — no agent registry:
```yaml
extract:
provider: dashscope
model: qwen-plus
@@ -78,14 +71,19 @@ extract:
### Workflow Definition (TypeScript)
Roles declare their adapter directly — no indirection through named agents:
```ts
import { cursorAdapter, createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
const workflow: WorkflowSpec<MyMeta> = {
name: "develop-workflow",
roles: {
architect: { agent: "developer", prompt: architectPrompt, meta: architectSchema },
coder: { agent: "developer", prompt: coderPrompt, meta: coderSchema },
reviewer: { agent: "developer", prompt: reviewPrompt, meta: reviewSchema, timeout: "60s" },
deployer: { agent: "ops", prompt: deployPrompt, meta: deploySchema },
architect: { adapter: cursorAdapter, prompt: architectPrompt, meta: architectSchema },
coder: { adapter: createCursorAdapter({ model: "claude-sonnet-4", timeout: 600 }), prompt: coderPrompt, meta: coderSchema },
reviewer: { adapter: hermesAdapter, prompt: reviewPrompt, meta: reviewSchema },
deployer: { adapter: hermesAdapter, prompt: deployPrompt, meta: deploySchema },
},
moderator,
};
@@ -94,14 +92,12 @@ const workflow: WorkflowSpec<MyMeta> = {
### Runtime Assembly
```
nerve.yaml → AgentRegistry → adapter(cursor/hermes/codex/...)
WorkflowSpec → Role(agent + prompt) → AgentFn(prompt, ctx) → string
WorkflowSpec → Role(adapter fn + prompt) → adapter(prompt, ctx) → string
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 is a direct function reference on each role — no map, no lookup, no registry.
### Adapter Packages
@@ -109,49 +105,51 @@ Each agent adapter lives in its own package to avoid pulling unnecessary depende
```
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)
adapter-cursor/ # @uncaged/nerve-adapter-cursor — cursor-agent CLI
adapter-hermes/ # @uncaged/nerve-adapter-hermes — hermes CLI subagent
adapter-claude/ # @uncaged/nerve-adapter-claude — claude-code CLI (future)
adapter-codex/ # @uncaged/nerve-adapter-codex — codex CLI (future)
```
Each adapter exports a single factory function:
Each adapter exports a **default instance** and a **factory** for customization:
```ts
// @nerve/adapter-cursor
import type { AgentConfig, AgentFn } from "@nerve/core";
// @uncaged/nerve-adapter-cursor
import type { AgentConfig, AgentFn } from "@uncaged/nerve-core";
// Factory — custom config
export function createCursorAdapter(config: AgentConfig): AgentFn;
// Default — sensible defaults (model: "auto", timeout: 300)
export const cursorAdapter: 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.
The factory receives adapter config (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:
**Wiring** — workflows import adapters directly, no daemon-level registry:
```ts
import { createCursorAdapter } from "@nerve/adapter-cursor";
import { createHermesAdapter } from "@nerve/adapter-hermes";
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
const registry = createAgentRegistry(config.agents, {
cursor: createCursorAdapter,
hermes: createHermesAdapter,
});
// Use default instances directly in roles
{ adapter: cursorAdapter, prompt: "...", meta: schema }
```
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.
Adapters not installed simply can't be imported — TypeScript catches missing dependencies at compile time.
**Workspace `package.json`** only lists the adapters it actually uses:
```json
{
"dependencies": {
"@nerve/adapter-cursor": "workspace:*",
"@nerve/adapter-hermes": "workspace:*"
"@uncaged/nerve-adapter-cursor": "workspace:*",
"@uncaged/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.
**Migration from `workflow-utils`** — the existing `role-cursor.ts` / `shared/cursor-agent.ts` spawn logic moves to `@uncaged/nerve-adapter-cursor`. `role-hermes.ts` / `shared/hermes-agent.ts` moves to `@uncaged/nerve-adapter-hermes`. `workflow-utils` retains only extract, prompt utilities, and shared spawn infrastructure.
### Dynamic Prompts
@@ -161,10 +159,9 @@ The daemon's entry point wires installed adapters; adapters not installed are no
type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
type RoleSpec<M> = {
agent: string;
adapter: AgentFn;
prompt: PromptInput;
meta: Schema<M>;
timeout: string | null;
};
```
@@ -172,23 +169,20 @@ Static prompts cover simple cases. Dynamic prompts (functions) are needed when t
### Timeout Resolution
Two-layer with role override:
Timeout is an **adapter concern**, not a role concern. Roles define *what to do* (prompt + schema); adapters define *how to do it* (tool, model, timeout).
1. Agent config provides the default timeout
2. Role definition can override for specific scenarios
```yaml
# Agent default
agents:
developer:
timeout: 300s
```
When different roles need different timeouts, create separate adapter instances:
```ts
// Role override — review is faster
reviewer: { agent: "developer", ..., timeout: "60s" }
// coder uses agent default (300s)
coder: { agent: "developer", ... }
import { cursorAdapter, createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
const fastCursor = createCursorAdapter({ model: "auto", timeout: 60 });
const slowCursor = createCursorAdapter({ model: "auto", timeout: 600 });
roles: {
reviewer: { adapter: fastCursor, prompt: reviewPrompt, meta: reviewSchema },
coder: { adapter: slowCursor, prompt: coderPrompt, meta: coderSchema },
}
```
### No Runtime Fallback
@@ -198,9 +192,9 @@ coder: { agent: "developer", ... }
Rationale: silent fallback hides quality differences (cursor → hermes subagent produces very different output) and makes debugging harder.
### Agent Hot-Reload
### Adapter Hot-Reload
Follows the existing `nerve.yaml` hot-reload mechanism. On config change, `AgentRegistry` rebuilds. Running workflow threads are not affected (they use the `AgentFn` bound at thread start). New threads automatically use the updated config.
Follows the existing `nerve.yaml` hot-reload mechanism. On config change, adapters are rebuilt. Running workflow threads are not affected (they use the `AdapterFn` bound at thread start). New threads automatically use the updated config.
### WorkflowContext
@@ -218,8 +212,8 @@ type WorkflowContext = {
### Configuration Validation
`nerve validate` checks:
- All agent names referenced in WorkflowSpec roles exist in `nerve.yaml`
- Agent type adapters are available (CLI exists, service reachable)
- All roles have a valid adapter function (not null/undefined)
- Adapter CLIs are available (binary exists in PATH)
- Extract provider is configured and reachable
## Compatibility with Current Types
@@ -230,7 +224,7 @@ The existing `Role<Meta>` signature:
type Role<Meta> = (start: StartStep, messages: WorkflowMessage[]) => Promise<RoleResult<Meta>>
```
remains the runtime interface. The new config layer is syntactic sugar — the runtime assembles `Role<Meta>` functions from `(agent config + prompt + schema)` instead of users writing them by hand. `WorkflowDefinition` stays the same at the engine level; `WorkflowSpec` is the new user-facing authoring format that compiles down to it at daemon startup / hot-reload time (runtime lazy compile, not `nerve init`).
remains the runtime interface. The new config layer is syntactic sugar — the runtime assembles `Role<Meta>` functions from `(adapter + prompt + schema)` instead of users writing them by hand. `WorkflowDefinition` stays the same at the engine level; `WorkflowSpec` is the new user-facing authoring format that compiles down to it at daemon startup / hot-reload time (runtime lazy compile, not `nerve init`).
Existing hand-written `Role` functions continue to work — `WorkflowSpec` is additive, not a breaking change.
@@ -310,13 +304,13 @@ Workflow context (start + msgs) Per run, moderator-controlled history
## Open Questions
1. **Agent long-term memory** — storage format and mechanism for persisting domain expertise across runs
2. **Embedding service** — self-hosted vs managed (Cloudflare Workers AI, Dashscope, etc.), model choice (e.g. `text-embedding-3-small`)
### Resolved
- **Agent naming** → arbitrary names allowed, docs provide a recommended set (`developer`, `ops`, `writer`)
- **Extract override granularity** → three-level merge: global → agent → role
- **Agent naming / registry** → removed; workflow roles declare adapter directly, no intermediate registry
- **Extract override granularity** → two-level merge: global → role (agent level removed)
- **Context threading** → `WorkflowContext` includes `workdir` and `signal` (see design above)
- **Embedding service** → self-hosted, 1024-dim vectors, content-hash cache
## References
+14 -2
View File
@@ -83,10 +83,15 @@ function throwCursorSpawnError(error: SpawnError): never {
throw new Error(`cursor-agent: ${error.message}`);
}
/** Default adapter config: model auto-selection and 300s wall-clock cap (milliseconds). */
const CURSOR_ADAPTER_DEFAULT_MS = 300_000;
/**
* Factory for RFC-003 `AgentRegistry`: runs `cursor-agent` using config + per-invocation context.
* Builds a Cursor CLI `AgentFn` from adapter config (model, timeout).
*/
export function createCursorAdapter(config: AgentConfig): AgentFn {
const timeoutMs = config.timeout;
return async (prompt: string, context: WorkflowContext): Promise<string> => {
const run = await cursorAgent({
prompt,
@@ -94,7 +99,7 @@ export function createCursorAdapter(config: AgentConfig): AgentFn {
model: config.model,
cwd: context.workdir,
env: null,
timeoutMs: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
});
@@ -104,3 +109,10 @@ export function createCursorAdapter(config: AgentConfig): AgentFn {
return run.value;
};
}
/** Default instance — `model: "auto"`, `timeout: 300` seconds (as milliseconds). */
export const cursorAdapter: AgentFn = createCursorAdapter({
type: "cursor",
model: "auto",
timeout: CURSOR_ADAPTER_DEFAULT_MS,
});
+13 -2
View File
@@ -88,11 +88,15 @@ function throwHermesSpawnError(error: SpawnError): never {
const HERMES_ADAPTER_DEFAULT_MAX_TURNS = 90;
/** Default wall-clock cap: 300 seconds (milliseconds). */
const HERMES_ADAPTER_DEFAULT_MS = 300_000;
/**
* Factory for RFC-003 `AgentRegistry`: runs `hermes chat` using config + per-invocation context.
* Builds a Hermes CLI `AgentFn` from adapter config (model, timeout).
*/
export function createHermesAdapter(config: AgentConfig): AgentFn {
const modelFromConfig = config.model === "auto" ? null : config.model;
const timeoutMs = config.timeout;
return async (prompt: string, context: WorkflowContext): Promise<string> => {
const run = await hermesAgent({
@@ -103,7 +107,7 @@ export function createHermesAdapter(config: AgentConfig): AgentFn {
quiet: true,
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
env: null,
timeoutMs: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
});
@@ -113,3 +117,10 @@ export function createHermesAdapter(config: AgentConfig): AgentFn {
return run.value;
};
}
/** Default instance — `model: "auto"`, `timeout: 300` seconds (as milliseconds). */
export const hermesAdapter: AgentFn = createHermesAdapter({
type: "hermes",
model: "auto",
timeout: HERMES_ADAPTER_DEFAULT_MS,
});
@@ -200,7 +200,6 @@ 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" },
};
@@ -1,35 +1,201 @@
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { KnowledgeChunkRow } from "../knowledge/knowledge-db.js";
import { rankChunksByWordOverlap } from "../knowledge/query.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function chunk(path: string, text: string): KnowledgeChunkRow {
return {
path,
slug: `${path}#0`,
chunkIndex: 0,
text,
embedding: Buffer.alloc(8),
contentHash: "ab",
};
import { fakeEmbeddingBytes } from "../knowledge/fake-embedding.js";
import { contentHash, openKnowledgeDb, replaceAllChunks } from "../knowledge/knowledge-db.js";
import { KNOWLEDGE_DB } from "../knowledge/paths.js";
const DIM = 1024;
function fakeEmbedding1024(seed: string): Buffer {
const buf = Buffer.alloc(DIM * 4);
for (let i = 0; i < DIM; i++) {
const c = seed.charCodeAt(i % Math.max(seed.length, 1)) || 1;
buf.writeFloatLE((c / 255) * Math.sin(i + 0.1), i * 4);
}
return buf;
}
describe("rankChunksByWordOverlap", () => {
it("returns higher scores for chunks that share words with the query", () => {
const rows = [
chunk("a.md", "the signal bus emits notifications"),
chunk("b.md", "unrelated cooking recipes"),
];
const embedMocks = vi.hoisted(() => ({
resolveEmbedConfig: vi.fn(),
embedQuery: vi.fn(),
}));
const ranked = rankChunksByWordOverlap("signal bus", rows, 10);
vi.mock("../knowledge/embed-service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../knowledge/embed-service.js")>();
return {
...actual,
resolveEmbedConfig: () => embedMocks.resolveEmbedConfig(),
embedQuery: (cfg: Parameters<typeof actual.embedQuery>[0], text: string) =>
embedMocks.embedQuery(cfg, text),
};
});
import { queryKnowledgeRepo } from "../knowledge/query.js";
describe("queryKnowledgeRepo (word overlap fallback)", () => {
const savedUrl = process.env.EMBED_SERVICE_URL;
const savedToken = process.env.EMBED_AUTH_TOKEN;
beforeEach(() => {
process.env.EMBED_SERVICE_URL = undefined;
process.env.EMBED_AUTH_TOKEN = undefined;
embedMocks.resolveEmbedConfig.mockReturnValue(null);
embedMocks.embedQuery.mockReset();
});
afterEach(() => {
if (savedUrl !== undefined) {
process.env.EMBED_SERVICE_URL = savedUrl;
} else {
process.env.EMBED_SERVICE_URL = undefined;
}
if (savedToken !== undefined) {
process.env.EMBED_AUTH_TOKEN = savedToken;
} else {
process.env.EMBED_AUTH_TOKEN = undefined;
}
});
it("returns higher scores for chunks that share words with the query", async () => {
const root = mkdtempSync(join(tmpdir(), "nerve-q-"));
const dbPath = join(root, KNOWLEDGE_DB);
mkdirSync(root, { recursive: true });
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, [
{
path: "a.md",
slug: "a.md#0",
chunkIndex: 0,
text: "the signal bus emits notifications",
contentHash: contentHash("the signal bus emits notifications"),
embedding: fakeEmbeddingBytes("a"),
},
{
path: "b.md",
slug: "b.md#0",
chunkIndex: 0,
text: "unrelated cooking recipes",
contentHash: contentHash("unrelated cooking recipes"),
embedding: fakeEmbeddingBytes("b"),
},
]);
} finally {
db.close();
}
const ranked = await queryKnowledgeRepo(root, dbPath, "signal bus", 10);
expect(ranked.length).toBe(2);
expect(ranked[0]?.chunk.path).toBe("a.md");
expect(ranked[1]?.chunk.path).toBe("b.md");
expect(ranked[0]?.path).toBe("a.md");
expect(ranked[1]?.path).toBe("b.md");
expect(ranked[0]?.score).toBeGreaterThan(ranked[1]?.score ?? 0);
});
it("respects limit", () => {
const rows = [chunk("x.md", "one"), chunk("y.md", "two")];
expect(rankChunksByWordOverlap("one", rows, 1)).toHaveLength(1);
it("respects limit", async () => {
const root = mkdtempSync(join(tmpdir(), "nerve-q2-"));
const dbPath = join(root, KNOWLEDGE_DB);
mkdirSync(root, { recursive: true });
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, [
{
path: "x.md",
slug: "x.md#0",
chunkIndex: 0,
text: "one",
contentHash: contentHash("one"),
embedding: fakeEmbeddingBytes("x"),
},
{
path: "y.md",
slug: "y.md#0",
chunkIndex: 0,
text: "two",
contentHash: contentHash("two"),
embedding: fakeEmbeddingBytes("y"),
},
]);
} finally {
db.close();
}
const ranked = await queryKnowledgeRepo(root, dbPath, "one", 1);
expect(ranked).toHaveLength(1);
});
});
describe("queryKnowledgeRepo (embed service)", () => {
const savedUrl = process.env.EMBED_SERVICE_URL;
const savedToken = process.env.EMBED_AUTH_TOKEN;
beforeEach(() => {
process.env.EMBED_SERVICE_URL = "http://embed.test";
process.env.EMBED_AUTH_TOKEN = "test-token";
embedMocks.resolveEmbedConfig.mockReturnValue({
url: "http://embed.test",
token: "test-token",
});
embedMocks.embedQuery.mockImplementation(async (_c: unknown, text: string) =>
fakeEmbedding1024(text),
);
});
afterEach(() => {
embedMocks.embedQuery.mockReset();
embedMocks.resolveEmbedConfig.mockReset();
if (savedUrl !== undefined) {
process.env.EMBED_SERVICE_URL = savedUrl;
} else {
process.env.EMBED_SERVICE_URL = undefined;
}
if (savedToken !== undefined) {
process.env.EMBED_AUTH_TOKEN = savedToken;
} else {
process.env.EMBED_AUTH_TOKEN = undefined;
}
});
it("uses cosine similarity when embed config is present", async () => {
const root = mkdtempSync(join(tmpdir(), "nerve-q-embed-"));
const dbPath = join(root, KNOWLEDGE_DB);
mkdirSync(root, { recursive: true });
const textA = "alpha beta gamma";
const textB = "zzz unrelated";
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, [
{
path: "a.md",
slug: "a.md#0",
chunkIndex: 0,
text: textA,
contentHash: contentHash(textA),
embedding: fakeEmbedding1024(textA),
},
{
path: "b.md",
slug: "b.md#0",
chunkIndex: 0,
text: textB,
contentHash: contentHash(textB),
embedding: fakeEmbedding1024(textB),
},
]);
} finally {
db.close();
}
const ranked = await queryKnowledgeRepo(root, dbPath, textA, 10);
expect(ranked.length).toBe(2);
expect(ranked[0]?.path).toBe("a.md");
expect(ranked[0]?.score).toBeGreaterThan(ranked[1]?.score ?? 0);
});
});
@@ -4,12 +4,55 @@ import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const DIM = 1024;
function fakeEmbedding1024(seed: string): Buffer {
const buf = Buffer.alloc(DIM * 4);
for (let i = 0; i < DIM; i++) {
const c = seed.charCodeAt(i % Math.max(seed.length, 1)) || 1;
buf.writeFloatLE((c / 255) * Math.sin(i + 0.1), i * 4);
}
return buf;
}
vi.mock("../knowledge/embed-service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../knowledge/embed-service.js")>();
return {
...actual,
resolveEmbedConfig: vi.fn(() => ({ url: "http://embed.test", token: "test-token" })),
embedTexts: vi.fn(async (_config: unknown, texts: string[]) =>
texts.map((t) => fakeEmbedding1024(t)),
),
};
});
import { runKnowledgeSync } from "../knowledge/sync.js";
describe("runKnowledgeSync", () => {
it("creates knowledge.db with chunk rows", () => {
const savedUrl = process.env.EMBED_SERVICE_URL;
const savedToken = process.env.EMBED_AUTH_TOKEN;
beforeEach(() => {
process.env.EMBED_SERVICE_URL = "http://embed.test";
process.env.EMBED_AUTH_TOKEN = "test-token";
});
afterEach(() => {
if (savedUrl !== undefined) {
process.env.EMBED_SERVICE_URL = savedUrl;
} else {
process.env.EMBED_SERVICE_URL = undefined;
}
if (savedToken !== undefined) {
process.env.EMBED_AUTH_TOKEN = savedToken;
} else {
process.env.EMBED_AUTH_TOKEN = undefined;
}
});
it("creates knowledge.db with chunk rows", async () => {
const nerveHome = mkdtempSync(join(tmpdir(), "nerve-home-"));
const root = mkdtempSync(join(tmpdir(), "nerve-know-sync-"));
mkdirSync(join(root, "docs"), { recursive: true });
@@ -29,8 +72,9 @@ exclude: []
`,
);
const result = runKnowledgeSync(root, nerveHome);
const result = await runKnowledgeSync(root, nerveHome);
expect(result.chunksWritten).toBeGreaterThan(0);
expect(result.embeddingSource).toBe("remote");
const db = new DatabaseSync(result.dbPath, { readOnly: true });
try {
@@ -1,5 +1,5 @@
/**
* RFC-003 Phase 5: nerve validate — WorkflowSpec agent refs and extract.
* RFC-003 Phase 5: nerve validate — WorkflowSpec adapter usage and extract.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -10,8 +10,8 @@ import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it } from "vitest";
import {
collectWorkflowSpecAgentReferences,
validateAgentConfigurationLayer,
workflowSourcesDeclareAdapterRoles,
} from "../workflow-agent-validation.js";
function baseConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
@@ -20,7 +20,6 @@ function baseConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
senses: {},
workflows: {},
api: { port: null, token: null, host: "127.0.0.1" },
agents: {},
extract: null,
...overrides,
};
@@ -33,17 +32,18 @@ describe("validateAgentConfigurationLayer", () => {
rmSync(nerveRoot, { recursive: true, force: true });
});
it("fails when WorkflowSpec references an agent not in nerve.yaml", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-agents-"));
it("fails when workflow sources use adapters but extract is missing", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
`
import type { WorkflowSpec } from "@uncaged/nerve-core";
const adapter = async () => "";
const spec: WorkflowSpec<{ r: { x: number } }> = {
name: "demo",
roles: {
r: { agent: "missing-agent", prompt: "p", meta: {} as never, timeout: null },
r: { adapter: adapter, prompt: "p", meta: {} as never },
},
moderator: () => "__end__" as never,
};
@@ -52,30 +52,26 @@ export default spec;
"utf8",
);
const result = validateAgentConfigurationLayer(baseConfig({ agents: {} }), nerveRoot);
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain("missing-agent");
expect(result.message).toMatch(/extract/i);
}
});
it("passes when all WorkflowSpec agent refs exist and extract is configured", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-agents-"));
it("passes when adapters are used and extract is configured", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
`
roles: { x: { agent: "my-dev", prompt: "", meta: {} as never, timeout: null } }
agent: "my-dev"
roles: { x: { adapter: foo, prompt: "", meta: {} as never } }
`,
"utf8",
);
const result = validateAgentConfigurationLayer(
baseConfig({
agents: {
"my-dev": { type: "echo", model: "auto", timeout: null },
},
extract: { provider: "dashscope", model: "qwen-plus" },
}),
nerveRoot,
@@ -83,64 +79,36 @@ agent: "my-dev"
expect(result.ok).toBe(true);
});
it("requires extract when any WorkflowSpec agent ref is found", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-agents-"));
it("passes when no adapter usage is detected", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "wf.ts"),
`const role = { agent: "my-dev", prompt: "x" };`,
`const role = { prompt: "x" };`,
"utf8",
);
const result = validateAgentConfigurationLayer(
baseConfig({
agents: {
"my-dev": { type: "echo", model: "auto", timeout: null },
},
extract: null,
}),
nerveRoot,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toMatch(/extract/i);
}
});
it("rejects unknown agent adapter type in nerve.yaml", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-agents-"));
const result = validateAgentConfigurationLayer(
baseConfig({
agents: {
bad: { type: "future-adapter", model: "auto", timeout: null },
},
}),
nerveRoot,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain("future-adapter");
expect(result.message).toContain("echo");
}
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
expect(result.ok).toBe(true);
});
});
describe("collectWorkflowSpecAgentReferences", () => {
describe("workflowSourcesDeclareAdapterRoles", () => {
let nerveRoot: string;
afterEach(() => {
rmSync(nerveRoot, { recursive: true, force: true });
});
it("collects agent strings from workflows/*/src", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-collect-refs-"));
it("detects adapter: identifiers under workflows/*/src", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-collect-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "w1", "src", "nested"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "w1", "src", "nested", "a.ts"),
`agent: 'alpha'\nagent: "beta"`,
"adapter: foo\nadapter: bar",
"utf8",
);
expect(collectWorkflowSpecAgentReferences(nerveRoot)).toEqual(["alpha", "beta"]);
expect(workflowSourcesDeclareAdapterRoles(nerveRoot)).toBe(true);
});
});
@@ -16,7 +16,7 @@ export function parseKnowledgeQueryLimit(raw: string | undefined): number {
return Number.isFinite(n) && n > 0 ? n : DEFAULT_LIMIT;
}
export function runKnowledgeQueryGlobal(queryText: string, limit: number): void {
export async function runKnowledgeQueryGlobal(queryText: string, limit: number): Promise<void> {
const roots = listRegisteredKnowledgeRoots();
if (roots.length === 0) {
process.stderr.write(
@@ -24,7 +24,7 @@ export function runKnowledgeQueryGlobal(queryText: string, limit: number): void
);
process.exit(1);
}
const hits = queryKnowledgeGlobal(roots, KNOWLEDGE_DB, queryText, limit);
const hits = await queryKnowledgeGlobal(roots, KNOWLEDGE_DB, queryText, limit);
if (hits.length === 0) {
process.stdout.write("No results.\n");
return;
@@ -39,11 +39,11 @@ export function runKnowledgeQueryGlobal(queryText: string, limit: number): void
}
}
export function runKnowledgeQueryScoped(
export async function runKnowledgeQueryScoped(
repoFlag: string | undefined,
queryText: string,
limit: number,
): void {
): Promise<void> {
let repoRoot: string | null = null;
if (repoFlag !== undefined && String(repoFlag).trim().length > 0) {
repoRoot = resolve(String(repoFlag).trim());
@@ -64,7 +64,7 @@ export function runKnowledgeQueryScoped(
process.exit(1);
}
const hits = queryKnowledgeRepo(repoRoot, dbPath, queryText, limit);
const hits = await queryKnowledgeRepo(repoRoot, dbPath, queryText, limit);
if (hits.length === 0) {
process.stdout.write("No results.\n");
return;
+3 -3
View File
@@ -23,7 +23,7 @@ const syncCommand = defineCommand({
process.exit(1);
}
try {
const result = runKnowledgeSync(repoRoot);
const result = await runKnowledgeSync(repoRoot);
process.stdout.write(
`✅ Indexed ${String(result.filesIndexed)} file(s), ${String(result.chunksWritten)} chunk(s) → ${result.dbPath}\n`,
);
@@ -73,11 +73,11 @@ const queryCommand = defineCommand({
const limit = parseKnowledgeQueryLimit(args.limit);
if (args.g) {
runKnowledgeQueryGlobal(queryText, limit);
await runKnowledgeQueryGlobal(queryText, limit);
return;
}
runKnowledgeQueryScoped(args.repo as string, queryText, limit);
await runKnowledgeQueryScoped(args.repo as string, queryText, limit);
},
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Remote embedding service client — calls embed.shazhou.workers.dev
* for real vector embeddings. Falls back to fake hash-based embeddings
* if credentials are not configured.
*/
type EmbedResponse = {
embeddings: number[][];
model: string;
dimensions: number;
cached: boolean[];
};
export type EmbedServiceConfig = {
url: string;
token: string;
};
/**
* Resolve embed service config from environment or cfg.
* Returns null if not configured (will fall back to placeholder).
*/
export function resolveEmbedConfig(): EmbedServiceConfig | null {
const url = process.env.EMBED_SERVICE_URL ?? null;
const token = process.env.EMBED_AUTH_TOKEN ?? null;
if (url === null || token === null) {
return null;
}
return { url, token };
}
const BATCH_SIZE = 100;
/**
* Call remote embedding service. Batches texts in groups of 100.
* Returns Float32Array per text (stored as Buffer for SQLite BLOB).
*/
export async function embedTexts(config: EmbedServiceConfig, texts: string[]): Promise<Buffer[]> {
const results: Buffer[] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const resp = await fetch(`${config.url}/embed`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.token}`,
},
body: JSON.stringify({ texts: batch }),
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Embed service error ${String(resp.status)}: ${body}`);
}
const data = (await resp.json()) as EmbedResponse;
for (const vec of data.embeddings) {
const buf = Buffer.alloc(vec.length * 4);
for (let j = 0; j < vec.length; j++) {
buf.writeFloatLE(vec[j] as number, j * 4);
}
results.push(buf);
}
}
return results;
}
/**
* Embed a single text (for query). Returns Float32Array as Buffer.
*/
export async function embedQuery(config: EmbedServiceConfig, text: string): Promise<Buffer> {
const results = await embedTexts(config, [text]);
const first = results[0];
if (first === undefined) {
throw new Error("Embed service returned empty result");
}
return first;
}
/**
* Cosine similarity between two embedding buffers (Float32LE encoded).
*/
export function cosineSimilarity(a: Buffer, b: Buffer): number {
const len = Math.min(a.length, b.length) / 4;
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < len; i++) {
const va = a.readFloatLE(i * 4);
const vb = b.readFloatLE(i * 4);
dot += va * vb;
normA += va * va;
normB += vb * vb;
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
if (denom === 0) return 0;
return dot / denom;
}
+4 -5
View File
@@ -1,8 +1,6 @@
import { createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { fakeEmbeddingBytes } from "./fake-embedding.js";
export type KnowledgeChunkRow = {
path: string;
slug: string;
@@ -18,6 +16,7 @@ export type KnowledgeChunkInsert = {
chunkIndex: number;
text: string;
contentHash: string;
embedding: Buffer;
};
const SCHEMA = `
@@ -56,7 +55,7 @@ export function replaceAllChunks(db: DatabaseSync, rows: KnowledgeChunkInsert[])
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row === undefined) continue;
const emb = fakeEmbeddingBytes(row.text);
const emb = row.embedding;
insert.run({
path: row.path,
chunk_index: row.chunkIndex,
@@ -82,7 +81,7 @@ export function loadAllChunks(db: DatabaseSync): KnowledgeChunkRow[] {
chunk_index: number;
slug: string;
text: string;
embedding: Buffer;
embedding: Buffer | Uint8Array;
content_hash: string;
}>;
return rows.map((r) => ({
@@ -90,7 +89,7 @@ export function loadAllChunks(db: DatabaseSync): KnowledgeChunkRow[] {
slug: r.slug,
chunkIndex: r.chunk_index,
text: r.text,
embedding: r.embedding,
embedding: Buffer.from(r.embedding),
contentHash: r.content_hash,
}));
}
+41 -7
View File
@@ -1,6 +1,12 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
type EmbedServiceConfig,
cosineSimilarity,
embedQuery,
resolveEmbedConfig,
} from "./embed-service.js";
import type { KnowledgeChunkRow } from "./knowledge-db.js";
import { loadAllChunks, openKnowledgeDb } from "./knowledge-db.js";
import { wordOverlapScore } from "./word-overlap.js";
@@ -13,7 +19,20 @@ export type KnowledgeQueryHit = {
score: number;
};
export function rankChunksByWordOverlap(
function rankChunksByCosine(
queryEmbedding: Buffer,
chunks: KnowledgeChunkRow[],
limit: number,
): Array<{ chunk: KnowledgeChunkRow; score: number }> {
const scored = chunks.map((chunk) => ({
chunk,
score: cosineSimilarity(queryEmbedding, chunk.embedding),
}));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit);
}
function rankChunksByWordOverlap(
query: string,
chunks: KnowledgeChunkRow[],
limit: number,
@@ -26,16 +45,30 @@ export function rankChunksByWordOverlap(
return scored.slice(0, limit);
}
export function queryKnowledgeRepo(
async function rankChunks(
queryText: string,
chunks: KnowledgeChunkRow[],
limit: number,
embedConfig: EmbedServiceConfig | null,
): Promise<Array<{ chunk: KnowledgeChunkRow; score: number }>> {
if (embedConfig !== null) {
const queryVec = await embedQuery(embedConfig, queryText);
return rankChunksByCosine(queryVec, chunks, limit);
}
return rankChunksByWordOverlap(queryText, chunks, limit);
}
export async function queryKnowledgeRepo(
repoRoot: string,
dbPath: string,
queryText: string,
limit: number,
): KnowledgeQueryHit[] {
): Promise<KnowledgeQueryHit[]> {
const embedConfig = resolveEmbedConfig();
const db = openKnowledgeDb(dbPath);
try {
const rows = loadAllChunks(db);
const ranked = rankChunksByWordOverlap(queryText, rows, limit);
const ranked = await rankChunks(queryText, rows, limit, embedConfig);
return ranked.map((r) => ({
repoRoot,
path: r.chunk.path,
@@ -48,12 +81,13 @@ export function queryKnowledgeRepo(
}
}
export function queryKnowledgeGlobal(
export async function queryKnowledgeGlobal(
repoRoots: ReadonlyArray<string>,
dbFileName: string,
queryText: string,
limit: number,
): KnowledgeQueryHit[] {
): Promise<KnowledgeQueryHit[]> {
const embedConfig = resolveEmbedConfig();
const combined: KnowledgeQueryHit[] = [];
for (const root of repoRoots) {
const dbPath = join(root, dbFileName);
@@ -63,7 +97,7 @@ export function queryKnowledgeGlobal(
const db = openKnowledgeDb(dbPath);
try {
const rows = loadAllChunks(db);
const ranked = rankChunksByWordOverlap(queryText, rows, limit);
const ranked = await rankChunks(queryText, rows, limit, embedConfig);
for (const r of ranked) {
combined.push({
repoRoot: root,
+40 -8
View File
@@ -4,7 +4,10 @@ import { join } from "node:path";
import { type KnowledgeConfig, parseKnowledgeYaml } from "@uncaged/nerve-core";
import { chunkKnowledgeFile } from "./chunk.js";
import { type EmbedServiceConfig, embedTexts, resolveEmbedConfig } from "./embed-service.js";
import { fakeEmbeddingBytes } from "./fake-embedding.js";
import { listKnowledgeFiles } from "./glob-files.js";
import type { KnowledgeChunkInsert } from "./knowledge-db.js";
import { contentHash, openKnowledgeDb, replaceAllChunks } from "./knowledge-db.js";
import { KNOWLEDGE_DB, KNOWLEDGE_YAML } from "./paths.js";
import { registerKnowledgeRepoRoot } from "./registry.js";
@@ -14,6 +17,7 @@ export type KnowledgeSyncResult = {
dbPath: string;
filesIndexed: number;
chunksWritten: number;
embeddingSource: "remote" | "placeholder";
};
function loadConfig(repoRoot: string): KnowledgeConfig {
@@ -25,21 +29,35 @@ function loadConfig(repoRoot: string): KnowledgeConfig {
return parsed.value;
}
async function computeEmbeddings(
texts: string[],
embedConfig: EmbedServiceConfig | null,
): Promise<{ buffers: Buffer[]; source: "remote" | "placeholder" }> {
if (embedConfig !== null) {
const buffers = await embedTexts(embedConfig, texts);
return { buffers, source: "remote" };
}
// Fallback to placeholder when embed service is not configured
const buffers = texts.map((t) => fakeEmbeddingBytes(t));
return { buffers, source: "placeholder" };
}
/**
* @param nerveHomeForRegistry — when set, registers this repo under that Nerve home (for tests); default writes `~/.uncaged-nerve/data/knowledge-repos.json`.
*/
export function runKnowledgeSync(
export async function runKnowledgeSync(
repoRoot: string,
nerveHomeForRegistry: string | null = null,
): KnowledgeSyncResult {
): Promise<KnowledgeSyncResult> {
const config = loadConfig(repoRoot);
const relFiles = listKnowledgeFiles(repoRoot, config);
const inserts: Array<{
const preInserts: Array<{
path: string;
slug: string;
chunkIndex: number;
text: string;
contentHash: string;
hash: string;
}> = [];
for (const rel of relFiles) {
@@ -49,17 +67,30 @@ export function runKnowledgeSync(
for (let i = 0; i < chunks.length; i++) {
const ch = chunks[i];
if (ch === undefined) continue;
const text = ch.text;
inserts.push({
preInserts.push({
path: rel,
slug: ch.slug,
chunkIndex: i,
text,
contentHash: contentHash(text),
text: ch.text,
hash: contentHash(ch.text),
});
}
}
// Compute embeddings (remote or placeholder)
const embedConfig = resolveEmbedConfig();
const texts = preInserts.map((p) => p.text);
const { buffers, source } = await computeEmbeddings(texts, embedConfig);
const inserts: KnowledgeChunkInsert[] = preInserts.map((p, idx) => ({
path: p.path,
slug: p.slug,
chunkIndex: p.chunkIndex,
text: p.text,
contentHash: p.hash,
embedding: buffers[idx] ?? fakeEmbeddingBytes(p.text),
}));
const dbPath = join(repoRoot, KNOWLEDGE_DB);
const db = openKnowledgeDb(dbPath);
try {
@@ -75,5 +106,6 @@ export function runKnowledgeSync(
dbPath,
filesIndexed: relFiles.length,
chunksWritten: inserts.length,
embeddingSource: source,
};
}
+13 -40
View File
@@ -1,20 +1,17 @@
/**
* RFC-003: cross-check WorkflowSpec `agent:` references in workflow sources against nerve.yaml.
* RFC-003: validate extract config when workflows declare `adapter:` roles.
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { KNOWN_AGENT_ADAPTER_IDS } from "@uncaged/nerve-core";
/**
* Matches RoleSpec `agent: "name"` / `agent: 'name'` in workflow TypeScript sources.
* NOTE: This regex can match occurrences inside comments. For current usage (validation
* hint) this is acceptable — false positives just trigger a "missing agent" warning that
* the user can ignore. If precision becomes important, switch to AST-based extraction.
* Detects RoleSpec `adapter:` usage in workflow TypeScript sources.
* NOTE: This regex can match occurrences inside comments.
*/
const WORKFLOW_SPEC_AGENT_PATTERN = /agent:\s*["']([^"']+)["']/g;
const WORKFLOW_SPEC_ADAPTER_PATTERN = /adapter:\s*[a-zA-Z_$]/;
function collectTsSourceFiles(dir: string, acc: string[]): void {
if (!existsSync(dir)) return;
@@ -29,15 +26,14 @@ function collectTsSourceFiles(dir: string, acc: string[]): void {
}
/**
* Collects distinct agent names referenced via `agent: "..."` in each workflow's `src` tree.
* Returns true when any workflow `src` tree appears to use WorkflowSpec roles with adapters.
*/
export function collectWorkflowSpecAgentReferences(nerveRoot: string): string[] {
export function workflowSourcesDeclareAdapterRoles(nerveRoot: string): boolean {
const workflowsRoot = join(nerveRoot, "workflows");
if (!existsSync(workflowsRoot)) {
return [];
return false;
}
const refs = new Set<string>();
for (const wfName of readdirSync(workflowsRoot)) {
const wfDir = join(workflowsRoot, wfName);
if (!statSync(wfDir).isDirectory()) continue;
@@ -48,52 +44,29 @@ export function collectWorkflowSpecAgentReferences(nerveRoot: string): string[]
for (const filePath of files) {
const content = readFileSync(filePath, "utf8");
for (const m of content.matchAll(WORKFLOW_SPEC_AGENT_PATTERN)) {
refs.add(m[1]);
if (WORKFLOW_SPEC_ADAPTER_PATTERN.test(content)) {
return true;
}
}
}
return [...refs].sort((a, b) => a.localeCompare(b));
return false;
}
const knownAdapterSet = new Set<string>(KNOWN_AGENT_ADAPTER_IDS);
export type AgentLayerValidationResult = { ok: true } | { ok: false; message: string };
/**
* Validates agents.*.type against known adapters, WorkflowSpec agent refs vs `agents:`,
* and `extract:` when any WorkflowSpec role references an agent (typed meta uses extract).
* Ensures `extract:` is configured when workflow sources declare role adapters (typed meta uses extract).
*/
export function validateAgentConfigurationLayer(
config: NerveConfig,
nerveRoot: string,
): AgentLayerValidationResult {
for (const [name, agent] of Object.entries(config.agents)) {
if (!knownAdapterSet.has(agent.type)) {
return {
ok: false,
message: `agents.${name}.type: unknown adapter "${agent.type}" (known: ${KNOWN_AGENT_ADAPTER_IDS.join(", ")})`,
};
}
}
const refs = collectWorkflowSpecAgentReferences(nerveRoot);
for (const ref of refs) {
if (config.agents[ref] === undefined) {
return {
ok: false,
message: `WorkflowSpec references unknown agent "${ref}" (not defined under agents: in nerve.yaml)`,
};
}
}
if (refs.length > 0 && config.extract === null) {
if (workflowSourcesDeclareAdapterRoles(nerveRoot) && config.extract === null) {
return {
ok: false,
message:
"extract: required when WorkflowSpec roles reference agents (configure extract.provider and extract.model)",
"extract: required when WorkflowSpec roles use adapters (configure extract.provider and extract.model)",
};
}
+4 -71
View File
@@ -52,7 +52,6 @@ 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" });
});
@@ -223,19 +222,11 @@ senses:
expect(result.value.senses.cpu.on).toEqual(["memory"]);
});
it("parses agents and extract sections", () => {
it("parses extract section", () => {
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
@@ -243,37 +234,8 @@ extract:
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", () => {
@@ -504,49 +466,20 @@ workflows:
expect(result.error.message).toMatch(/max_queue.*not allowed.*drop/);
});
it("returns error when agent key is not kebab-case", () => {
it("returns error when agents key is present", () => {
const yaml = `
senses:
cpu:
group: system
agents:
Developer:
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/);
expect(result.error.message).toMatch(/agents: key is no longer supported/);
});
it("returns error when extract section is not an object", () => {
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveRoleTimeoutMs } from "../workflow-spec.js";
describe("resolveRoleTimeoutMs", () => {
it("uses agent default when role timeout is null", () => {
const r = resolveRoleTimeoutMs(null, 300_000);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(300_000);
});
it("uses role override string over agent default", () => {
const r = resolveRoleTimeoutMs("60s", 300_000);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(60_000);
});
it("allows explicit role duration when agent default is null", () => {
const r = resolveRoleTimeoutMs("5s", null);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(5000);
});
it("returns err for invalid duration string", () => {
const r = resolveRoleTimeoutMs("not-a-duration", 300_000);
expect(r.ok).toBe(false);
});
});
+1 -2
View File
@@ -1,6 +1,5 @@
/**
* 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.
* Workflows import adapter packages directly; echo may be used in tests via a small factory.
*/
export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const;
+3 -4
View File
@@ -36,12 +36,13 @@ export type NerveApiConfig = {
host: string;
};
/** Agent adapter defaults keyed by arbitrary kebab-case names in `nerve.yaml` (RFC-003). */
/** Adapter factory input (model, timeout); used by adapter packages (RFC-003). */
export type AgentConfig = {
/** Adapter id (e.g. `cursor`, `hermes`, `codex`). */
/** Adapter id (e.g. `cursor`, `hermes`, `echo`) — informational for factories that branch on type. */
type: string;
/** Model id or `"auto"` for adapter defaults. */
model: string;
/** Wall-clock cap in milliseconds, or `null` for adapter-specific default. */
timeout: number | null;
};
@@ -71,8 +72,6 @@ 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;
};
+1 -1
View File
@@ -11,7 +11,7 @@ const DURATION_MULTIPLIERS: Record<string, number> = {
/**
* Parse a duration string such as `5s`, `10m`, `1h` to milliseconds.
* Used by `parseNerveConfig` and WorkflowSpec role timeout (RFC-003).
* Used by `parseNerveConfig` sense/workflow duration fields.
*/
export function parseDurationStringToMs(value: string): Result<number> {
const match = DURATION_RE.exec(value);
-1
View File
@@ -28,7 +28,6 @@ export type {
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.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";
export { ExtractError } from "./extract-layer.js";
+7 -62
View File
@@ -1,7 +1,6 @@
import { parse } from "yaml";
import {
type AgentConfig,
DEFAULT_SENSE_SIGNAL_RETENTION,
type ExtractConfig,
type NerveApiConfig,
@@ -19,11 +18,6 @@ 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);
@@ -273,59 +267,6 @@ 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);
@@ -386,8 +327,13 @@ 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;
if (Object.hasOwn(obj, "agents")) {
return err(
new Error(
"agents: key is no longer supported — declare adapters on WorkflowSpec roles (RFC-003)",
),
);
}
const extractResult = parseExtract(obj);
if (!extractResult.ok) return extractResult;
@@ -397,7 +343,6 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
senses,
workflows: workflowsResult.value,
api: apiResult.value,
agents: agentsResult.value,
extract: extractResult.value,
});
}
+4 -22
View File
@@ -1,8 +1,5 @@
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, StartStep, WorkflowMessage } from "./workflow.js";
import type { AgentFn, Moderator, RoleMeta, StartStep, WorkflowMessage } from "./workflow.js";
/** Static string or async prompt built from thread context (RFC-003 dynamic prompts). */
export type PromptInput =
@@ -10,15 +7,13 @@ export type PromptInput =
| ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
/**
* Authoring-time role: references a named agent, prompt, extract schema, and optional timeout.
* Compiles to runtime `Role<Meta>` via `compileWorkflowSpec` (RFC-003 Phase 4).
* Authoring-time role: adapter function, prompt, extract schema (RFC-003).
* Compiles to runtime `Role<Meta>` via `compileWorkflowSpec`.
*/
export type RoleSpec<Meta extends Record<string, unknown>> = {
agent: string;
adapter: AgentFn;
prompt: PromptInput;
meta: Schema<Meta>;
/** Override agent default; `null` uses the agent's configured timeout from `nerve.yaml`. */
timeout: string | null;
};
/** User-facing workflow authoring shape; compiles to `WorkflowDefinition`. */
@@ -27,16 +22,3 @@ export type WorkflowSpec<M extends RoleMeta> = {
roles: { [K in keyof M]: RoleSpec<M[K]> };
moderator: Moderator<M>;
};
/**
* Two-level timeout: explicit role string wins; otherwise agent default (milliseconds).
*/
export function resolveRoleTimeoutMs(
roleTimeout: string | null,
agentDefaultMs: number | null,
): Result<number | null> {
if (roleTimeout === null) {
return ok(agentDefaultMs);
}
return parseDurationStringToMs(roleTimeout);
}
+1 -3
View File
@@ -22,12 +22,10 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib 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",
"pretest": "pnpm --filter @uncaged/nerve-core 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",
@@ -1,105 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentConfig, AgentFn, StartStep, WorkflowContext } from "@uncaged/nerve-core";
import { START } from "@uncaged/nerve-core";
import { type AgentAdapterFactories, createAgentRegistry } from "../agent-registry.js";
function makeContext(overrides: Partial<WorkflowContext> = {}): WorkflowContext {
const start: StartStep = {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: false, threadId: "thread-1" },
timestamp: Date.now(),
};
return {
start,
messages: [],
workdir: "/tmp/repo",
signal: new AbortController().signal,
...overrides,
};
}
function echoAgent(model = "auto"): AgentConfig {
return { type: "echo", model, timeout: null };
}
describe("createAgentRegistry", () => {
it("get() returns AgentFn for a defined agent", async () => {
const registry = createAgentRegistry({ dev: echoAgent() }, {});
const fn = registry.get("dev");
expect(typeof fn).toBe("function");
const out = await fn("hello", makeContext());
expect(out).toBe("hello");
});
it("get() throws for an undefined agent and the message includes the name", () => {
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 }, {});
expect(registry.getAgentConfig("dev")).toEqual(cfg);
});
it("getAgentConfig throws for an undefined agent", () => {
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 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 a = registry.get("agent-a");
const b = registry.get("agent-b");
expect(a).not.toBe(b);
await expect(a("only-a", makeContext())).resolves.toBe("only-a");
await expect(b("only-b", makeContext())).resolves.toBe("only-b");
});
it("AbortSignal is accessible in context", async () => {
const registry = createAgentRegistry({ dev: echoAgent() }, {});
const inner = registry.get("dev");
const seen: WorkflowContext[] = [];
const trace: AgentFn = async (prompt, ctx) => {
seen.push(ctx);
return inner(prompt, ctx);
};
const ac = new AbortController();
const ctx = makeContext({ signal: ac.signal });
await expect(trace("x", ctx)).resolves.toBe("x");
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\)/);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type {
AgentFn,
@@ -13,13 +13,12 @@ import type {
} from "@uncaged/nerve-core";
import { END, START } from "@uncaged/nerve-core";
import { createAgentRegistry } from "../agent-registry.js";
import { type CompileWorkflowSpecDeps, compileWorkflowSpec } from "../compile-workflow-spec.js";
import { compileWorkflowSpec } from "../compile-workflow-spec.js";
type DemoMeta = { n: number };
function echoAgent(): import("@uncaged/nerve-core").AgentConfig {
return { type: "echo", model: "auto", timeout: 300_000 };
function echoAdapter(): AgentFn {
return async (prompt: string, _ctx: WorkflowContext) => prompt;
}
function makeStart(threadId = "t1"): StartStep {
@@ -49,18 +48,15 @@ describe("compileWorkflowSpec", () => {
name: "demo",
roles: {
main: {
agent: "dev",
adapter: echoAdapter(),
prompt: "hello",
meta: schema,
timeout: null,
},
},
moderator: (_ctx: ModeratorContext<{ main: DemoMeta }>) => END,
};
const registry = createAgentRegistry({ dev: echoAgent() }, {});
const def = compileWorkflowSpec(spec, {
registry,
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
createContext: makeContext,
});
@@ -76,51 +72,29 @@ describe("compileWorkflowSpec", () => {
const order: string[] = [];
const registry = createAgentRegistry(
{
dev: { type: "echo", model: "auto", timeout: null },
},
{},
);
const extractFn: CompileWorkflowSpecDeps["extractFn"] = async <T>(
raw: string,
_sch: Schema<T>,
) => {
order.push("extract");
return { n: raw.length } as T;
};
const orig = registry.get("dev");
const baseEcho = echoAdapter();
const spyAgent: AgentFn = async (prompt, ctx) => {
order.push("agent");
return orig(prompt, ctx);
};
const registryWithSpy = {
...registry,
get(name: string): AgentFn {
if (name === "dev") return spyAgent;
return registry.get(name);
},
return baseEcho(prompt, ctx);
};
const spec: WorkflowSpec<{ main: DemoMeta }> = {
name: "order-test",
roles: {
main: {
agent: "dev",
adapter: spyAgent,
prompt: "ping",
meta: schema,
timeout: null,
},
},
moderator: () => END,
};
const def = compileWorkflowSpec(spec, {
registry: registryWithSpy,
extractFn,
extractFn: async <T>(raw: string, _sch: Schema<T>) => {
order.push("extract");
return { n: raw.length } as T;
},
createContext: makeContext,
});
@@ -130,89 +104,55 @@ describe("compileWorkflowSpec", () => {
expect(order).toEqual(["agent", "extract"]);
});
it("exposes two-level timeout via resolveRoleTimeoutMs integration (agent default vs override)", async () => {
it("passes WorkflowContext from createContext to AgentFn (adapter owns timeout)", async () => {
const witness: DemoMeta | null = null;
const schema: Schema<DemoMeta> = { witness };
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
const seenCtx: WorkflowContext[] = [];
const registry = createAgentRegistry(
{
slow: { type: "echo", model: "auto", timeout: 400_000 },
},
{},
);
const adapter: AgentFn = async (_prompt, ctx) => {
seenCtx.push(ctx);
return "x";
};
const specDefault: WorkflowSpec<{ main: DemoMeta }> = {
name: "def",
const spec: WorkflowSpec<{ main: DemoMeta }> = {
name: "ctx",
roles: {
main: {
agent: "slow",
adapter,
prompt: "x",
meta: schema,
timeout: null,
},
},
moderator: () => END,
};
await compileWorkflowSpec(specDefault, {
registry,
await compileWorkflowSpec(spec, {
extractFn: async <T>(_raw: string, _s: Schema<T>) => ({ n: 0 }) as T,
createContext: makeContext,
}).roles.main(makeStart(), []);
expect(timeoutSpy).toHaveBeenCalledWith(400_000);
timeoutSpy.mockClear();
const specOverride: WorkflowSpec<{ main: DemoMeta }> = {
name: "ov",
roles: {
main: {
agent: "slow",
prompt: "x",
meta: schema,
timeout: "60s",
},
},
moderator: () => END,
};
await compileWorkflowSpec(specOverride, {
registry,
extractFn: async <T>(_raw: string, _s: Schema<T>) => ({ n: 0 }) as T,
createContext: makeContext,
}).roles.main(makeStart(), []);
expect(timeoutSpy).toHaveBeenCalledWith(60_000);
timeoutSpy.mockRestore();
expect(seenCtx).toHaveLength(1);
expect(seenCtx[0].workdir).toBe("/tmp/repo");
});
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",
adapter: echoAdapter(),
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,
});
@@ -64,7 +64,6 @@ 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,7 +70,6 @@ function makeWfConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConf
senses: {},
workflows,
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -461,7 +460,6 @@ 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" },
};
@@ -498,7 +496,6 @@ 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" },
};
@@ -521,7 +518,6 @@ 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" },
};
@@ -545,7 +541,6 @@ 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" },
};
@@ -563,7 +558,6 @@ 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" },
};
@@ -1,158 +0,0 @@
/**
* Kernel AgentRegistry integration — rebuilt on reloadConfig (RFC-003 Phase 5).
*/
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockCreateAgentRegistry = vi.hoisted(() =>
vi.fn(() => ({
get: vi.fn(),
getAgentConfig: vi.fn(),
})),
);
const mockChildren: MockChild[] = [];
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
pid: number;
};
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
setImmediate(() => {
child.emit("message", { type: "ready" });
});
child.send = vi.fn((msg: unknown) => {
if (msg === null || typeof msg !== "object") return;
const m = msg as Record<string, unknown>;
if (m.type === "shutdown") {
setImmediate(() => child.emit("exit", 0, null));
}
});
child.kill = vi.fn((_signal?: string) => {
child.emit("exit", null, _signal ?? "SIGKILL");
});
child.pid = pid;
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 1);
mockChildren.push(child);
return child;
}),
}));
vi.mock("../agent-registry.js", () => ({
createAgentRegistry: mockCreateAgentRegistry,
}));
const { createKernel } = await import("../kernel.js");
const { createLogStore } = await import("@uncaged/nerve-store");
function makeConfig(agents: NerveConfig["agents"]): NerveConfig {
return {
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: {},
maxRounds: 10,
agents,
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
}
describe("kernel — AgentRegistry hot-reload", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
mockCreateAgentRegistry.mockClear();
mockCreateAgentRegistry.mockImplementation(() => ({
get: vi.fn(),
getAgentConfig: vi.fn(),
}));
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-agent-reg-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("rebuilds AgentRegistry on reloadConfig", async () => {
const logStore = createLogStore(join(nerveRoot, "logs.db"));
const a = makeConfig({
dev: { type: "echo", model: "auto", timeout: null },
});
const kernel = createKernel(a, nerveRoot, { logStore });
await vi.runAllTimersAsync();
expect(mockCreateAgentRegistry).toHaveBeenCalledTimes(1);
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 },
ops: { type: "echo", model: "auto", timeout: null },
});
kernel.reloadConfig(b);
expect(mockCreateAgentRegistry).toHaveBeenCalledTimes(2);
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);
expect(reloadLogs[0].payload).toBe(JSON.stringify({ agentNames: ["dev", "ops"] }));
await kernel.stop();
await vi.runAllTimersAsync();
});
it("getAgentRegistry returns the registry from the latest reload", async () => {
const cfg = makeConfig({});
const kernel = createKernel(cfg, nerveRoot);
await vi.runAllTimersAsync();
const r1 = kernel.getAgentRegistry();
kernel.reloadConfig(makeConfig({ x: { type: "echo", model: "auto", timeout: null } }));
const r2 = kernel.getAgentRegistry();
expect(r1).not.toBe(r2);
await kernel.stop();
await vi.runAllTimersAsync();
});
});
@@ -37,7 +37,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -85,7 +85,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -246,7 +245,6 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
});
@@ -281,7 +279,6 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -306,7 +303,6 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
});
@@ -347,7 +343,6 @@ describe("kernel — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
});
@@ -105,7 +105,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -117,7 +117,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -457,7 +456,6 @@ 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" },
};
@@ -535,7 +533,6 @@ describe("kernel + workflowManager integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -74,7 +74,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -287,7 +286,6 @@ describe("kernel — groupForSense mapping", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -38,7 +38,6 @@ describe("LogStore + SenseScheduler integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -76,7 +75,6 @@ describe("LogStore + SenseScheduler integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -117,7 +115,6 @@ describe("LogStore + SenseScheduler integration", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -34,7 +34,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -171,7 +170,6 @@ describe("phase6 — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -209,7 +207,6 @@ describe("phase6 — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -234,7 +231,6 @@ describe("phase6 — reloadConfig", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -289,7 +285,6 @@ describe("phase6 — error isolation", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -441,7 +436,6 @@ describe("phase6 — getHealth", () => {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
@@ -19,7 +19,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -41,7 +41,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
},
workflows: {},
maxRounds: 10,
agents: {},
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
...overrides,
@@ -89,7 +89,6 @@ 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" },
};
-63
View File
@@ -1,63 +0,0 @@
import type { AgentConfig, AgentFn } 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 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);
}
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>,
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, adapterFactories));
configs.set(name, config);
}
return {
get(name: string): AgentFn {
const fn = byName.get(name);
if (fn === undefined) {
throw new Error(`Agent not found: "${name}"`);
}
return fn;
},
getAgentConfig(name: string): AgentConfig {
const config = configs.get(name);
if (config === undefined) {
throw new Error(`Agent not found: "${name}"`);
}
return config;
},
};
}
+4 -30
View File
@@ -9,22 +9,10 @@ import type {
WorkflowMessage,
WorkflowSpec,
} from "@uncaged/nerve-core";
import { resolveRoleTimeoutMs } from "@uncaged/nerve-core";
import type { AgentRegistry } from "./agent-registry.js";
/** Combines user cancellation (`AbortSignal` from context) with an optional wall-clock cap. */
function mergeWorkflowSignals(userSignal: AbortSignal, timeoutMs: number | null): AbortSignal {
if (timeoutMs === null) {
return userSignal;
}
return AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]);
}
export type CompileWorkflowSpecDeps = {
registry: AgentRegistry;
/**
* Typed extraction for agent raw output (global/agent/role merge applied before compile).
* Typed extraction for agent raw output (global/role merge applied before compile).
*/
extractFn: <T>(raw: string, schema: Schema<T>) => Promise<T>;
/** Builds thread context for each role invocation (workdir, cancellation, etc.). */
@@ -36,35 +24,21 @@ function compileRoleForSpec<Meta extends Record<string, unknown>>(
deps: CompileWorkflowSpecDeps,
): Role<Meta> {
return async (start: StartStep, messages: WorkflowMessage[]) => {
const agentFn = deps.registry.get(roleSpec.agent);
const agentConfig = deps.registry.getAgentConfig(roleSpec.agent);
const timeoutResult = resolveRoleTimeoutMs(roleSpec.timeout, agentConfig.timeout);
if (!timeoutResult.ok) {
throw timeoutResult.error;
}
const baseCtx = deps.createContext(start, messages);
const signal = mergeWorkflowSignals(baseCtx.signal, timeoutResult.value);
const ctx: WorkflowContext = {
start: baseCtx.start,
messages: baseCtx.messages,
workdir: baseCtx.workdir,
signal,
};
const ctx = deps.createContext(start, messages);
const promptText =
typeof roleSpec.prompt === "string"
? roleSpec.prompt
: await roleSpec.prompt(start, messages);
const raw = await agentFn(promptText, ctx);
const raw = await roleSpec.adapter(promptText, ctx);
const meta = await deps.extractFn(raw, roleSpec.meta);
return { content: raw, meta };
};
}
/**
* Turns RFC-003 `WorkflowSpec` into engine `WorkflowDefinition`: resolves agents, timeout layers,
* and wires extract per role.
* Turns RFC-003 `WorkflowSpec` into engine `WorkflowDefinition`: wires adapters and extract per role.
*/
export function compileWorkflowSpec<M extends RoleMeta>(
spec: WorkflowSpec<M>,
-6
View File
@@ -58,12 +58,6 @@ export type {
export { createWorkflowManager } from "./workflow-manager.js";
export type { WorkflowManager } from "./workflow-manager.js";
export { createAgentRegistry } 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";
-25
View File
@@ -8,8 +8,6 @@ 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,
@@ -21,8 +19,6 @@ import { routeSenseComputeOutput } from "@uncaged/nerve-core";
import { createLogStore } from "@uncaged/nerve-store";
import type { LogStore } from "@uncaged/nerve-store";
import { createAgentRegistry } from "./agent-registry.js";
import type { AgentRegistry } from "./agent-registry.js";
import { createDaemonHandlers } from "./daemon-handlers.js";
import { createDaemonIpcServer } from "./daemon-ipc.js";
import type { DaemonIpcServer } from "./daemon-ipc.js";
@@ -46,15 +42,6 @@ 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;
@@ -77,8 +64,6 @@ export type Kernel = {
triggerSense: (senseName: string) => void;
restartGroup: (group: string) => Promise<void>;
reloadConfig: (newConfig: NerveConfig) => void;
/** Agent adapters rebuilt on config hot-reload; running workflow threads keep bindings from thread start. */
getAgentRegistry: () => AgentRegistry;
getHealth: () => KernelHealth;
/** HTTP/IPC-oriented health (version, uptime seconds, hostname). */
getDaemonHealth: () => HealthInfo;
@@ -141,7 +126,6 @@ export function createKernel(
});
let config = initialConfig;
let agentRegistry = createAgentRegistry(config.agents, defaultAgentAdapterFactories());
let _signalIdCounter = 0;
function nextSignalId(): number {
@@ -321,14 +305,6 @@ export function createKernel(
const oldConfig = config;
const oldWorkflows = config.workflows;
config = newConfig;
agentRegistry = createAgentRegistry(newConfig.agents, defaultAgentAdapterFactories());
logStore.append({
source: "system",
type: "agent_registry_reload",
refId: null,
payload: JSON.stringify({ agentNames: Object.keys(newConfig.agents).sort() }),
timestamp: Date.now(),
});
scheduler.stop();
scheduler = createSenseScheduler(config, bus, triggerFn, {
logStore,
@@ -501,7 +477,6 @@ export function createKernel(
triggerSense,
restartGroup: (group) => senseWorkerPool.restartGroup(group),
reloadConfig,
getAgentRegistry: () => agentRegistry,
getHealth,
getDaemonHealth,
};
+1
View File
@@ -17,6 +17,7 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build",
"test": "vitest run"
},
"dependencies": {
@@ -48,11 +48,7 @@ describe("mergeExtractConfig", () => {
const emptyLayer: ExtractConfigLayer = { provider: null, model: null };
it("resolves global-only extract settings", () => {
const result = mergeExtractConfig(
{ provider: "dashscope", model: "qwen-plus" },
emptyLayer,
emptyLayer,
);
const result = mergeExtractConfig({ provider: "dashscope", model: "qwen-plus" }, emptyLayer);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
@@ -60,13 +56,9 @@ describe("mergeExtractConfig", () => {
expect(result.value).toEqual({ provider: "dashscope", model: "qwen-plus" });
});
it("lets agent override global provider and model", () => {
const agent: ExtractConfigLayer = { provider: "openai", model: null };
const result = mergeExtractConfig(
{ provider: "dashscope", model: "qwen-plus" },
agent,
emptyLayer,
);
it("lets role override global provider and keep model from global", () => {
const role: ExtractConfigLayer = { provider: "openai", model: null };
const result = mergeExtractConfig({ provider: "dashscope", model: "qwen-plus" }, role);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
@@ -74,19 +66,18 @@ describe("mergeExtractConfig", () => {
expect(result.value).toEqual({ provider: "openai", model: "qwen-plus" });
});
it("lets role override agent and global", () => {
const agent: ExtractConfigLayer = { provider: "openai", model: "gpt-4o" };
it("lets role override model and inherit provider from global", () => {
const role: ExtractConfigLayer = { provider: null, model: "small" };
const result = mergeExtractConfig({ provider: "dashscope", model: "qwen-plus" }, agent, role);
const result = mergeExtractConfig({ provider: "dashscope", model: "qwen-plus" }, role);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({ provider: "openai", model: "small" });
expect(result.value).toEqual({ provider: "dashscope", model: "small" });
});
it("returns error when provider cannot be resolved", () => {
const result = mergeExtractConfig(null, { provider: null, model: "m" }, emptyLayer);
const result = mergeExtractConfig(null, { provider: null, model: "m" });
expect(result.ok).toBe(false);
if (result.ok) {
return;
@@ -2,8 +2,7 @@ import type { ExtractConfig, Result } from "@uncaged/nerve-core";
import { err, ok } from "@uncaged/nerve-core";
/**
* One level in global → agent → role merge. Use `null` for a field to inherit
* from the lower-precedence layer (RFC-003).
* One level in global → role merge. Use `null` for a field to inherit from global (RFC-003).
*/
export type ExtractConfigLayer = {
provider: string | null;
@@ -12,11 +11,10 @@ export type ExtractConfigLayer = {
export function mergeExtractConfig(
global: ExtractConfig | null,
agent: ExtractConfigLayer,
role: ExtractConfigLayer,
): Result<ExtractConfig> {
const provider = role.provider ?? agent.provider ?? global?.provider ?? null;
const model = role.model ?? agent.model ?? global?.model ?? null;
const provider = role.provider ?? global?.provider ?? null;
const model = role.model ?? global?.model ?? null;
if (provider === null || provider.trim() === "") {
return err(new Error("extract: unresolved provider after merge"));
-6
View File
@@ -102,12 +102,6 @@ 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