# RFC-003: Agent Configuration Layer **Author:** 小橘 🍊(NEKO TeamοΌ‰ **Status:** Draft **Created:** 2026-04-29 ## Summary 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 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: - **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 ### Adapter vs Role | | Adapter | Role | |---|---|---| | **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 `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 All agent types implement a single unified interface: ```ts type AgentFn = (prompt: string, context: WorkflowContext) => Promise ``` - **Input**: prompt (assembled by Role) + context (start frame + prior messages + workdir + abort signal) - **Output**: raw string β€” structured data is extracted separately - **Internals**: adapter handles tool-specific details (cursor CLI, hermes subagent, codex API, etc.) Workflow runtime never interacts with agent internals. ### Extract Layer A separate concern that parses agent output (raw string) into typed meta: ```ts type ExtractFn = (raw: string, schema: Schema) => Promise ``` 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. ## Design ### Configuration (`nerve.yaml`) `nerve.yaml` holds only extract and knowledge config β€” no agent registry: ```yaml extract: provider: dashscope model: qwen-plus ``` ### 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: { 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, }; ``` ### Runtime Assembly ``` WorkflowSpec β†’ Role(adapter fn + prompt) β†’ adapter(prompt, ctx) β†’ string ↓ nerve.yaml#extract β†’ ExtractFn(string, schema) β†’ T (typed meta) ``` Adapter is a direct function reference on each role β€” no map, no lookup, no registry. ### Adapter Packages Each agent adapter lives in its own package to avoid pulling unnecessary dependencies: ``` packages/ 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 **default instance** and a **factory** for customization: ```ts // @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 adapter config (model, timeout) and returns an `AgentFn` that spawns the CLI tool, passes the prompt, and returns raw output. **Wiring** β€” workflows import adapters directly, no daemon-level registry: ```ts import { cursorAdapter } from "@uncaged/nerve-adapter-cursor"; import { hermesAdapter } from "@uncaged/nerve-adapter-hermes"; // Use default instances directly in roles { adapter: cursorAdapter, prompt: "...", meta: schema } ``` 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": { "@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 `@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 `RoleSpec.prompt` supports both static strings and async functions: ```ts type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise); type RoleSpec = { adapter: AgentFn; prompt: PromptInput; meta: Schema; }; ``` Static prompts cover simple cases. Dynamic prompts (functions) are needed when the prompt depends on thread context β€” e.g. reading issue content, injecting prior step results, or resolving repo paths at runtime. ### Timeout Resolution 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). When different roles need different timeouts, create separate adapter instances: ```ts 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 - **`nerve init`** β€” detects agent availability (CLI exists? service reachable?), reports errors immediately - **Runtime** β€” if an agent is unavailable, the workflow fails with a clear error. No silent degradation. Rationale: silent fallback hides quality differences (cursor β†’ hermes subagent produces very different output) and makes debugging harder. ### Adapter Hot-Reload 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 ```ts type WorkflowContext = { start: StartStep; messages: WorkflowMessage[]; workdir: string; // repo root β€” coding agent working directory signal: AbortSignal; // graceful cancellation }; ``` `workdir` is required for coding agents. `signal` enables graceful cancellation of long-running agent calls β€” adapters must respect it (e.g. kill subprocess on abort). ### Configuration Validation `nerve validate` checks: - 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 The existing `Role` signature: ```ts type Role = (start: StartStep, messages: WorkflowMessage[]) => Promise> ``` 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. ## Knowledge Layer Project knowledge is a **built-in nerve feature**. Scope is the **repo** β€” each repo has its own knowledge base, tracked in git. ### Architecture ``` Local (per repo) Remote Service β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ knowledge.yaml β”‚ β”‚ Embedding API β”‚ β”‚ β”œβ”€β”€ include/exclude β”‚ ──→ β”‚ text β†’ vector β”‚ β”‚ knowledge.db (SQLite) β”‚ ←── β”‚ content-hash cache β”‚ β”‚ β”œβ”€β”€ chunk text β”‚ β”‚ (avoid recompute) β”‚ β”‚ β”œβ”€β”€ embedding bytes β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ └── cosine search β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` - **Local-first** β€” `knowledge.db` stores chunks + embeddings, search runs locally (in-memory cosine similarity) - **Remote service only computes embeddings** β€” content-addressable cache keyed by text hash, avoids redundant computation across agents - **Branch-aware by design** β€” different agents on different branches naturally have different `knowledge.db` contents ### Configuration (`knowledge.yaml` at repo root) ```yaml include: - "src/**/*.ts" - "docs/**/*.md" - "*.md" exclude: - "node_modules/**" - "dist/**" - "*.test.ts" ``` `knowledge.yaml` is committed to git. `knowledge.db` is gitignored β€” it's a local cache rebuilt from source files + remote embedding service. ### CLI ```bash nerve knowledge sync # index/re-index changed files nerve knowledge query "how does the signal bus work" # Scope nerve knowledge query "..." # default: cwd repo nerve knowledge query --repo /path/to/other/repo "..." nerve knowledge query -g "..." # global search (all indexed repos) # --repo and -g are mutually exclusive ``` ### Search Implementation Project-scale knowledge (hundreds to low thousands of chunks) does not need vector indices. Full scan with cosine similarity in memory is sufficient and adds zero native dependencies. ```ts // Pseudocode const chunks = db.all("SELECT slug, chunk, embedding FROM chunks"); const query_vec = await embed(query); const results = chunks .map(c => ({ ...c, score: cosine(query_vec, c.embedding) })) .sort((a, b) => b.score - a.score) .slice(0, limit); ``` ### Knowledge Layers ``` Project knowledge (knowledge.yaml) Per repo, git managed, any agent reads Agent long-term memory Per agent, domain expertise, cross-run 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 ### Resolved - **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 - [RFC-002: Workflow Engine](./rfc-002-workflow-engine.md) - Current `Role` / `Moderator` types: `packages/core/src/workflow.ts`