Merge pull request 'feat: real embedding integration + remove AgentRegistry (#244, #245)' (#246) from feat/244-phase-c into main
This commit was merged in pull request #246.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Generated
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user