diff --git a/docs/rfc-003-agent-config-layer.md b/docs/rfc-003-agent-config-layer.md index dc2fc83..a15d36a 100644 --- a/docs/rfc-003-agent-config-layer.md +++ b/docs/rfc-003-agent-config-layer.md @@ -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 = (raw: string, schema: Schema) => Promise ``` -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 = { 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 = { ### 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); type RoleSpec = { - agent: string; + adapter: AgentFn; prompt: PromptInput; meta: Schema; - 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` signature: type Role = (start: StartStep, messages: WorkflowMessage[]) => Promise> ``` -remains the runtime interface. The new config layer is syntactic sugar — the runtime assembles `Role` 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` 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 diff --git a/packages/adapter-cursor/src/index.ts b/packages/adapter-cursor/src/index.ts index ec026b6..5f85e5c 100644 --- a/packages/adapter-cursor/src/index.ts +++ b/packages/adapter-cursor/src/index.ts @@ -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 => { 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, +}); diff --git a/packages/adapter-hermes/src/index.ts b/packages/adapter-hermes/src/index.ts index d50a1ec..66c8cd8 100644 --- a/packages/adapter-hermes/src/index.ts +++ b/packages/adapter-hermes/src/index.ts @@ -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 => { 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, +}); diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index 80854b7..bffc4ce 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -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" }, }; diff --git a/packages/cli/src/__tests__/knowledge-query.test.ts b/packages/cli/src/__tests__/knowledge-query.test.ts index f6cc716..cbd3994 100644 --- a/packages/cli/src/__tests__/knowledge-query.test.ts +++ b/packages/cli/src/__tests__/knowledge-query.test.ts @@ -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(); + return { + ...actual, + resolveEmbedConfig: () => embedMocks.resolveEmbedConfig(), + embedQuery: (cfg: Parameters[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); }); }); diff --git a/packages/cli/src/__tests__/knowledge-sync.test.ts b/packages/cli/src/__tests__/knowledge-sync.test.ts index 3e6abea..95218f7 100644 --- a/packages/cli/src/__tests__/knowledge-sync.test.ts +++ b/packages/cli/src/__tests__/knowledge-sync.test.ts @@ -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(); + 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 { diff --git a/packages/cli/src/__tests__/validate-workflow-agents.test.ts b/packages/cli/src/__tests__/validate-workflow-agents.test.ts index bf0b6ac..1bb599c 100644 --- a/packages/cli/src/__tests__/validate-workflow-agents.test.ts +++ b/packages/cli/src/__tests__/validate-workflow-agents.test.ts @@ -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 { @@ -20,7 +20,6 @@ function baseConfig(overrides: Partial = {}): 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); }); }); diff --git a/packages/cli/src/commands/knowledge-query-run.ts b/packages/cli/src/commands/knowledge-query-run.ts index fe11b8a..0a2045b 100644 --- a/packages/cli/src/commands/knowledge-query-run.ts +++ b/packages/cli/src/commands/knowledge-query-run.ts @@ -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 { 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 { 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; diff --git a/packages/cli/src/commands/knowledge.ts b/packages/cli/src/commands/knowledge.ts index 850e580..bcf1240 100644 --- a/packages/cli/src/commands/knowledge.ts +++ b/packages/cli/src/commands/knowledge.ts @@ -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); }, }); diff --git a/packages/cli/src/knowledge/embed-service.ts b/packages/cli/src/knowledge/embed-service.ts new file mode 100644 index 0000000..b57957b --- /dev/null +++ b/packages/cli/src/knowledge/embed-service.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/cli/src/knowledge/knowledge-db.ts b/packages/cli/src/knowledge/knowledge-db.ts index 978acd4..597d452 100644 --- a/packages/cli/src/knowledge/knowledge-db.ts +++ b/packages/cli/src/knowledge/knowledge-db.ts @@ -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, })); } diff --git a/packages/cli/src/knowledge/query.ts b/packages/cli/src/knowledge/query.ts index 9e53c05..4c0f8ad 100644 --- a/packages/cli/src/knowledge/query.ts +++ b/packages/cli/src/knowledge/query.ts @@ -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> { + 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 { + 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, dbFileName: string, queryText: string, limit: number, -): KnowledgeQueryHit[] { +): Promise { + 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, diff --git a/packages/cli/src/knowledge/sync.ts b/packages/cli/src/knowledge/sync.ts index 91f5450..368e8e3 100644 --- a/packages/cli/src/knowledge/sync.ts +++ b/packages/cli/src/knowledge/sync.ts @@ -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 { 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, }; } diff --git a/packages/cli/src/workflow-agent-validation.ts b/packages/cli/src/workflow-agent-validation.ts index 0ea157a..4a90b5c 100644 --- a/packages/cli/src/workflow-agent-validation.ts +++ b/packages/cli/src/workflow-agent-validation.ts @@ -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(); 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(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)", }; } diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index e289752..c3ce7d5 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -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", () => { diff --git a/packages/core/src/__tests__/workflow-spec.test.ts b/packages/core/src/__tests__/workflow-spec.test.ts deleted file mode 100644 index 8ee8c54..0000000 --- a/packages/core/src/__tests__/workflow-spec.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/core/src/agent-adapter-ids.ts b/packages/core/src/agent-adapter-ids.ts index d3fe1be..f96afce 100644 --- a/packages/core/src/agent-adapter-ids.ts +++ b/packages/core/src/agent-adapter-ids.ts @@ -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; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index dc9bbb9..ee9b596 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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; workflows: Record; api: NerveApiConfig; - /** Named agent adapters; keys must be kebab-case (RFC-003). */ - agents: Record; /** Global extract defaults; `null` when the section is omitted. */ extract: ExtractConfig | null; }; diff --git a/packages/core/src/duration.ts b/packages/core/src/duration.ts index 9a15dd3..8b38775 100644 --- a/packages/core/src/duration.ts +++ b/packages/core/src/duration.ts @@ -11,7 +11,7 @@ const DURATION_MULTIPLIERS: Record = { /** * 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 { const match = DURATION_RE.exec(value); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e35ef2..1fbad12 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/parse-nerve-config.ts b/packages/core/src/parse-nerve-config.ts index 2b3eacc..10ddd01 100644 --- a/packages/core/src/parse-nerve-config.ts +++ b/packages/core/src/parse-nerve-config.ts @@ -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 { if (field === undefined || field === null) { return ok(DEFAULT_SENSE_SIGNAL_RETENTION); @@ -273,59 +267,6 @@ function parseWorkflows(obj: Record): Result { - 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): Result> { - 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 = {}; - - 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): Result { if (obj.extract === undefined || obj.extract === null) { return ok(null); @@ -386,8 +327,13 @@ export function parseNerveConfig(raw: string): Result { 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 { senses, workflows: workflowsResult.value, api: apiResult.value, - agents: agentsResult.value, extract: extractResult.value, }); } diff --git a/packages/core/src/workflow-spec.ts b/packages/core/src/workflow-spec.ts index efa259a..056ee63 100644 --- a/packages/core/src/workflow-spec.ts +++ b/packages/core/src/workflow-spec.ts @@ -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); /** - * Authoring-time role: references a named agent, prompt, extract schema, and optional timeout. - * Compiles to runtime `Role` via `compileWorkflowSpec` (RFC-003 Phase 4). + * Authoring-time role: adapter function, prompt, extract schema (RFC-003). + * Compiles to runtime `Role` via `compileWorkflowSpec`. */ export type RoleSpec> = { - agent: string; + adapter: AgentFn; prompt: PromptInput; meta: Schema; - /** 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 = { roles: { [K in keyof M]: RoleSpec }; moderator: Moderator; }; - -/** - * Two-level timeout: explicit role string wins; otherwise agent default (milliseconds). - */ -export function resolveRoleTimeoutMs( - roleTimeout: string | null, - agentDefaultMs: number | null, -): Result { - if (roleTimeout === null) { - return ok(agentDefaultMs); - } - return parseDurationStringToMs(roleTimeout); -} diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 979f9fa..bce6d47 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -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", diff --git a/packages/daemon/src/__tests__/agent-registry.test.ts b/packages/daemon/src/__tests__/agent-registry.test.ts deleted file mode 100644 index 5f4104d..0000000 --- a/packages/daemon/src/__tests__/agent-registry.test.ts +++ /dev/null @@ -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 { - 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\)/); - }); -}); diff --git a/packages/daemon/src/__tests__/compile-workflow-spec.test.ts b/packages/daemon/src/__tests__/compile-workflow-spec.test.ts index 24e1aca..2ebeee1 100644 --- a/packages/daemon/src/__tests__/compile-workflow-spec.test.ts +++ b/packages/daemon/src/__tests__/compile-workflow-spec.test.ts @@ -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 (raw: string, _s: Schema) => ({ 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 ( - raw: string, - _sch: Schema, - ) => { - 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 (raw: string, _sch: Schema) => { + 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 = { 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 (_raw: string, _s: Schema) => ({ 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 (_raw: string, _s: Schema) => ({ 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 = { 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 (raw: string, _s: Schema) => ({ n: raw.length }) as T, createContext: makeContext, }); diff --git a/packages/daemon/src/__tests__/crash-recovery.test.ts b/packages/daemon/src/__tests__/crash-recovery.test.ts index 8b4cd77..c29f76c 100644 --- a/packages/daemon/src/__tests__/crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/crash-recovery.test.ts @@ -64,7 +64,6 @@ function makeConfig(workflows: Record = {}): NerveConfig senses: {}, workflows, maxRounds: 10, - agents: {}, extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; diff --git a/packages/daemon/src/__tests__/hot-reload.test.ts b/packages/daemon/src/__tests__/hot-reload.test.ts index 4624f5c..2bc092c 100644 --- a/packages/daemon/src/__tests__/hot-reload.test.ts +++ b/packages/daemon/src/__tests__/hot-reload.test.ts @@ -70,7 +70,6 @@ function makeWfConfig(workflows: Record = {}): 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" }, }; diff --git a/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts b/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts deleted file mode 100644 index 62515c4..0000000 --- a/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts +++ /dev/null @@ -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; - kill: ReturnType; - 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; - 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(); - }); -}); diff --git a/packages/daemon/src/__tests__/kernel-integration.test.ts b/packages/daemon/src/__tests__/kernel-integration.test.ts index e3908bc..10906e4 100644 --- a/packages/daemon/src/__tests__/kernel-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-integration.test.ts @@ -37,7 +37,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, - agents: {}, extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, diff --git a/packages/daemon/src/__tests__/kernel-phase6.test.ts b/packages/daemon/src/__tests__/kernel-phase6.test.ts index bbdedf4..a2e0b27 100644 --- a/packages/daemon/src/__tests__/kernel-phase6.test.ts +++ b/packages/daemon/src/__tests__/kernel-phase6.test.ts @@ -85,7 +85,6 @@ function makeConfig(overrides: Partial = {}): 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" }, }); diff --git a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts index 5bf7c8a..c3bf72b 100644 --- a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts +++ b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts @@ -105,7 +105,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, - agents: {}, extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, diff --git a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts index 8e3885d..dadcd50 100644 --- a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts @@ -117,7 +117,6 @@ function makeConfig(overrides: Partial = {}): 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" }, }; diff --git a/packages/daemon/src/__tests__/kernel.test.ts b/packages/daemon/src/__tests__/kernel.test.ts index 4688452..65e0fb1 100644 --- a/packages/daemon/src/__tests__/kernel.test.ts +++ b/packages/daemon/src/__tests__/kernel.test.ts @@ -74,7 +74,6 @@ function makeConfig(overrides: Partial = {}): 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" }, }; diff --git a/packages/daemon/src/__tests__/log-store-integration.test.ts b/packages/daemon/src/__tests__/log-store-integration.test.ts index ffe044f..41c067a 100644 --- a/packages/daemon/src/__tests__/log-store-integration.test.ts +++ b/packages/daemon/src/__tests__/log-store-integration.test.ts @@ -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" }, }; diff --git a/packages/daemon/src/__tests__/phase6-integration.test.ts b/packages/daemon/src/__tests__/phase6-integration.test.ts index 4eb694c..5dd40aa 100644 --- a/packages/daemon/src/__tests__/phase6-integration.test.ts +++ b/packages/daemon/src/__tests__/phase6-integration.test.ts @@ -34,7 +34,6 @@ function makeConfig(overrides: Partial = {}): 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" }, }; diff --git a/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts b/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts index d7bd613..0a02152 100644 --- a/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts +++ b/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts @@ -19,7 +19,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, - agents: {}, extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, diff --git a/packages/daemon/src/__tests__/sense-scheduler.test.ts b/packages/daemon/src/__tests__/sense-scheduler.test.ts index c98d3e4..9d9cf35 100644 --- a/packages/daemon/src/__tests__/sense-scheduler.test.ts +++ b/packages/daemon/src/__tests__/sense-scheduler.test.ts @@ -41,7 +41,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, - agents: {}, extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, diff --git a/packages/daemon/src/__tests__/workflow-manager.test.ts b/packages/daemon/src/__tests__/workflow-manager.test.ts index 6805c18..db86982 100644 --- a/packages/daemon/src/__tests__/workflow-manager.test.ts +++ b/packages/daemon/src/__tests__/workflow-manager.test.ts @@ -89,7 +89,6 @@ function makeConfig(overrides: Partial = {}): NerveCon maxRounds: 10, senses: {}, workflows: overrides as NerveConfig["workflows"], - agents: {}, extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; diff --git a/packages/daemon/src/agent-registry.ts b/packages/daemon/src/agent-registry.ts deleted file mode 100644 index 6386677..0000000 --- a/packages/daemon/src/agent-registry.ts +++ /dev/null @@ -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; - -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, - adapterFactories: AgentAdapterFactories, -): AgentRegistry { - const byName = new Map(); - const configs = new Map(); - 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; - }, - }; -} diff --git a/packages/daemon/src/compile-workflow-spec.ts b/packages/daemon/src/compile-workflow-spec.ts index 650ee8a..eb7e570 100644 --- a/packages/daemon/src/compile-workflow-spec.ts +++ b/packages/daemon/src/compile-workflow-spec.ts @@ -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: (raw: string, schema: Schema) => Promise; /** Builds thread context for each role invocation (workdir, cancellation, etc.). */ @@ -36,35 +24,21 @@ function compileRoleForSpec>( deps: CompileWorkflowSpecDeps, ): Role { 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( spec: WorkflowSpec, diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index cf8b06a..6ef1fad 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -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"; diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index 9ade4c1..d48f2fd 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -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; 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, }; diff --git a/packages/workflow-utils/package.json b/packages/workflow-utils/package.json index 1bce833..dca8c8c 100644 --- a/packages/workflow-utils/package.json +++ b/packages/workflow-utils/package.json @@ -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": { diff --git a/packages/workflow-utils/src/__tests__/extract-layer.test.ts b/packages/workflow-utils/src/__tests__/extract-layer.test.ts index 944c24e..6b7dfe1 100644 --- a/packages/workflow-utils/src/__tests__/extract-layer.test.ts +++ b/packages/workflow-utils/src/__tests__/extract-layer.test.ts @@ -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; diff --git a/packages/workflow-utils/src/shared/merge-extract-config.ts b/packages/workflow-utils/src/shared/merge-extract-config.ts index 1530922..7e7da1b 100644 --- a/packages/workflow-utils/src/shared/merge-extract-config.ts +++ b/packages/workflow-utils/src/shared/merge-extract-config.ts @@ -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 { - 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")); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbe1c0e..efc6659 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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