diff --git a/docs/rfc-003-agent-config-layer.md b/docs/rfc-003-agent-config-layer.md index 0dcd0b9..644a1b6 100644 --- a/docs/rfc-003-agent-config-layer.md +++ b/docs/rfc-003-agent-config-layer.md @@ -38,7 +38,7 @@ All agent types implement a single unified interface: type AgentFn = (prompt: string, context: WorkflowContext) => Promise ``` -- **Input**: prompt (assembled by Role) + context (start frame + prior messages) +- **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.) @@ -52,7 +52,9 @@ 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 role. +Configured globally in `nerve.yaml`, overridable per agent and per role (three-level merge: global → agent → 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 @@ -62,11 +64,11 @@ Configured globally in `nerve.yaml`, overridable per role. agents: developer: type: cursor # adapter: cursor | hermes | codex | ... - model: auto + model: auto # "auto" = delegate to adapter's default strategy timeout: 300s ops: type: hermes - model: auto + model: auto # each adapter interprets "auto" independently timeout: 600s extract: @@ -129,6 +131,30 @@ coder: { agent: "developer", ... } Rationale: silent fallback hides quality differences (cursor → hermes subagent produces very different output) and makes debugging harder. +### Agent 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. + +### 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 agent names referenced in WorkflowSpec roles exist in `nerve.yaml` +- Agent type adapters are available (CLI exists, service reachable) +- Extract provider is configured and reachable + ## Compatibility with Current Types The existing `Role` signature: @@ -137,34 +163,95 @@ 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. +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`). + +Existing hand-written `Role` functions continue to work — `WorkflowSpec` is additive, not a breaking change. ## Knowledge Layer -Project knowledge is **not a nerve feature**. It is managed by [Alysaril](https://git.shazhou.work/uncaged/alysaril) — an independent project knowledge base tool (Zettelkasten cards + semantic search). +Project knowledge is a **built-in nerve feature**. Scope is the **repo** — each repo has its own knowledge base, tracked in git. -Nerve's relationship to project knowledge: - -- **Nerve does not hardcode knowledge paths** — no `.nerve/knowledge/` convention in runtime code -- **Knowledge loading is a prompt concern** — role prompts tell agents to read relevant cards -- **Agent long-term memory** — domain expertise accumulated across runs (e.g. "this repo uses pnpm"), stored per agent, separate from project knowledge -- **Workflow context** (`start` + `messages`) serves as the only in-run state — no separate "short-term memory" layer needed +### Architecture ``` -Project knowledge (Alysaril) Shared, git managed, any agent reads via prompt -Agent long-term memory Per agent, domain expertise, cross-run -Workflow context (start + msgs) Per run, moderator-controlled history +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 -r /path/to/other/repo "..." +nerve knowledge query -g "..." # global search (all indexed repos) +# -r 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 naming convention** — should we enforce a fixed set (`developer`, `ops`, `writer`) or allow arbitrary names? -2. **Extract override granularity** — global only, or also per-agent and per-role? -3. **Context threading** — should `WorkflowContext` expose `workdir` and `signal` alongside the existing `start` + `messages`? -4. **Agent long-term memory** — storage format and mechanism for persisting domain expertise across runs +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 +- **Context threading** → `WorkflowContext` includes `workdir` and `signal` (see design above) ## References - [RFC-002: Workflow Engine](./rfc-002-workflow-engine.md) - Current `Role` / `Moderator` types: `packages/core/src/workflow.ts` -- [Alysaril](https://git.shazhou.work/uncaged/alysaril) — project knowledge base (independent tool) diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index e0ff2f8..80854b7 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -200,6 +200,8 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig { ...(withNoopWorkflow ? { noop: { concurrency: 1, overflow: "drop" as const } } : {}), }, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; } diff --git a/packages/cli/src/__tests__/validate-workflow-agents.test.ts b/packages/cli/src/__tests__/validate-workflow-agents.test.ts new file mode 100644 index 0000000..bf0b6ac --- /dev/null +++ b/packages/cli/src/__tests__/validate-workflow-agents.test.ts @@ -0,0 +1,146 @@ +/** + * RFC-003 Phase 5: nerve validate — WorkflowSpec agent refs and extract. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { NerveConfig } from "@uncaged/nerve-core"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + collectWorkflowSpecAgentReferences, + validateAgentConfigurationLayer, +} from "../workflow-agent-validation.js"; + +function baseConfig(overrides: Partial = {}): NerveConfig { + return { + maxRounds: 10, + senses: {}, + workflows: {}, + api: { port: null, token: null, host: "127.0.0.1" }, + agents: {}, + extract: null, + ...overrides, + }; +} + +describe("validateAgentConfigurationLayer", () => { + let nerveRoot: string; + + afterEach(() => { + rmSync(nerveRoot, { recursive: true, force: true }); + }); + + it("fails when WorkflowSpec references an agent not in nerve.yaml", () => { + nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-agents-")); + mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true }); + writeFileSync( + join(nerveRoot, "workflows", "demo", "src", "index.ts"), + ` +import type { WorkflowSpec } from "@uncaged/nerve-core"; +const spec: WorkflowSpec<{ r: { x: number } }> = { + name: "demo", + roles: { + r: { agent: "missing-agent", prompt: "p", meta: {} as never, timeout: null }, + }, + moderator: () => "__end__" as never, +}; +export default spec; +`, + "utf8", + ); + + const result = validateAgentConfigurationLayer(baseConfig({ agents: {} }), nerveRoot); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain("missing-agent"); + } + }); + + it("passes when all WorkflowSpec agent refs exist and extract is configured", () => { + nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-agents-")); + 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" +`, + "utf8", + ); + + const result = validateAgentConfigurationLayer( + baseConfig({ + agents: { + "my-dev": { type: "echo", model: "auto", timeout: null }, + }, + extract: { provider: "dashscope", model: "qwen-plus" }, + }), + nerveRoot, + ); + expect(result.ok).toBe(true); + }); + + it("requires extract when any WorkflowSpec agent ref is found", () => { + nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-agents-")); + mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true }); + writeFileSync( + join(nerveRoot, "workflows", "demo", "src", "wf.ts"), + `const role = { agent: "my-dev", 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"); + } + }); +}); + +describe("collectWorkflowSpecAgentReferences", () => { + let nerveRoot: string; + + afterEach(() => { + rmSync(nerveRoot, { recursive: true, force: true }); + }); + + it("collects agent strings from workflows/*/src", () => { + nerveRoot = mkdtempSync(join(tmpdir(), "nerve-collect-refs-")); + mkdirSync(join(nerveRoot, "workflows", "w1", "src", "nested"), { recursive: true }); + writeFileSync( + join(nerveRoot, "workflows", "w1", "src", "nested", "a.ts"), + `agent: 'alpha'\nagent: "beta"`, + "utf8", + ); + + expect(collectWorkflowSpecAgentReferences(nerveRoot)).toEqual(["alpha", "beta"]); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 189f44d..6f47fe0 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { parseNerveConfig } from "@uncaged/nerve-core"; import { defineCommand } from "citty"; +import { validateAgentConfigurationLayer } from "../workflow-agent-validation.js"; import { getNerveRoot } from "../workspace.js"; export const validateCommand = defineCommand({ @@ -12,7 +13,8 @@ export const validateCommand = defineCommand({ description: "Validate nerve.yaml configuration", }, async run() { - const configPath = join(getNerveRoot(), "nerve.yaml"); + const nerveRoot = getNerveRoot(); + const configPath = join(nerveRoot, "nerve.yaml"); let raw: string; try { raw = readFileSync(configPath, "utf8"); @@ -29,6 +31,12 @@ export const validateCommand = defineCommand({ } const config = result.value; + const agentLayer = validateAgentConfigurationLayer(config, nerveRoot); + if (!agentLayer.ok) { + process.stderr.write(`❌ Config validation failed: ${agentLayer.message}\n`); + process.exit(1); + } + const senseCount = Object.keys(config.senses).length; const triggerScheduleCount = Object.values(config.senses).filter( (s) => s.interval !== null || s.on.length > 0, diff --git a/packages/cli/src/workflow-agent-validation.ts b/packages/cli/src/workflow-agent-validation.ts new file mode 100644 index 0000000..8cd41ff --- /dev/null +++ b/packages/cli/src/workflow-agent-validation.ts @@ -0,0 +1,96 @@ +/** + * RFC-003: cross-check WorkflowSpec `agent:` references in workflow sources against nerve.yaml. + */ + +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. */ +const WORKFLOW_SPEC_AGENT_PATTERN = /agent:\s*["']([^"']+)["']/g; + +function collectTsSourceFiles(dir: string, acc: string[]): void { + if (!existsSync(dir)) return; + for (const ent of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, ent.name); + if (ent.isDirectory()) { + collectTsSourceFiles(p, acc); + } else if (ent.isFile() && /\.(ts|mts|cts)$/.test(ent.name) && !ent.name.endsWith(".d.ts")) { + acc.push(p); + } + } +} + +/** + * Collects distinct agent names referenced via `agent: "..."` in each workflow's `src` tree. + */ +export function collectWorkflowSpecAgentReferences(nerveRoot: string): string[] { + const workflowsRoot = join(nerveRoot, "workflows"); + if (!existsSync(workflowsRoot)) { + return []; + } + + const refs = new Set(); + for (const wfName of readdirSync(workflowsRoot)) { + const wfDir = join(workflowsRoot, wfName); + if (!statSync(wfDir).isDirectory()) continue; + + const srcDir = join(wfDir, "src"); + const files: string[] = []; + collectTsSourceFiles(srcDir, files); + + for (const filePath of files) { + const content = readFileSync(filePath, "utf8"); + for (const m of content.matchAll(WORKFLOW_SPEC_AGENT_PATTERN)) { + refs.add(m[1]); + } + } + } + + return [...refs].sort((a, b) => a.localeCompare(b)); +} + +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). + */ +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) { + return { + ok: false, + message: + "extract: required when WorkflowSpec roles reference agents (configure extract.provider and extract.model)", + }; + } + + return { ok: true }; +} diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index ea70226..e289752 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -52,6 +52,8 @@ describe("parseNerveConfig", () => { overflow: "queue", maxQueue: 10, }); + expect(result.value.agents).toEqual({}); + expect(result.value.extract).toBe(null); expect(result.value.api).toEqual({ port: null, token: null, host: "127.0.0.1" }); }); @@ -220,6 +222,58 @@ senses: expect(result.value.senses.cpu.interval).toBe(5000); expect(result.value.senses.cpu.on).toEqual(["memory"]); }); + + it("parses agents and extract sections", () => { + const yaml = ` +senses: + cpu: + group: system +agents: + developer: + type: cursor + model: auto + timeout: 300s + my-custom-agent: + type: hermes + model: auto +extract: + provider: dashscope + model: qwen-plus +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.agents.developer).toEqual({ + type: "cursor", + model: "auto", + timeout: 300_000, + }); + expect(result.value.agents["my-custom-agent"]).toEqual({ + type: "hermes", + model: "auto", + timeout: null, + }); + expect(result.value.extract).toEqual({ provider: "dashscope", model: "qwen-plus" }); + }); + + it("allows arbitrary kebab-case agent names including multi-segment keys", () => { + const yaml = ` +senses: + cpu: + group: system +agents: + a: + type: x + model: auto + bb-cc-dd: + type: y + model: z +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Object.keys(result.value.agents).sort()).toEqual(["a", "bb-cc-dd"]); + }); }); describe("invalid configs", () => { @@ -449,5 +503,77 @@ workflows: if (result.ok) return; expect(result.error.message).toMatch(/max_queue.*not allowed.*drop/); }); + + it("returns error when agent key is not kebab-case", () => { + const yaml = ` +senses: + cpu: + group: system +agents: + Developer: + type: cursor + model: auto +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/invalid key "Developer"/); + }); + + it("returns error when agent key uses underscores", () => { + const yaml = ` +senses: + cpu: + group: system +agents: + my_agent: + type: cursor + model: auto +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/invalid key "my_agent"/); + }); + + it("returns error when agents section is not an object", () => { + const yaml = ` +senses: + cpu: + group: system +agents: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/agents: must be an object/); + }); + + it("returns error when extract section is not an object", () => { + const yaml = ` +senses: + cpu: + group: system +extract: "dashscope" +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/extract: must be an object/); + }); + + it("returns error when extract.provider is missing", () => { + const yaml = ` +senses: + cpu: + group: system +extract: + model: qwen-plus +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/extract\.provider/); + }); }); }); diff --git a/packages/core/src/__tests__/workflow-spec.test.ts b/packages/core/src/__tests__/workflow-spec.test.ts new file mode 100644 index 0000000..8ee8c54 --- /dev/null +++ b/packages/core/src/__tests__/workflow-spec.test.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..e7be7b2 --- /dev/null +++ b/packages/core/src/agent-adapter-ids.ts @@ -0,0 +1,5 @@ +/** + * Agent adapter types that have a daemon implementation (RFC-003). + * Keep in sync with `packages/daemon` agent factory dispatch. + */ +export const KNOWN_AGENT_ADAPTER_IDS = ["echo"] as const; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 3f93ef9..dc9bbb9 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -36,6 +36,21 @@ export type NerveApiConfig = { host: string; }; +/** Agent adapter defaults keyed by arbitrary kebab-case names in `nerve.yaml` (RFC-003). */ +export type AgentConfig = { + /** Adapter id (e.g. `cursor`, `hermes`, `codex`). */ + type: string; + /** Model id or `"auto"` for adapter defaults. */ + model: string; + timeout: number | null; +}; + +/** Global extract provider for typed meta from agent raw output (RFC-003). */ +export type ExtractConfig = { + provider: string; + model: string; +}; + /** Parameters for starting a workflow from a Sense compute result (or CLI trigger). */ export type WorkflowTrigger = { name: string; @@ -56,4 +71,8 @@ export type NerveConfig = { senses: Record; 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 new file mode 100644 index 0000000..9a15dd3 --- /dev/null +++ b/packages/core/src/duration.ts @@ -0,0 +1,22 @@ +import type { Result } from "./result.js"; +import { err, ok } from "./result.js"; + +const DURATION_RE = /^(\d+)([smh])$/; + +const DURATION_MULTIPLIERS: Record = { + s: 1_000, + m: 60_000, + h: 3_600_000, +}; + +/** + * Parse a duration string such as `5s`, `10m`, `1h` to milliseconds. + * Used by `parseNerveConfig` and WorkflowSpec role timeout (RFC-003). + */ +export function parseDurationStringToMs(value: string): Result { + const match = DURATION_RE.exec(value); + if (!match) { + return err(new Error(`invalid duration "${value}" (expected e.g. "5s", "10m", "1h")`)); + } + return ok(Number(match[1]) * DURATION_MULTIPLIERS[match[2]]); +} diff --git a/packages/core/src/extract-layer.ts b/packages/core/src/extract-layer.ts new file mode 100644 index 0000000..51a9e56 --- /dev/null +++ b/packages/core/src/extract-layer.ts @@ -0,0 +1,23 @@ +/** + * Extract layer types — parses agent raw string output into typed meta (RFC-003). + */ + +/** Structured meta validation descriptor for `ExtractFn`; concrete validators are provider-defined. */ +export type Schema = { + readonly witness: T | null; +}; + +export type ExtractFn = (raw: string, schema: Schema) => Promise; + +export class ExtractError extends Error { + readonly raw: string; + readonly causeError: Error | null; + + constructor(message: string, detail: { raw: string; causeError: Error | null }) { + super(message); + this.name = "ExtractError"; + this.raw = detail.raw; + this.causeError = detail.causeError; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index acf951f..961e397 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,8 @@ export type { QueueOverflowConfig, WorkflowConfig, NerveApiConfig, + AgentConfig, + ExtractConfig, NerveConfig, WorkflowTrigger, ComputeResult, @@ -17,16 +19,24 @@ export type { Role, RoleMeta, StartStep, + WorkflowContext, + AgentFn, RoleStep, ModeratorContext, Moderator, WorkflowDefinition, } from "./workflow.js"; export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js"; +export type { 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"; export type { Result } from "./result.js"; export { ok, err } from "./result.js"; export { parseNerveConfig } from "./parse-nerve-config.js"; export { isPlainRecord } from "./is-plain-record.js"; +export { KNOWN_AGENT_ADAPTER_IDS } from "./agent-adapter-ids.js"; export type { RoutedSenseOutput } from "./sense-workflow-directive.js"; export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense-workflow-directive.js"; diff --git a/packages/core/src/parse-nerve-config.ts b/packages/core/src/parse-nerve-config.ts index 0ff95de..2b3eacc 100644 --- a/packages/core/src/parse-nerve-config.ts +++ b/packages/core/src/parse-nerve-config.ts @@ -1,35 +1,29 @@ import { parse } from "yaml"; import { + type AgentConfig, DEFAULT_SENSE_SIGNAL_RETENTION, + type ExtractConfig, type NerveApiConfig, type NerveConfig, type SenseConfig, type WorkflowConfig, } from "./config.js"; +import { parseDurationStringToMs } from "./duration.js"; import { isPlainRecord } from "./is-plain-record.js"; import type { Result } from "./result.js"; import { err, ok } from "./result.js"; import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js"; -const DURATION_RE = /^(\d+)([smh])$/; - -const DURATION_MULTIPLIERS: Record = { - s: 1_000, - m: 60_000, - h: 3_600_000, -}; - -function parseDurationToMs(value: string): number | null { - const match = DURATION_RE.exec(value); - if (!match) return null; - return Number(match[1]) * DURATION_MULTIPLIERS[match[2]]; -} - 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); @@ -47,13 +41,11 @@ function parseDurationField(field: unknown, label: string): Result { @@ -281,6 +273,81 @@ 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); + } + + if (!isPlainRecord(obj.extract)) { + return err(new Error("extract: must be an object if provided")); + } + + const ext = obj.extract; + + if (typeof ext.provider !== "string" || ext.provider.trim() === "") { + return err(new Error("extract.provider: required non-empty string")); + } + + if (typeof ext.model !== "string" || ext.model.trim() === "") { + return err(new Error("extract.model: required non-empty string")); + } + + return ok({ provider: ext.provider, model: ext.model }); +} + export function parseNerveConfig(raw: string): Result { let parsed: unknown; @@ -319,10 +386,18 @@ export function parseNerveConfig(raw: string): Result { const apiResult = parseApiConfig(obj); if (!apiResult.ok) return apiResult; + const agentsResult = parseAgents(obj); + if (!agentsResult.ok) return agentsResult; + + const extractResult = parseExtract(obj); + if (!extractResult.ok) return extractResult; + return ok({ maxRounds: maxRoundsResult.value, senses, workflows: workflowsResult.value, api: apiResult.value, + agents: agentsResult.value, + extract: extractResult.value, }); } diff --git a/packages/core/src/workflow-spec.ts b/packages/core/src/workflow-spec.ts new file mode 100644 index 0000000..a15b753 --- /dev/null +++ b/packages/core/src/workflow-spec.ts @@ -0,0 +1,37 @@ +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 } from "./workflow.js"; + +/** + * Authoring-time role: references a named agent, prompt, extract schema, and optional timeout. + * Compiles to runtime `Role` via `compileWorkflowSpec` (RFC-003 Phase 4). + */ +export type RoleSpec> = { + agent: string; + prompt: string; + 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`. */ +export type WorkflowSpec = { + name: string; + 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/core/src/workflow.ts b/packages/core/src/workflow.ts index cf750fc..3b1fb0a 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -44,6 +44,17 @@ export type StartStep = { timestamp: number; }; +/** Thread context passed to agent adapters (RFC-003): conversation frame, repo root, cancellation. */ +export type WorkflowContext = { + start: StartStep; + messages: WorkflowMessage[]; + workdir: string; + signal: AbortSignal; +}; + +/** Unified agent invocation — raw string output; structured meta uses the extract layer. */ +export type AgentFn = (prompt: string, context: WorkflowContext) => Promise; + /** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */ export type RoleStep = { [K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number }; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 9036088..ac92166 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "composite": false + "composite": false, + "lib": ["ES2022", "DOM"] }, "include": ["src"] } diff --git a/packages/daemon/package.json b/packages/daemon/package.json index daea5f4..bce6d47 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -22,6 +22,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/daemon/src/__tests__/agent-registry.test.ts b/packages/daemon/src/__tests__/agent-registry.test.ts new file mode 100644 index 0000000..6dd4ec9 --- /dev/null +++ b/packages/daemon/src/__tests__/agent-registry.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentConfig, AgentFn, StartStep, WorkflowContext } from "@uncaged/nerve-core"; +import { START } from "@uncaged/nerve-core"; + +import { 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); + }); +}); diff --git a/packages/daemon/src/__tests__/compile-workflow-spec.test.ts b/packages/daemon/src/__tests__/compile-workflow-spec.test.ts new file mode 100644 index 0000000..05e876f --- /dev/null +++ b/packages/daemon/src/__tests__/compile-workflow-spec.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + AgentFn, + ModeratorContext, + RoleMeta, + Schema, + StartStep, + WorkflowContext, + WorkflowDefinition, + WorkflowMessage, + WorkflowSpec, +} from "@uncaged/nerve-core"; +import { END, START } from "@uncaged/nerve-core"; + +import { createAgentRegistry } from "../agent-registry.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 makeStart(threadId = "t1"): StartStep { + return { + role: START, + content: "", + meta: { maxRounds: 10, dryRun: false, threadId }, + timestamp: Date.now(), + }; +} + +function makeContext(start: StartStep, messages: WorkflowMessage[]): WorkflowContext { + return { + start, + messages, + workdir: "/tmp/repo", + signal: new AbortController().signal, + }; +} + +describe("compileWorkflowSpec", () => { + it("compiles WorkflowSpec to WorkflowDefinition shape", () => { + const witness: DemoMeta | null = null; + const schema: Schema = { witness }; + + const spec: WorkflowSpec<{ main: DemoMeta }> = { + name: "demo", + roles: { + main: { + agent: "dev", + 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, _s) => ({ n: raw.length }), + createContext: makeContext, + }); + + expect(def.name).toBe("demo"); + expect(typeof def.roles.main).toBe("function"); + expect(def.moderator).toBe(spec.moderator); + }); + + it("runs AgentFn then ExtractFn in order", async () => { + const witness: DemoMeta | null = null; + const schema: Schema = { witness }; + + const order: string[] = []; + + const registry = createAgentRegistry({ + dev: { type: "echo", model: "auto", timeout: null }, + }); + + const extractFn = async (raw: string, _sch: Schema): Promise => { + order.push("extract"); + return { n: raw.length }; + }; + + const orig = registry.get("dev"); + 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); + }, + }; + + const spec: WorkflowSpec<{ main: DemoMeta }> = { + name: "order-test", + roles: { + main: { + agent: "dev", + prompt: "ping", + meta: schema, + timeout: null, + }, + }, + moderator: () => END, + }; + + const def = compileWorkflowSpec(spec, { + registry: registryWithSpy, + extractFn, + createContext: makeContext, + }); + + const start = makeStart(); + await def.roles.main(start, []); + + expect(order).toEqual(["agent", "extract"]); + }); + + it("exposes two-level timeout via resolveRoleTimeoutMs integration (agent default vs override)", async () => { + const witness: DemoMeta | null = null; + const schema: Schema = { witness }; + + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + + const registry = createAgentRegistry({ + slow: { type: "echo", model: "auto", timeout: 400_000 }, + }); + + const specDefault: WorkflowSpec<{ main: DemoMeta }> = { + name: "def", + roles: { + main: { + agent: "slow", + prompt: "x", + meta: schema, + timeout: null, + }, + }, + moderator: () => END, + }; + + await compileWorkflowSpec(specDefault, { + registry, + extractFn: async () => ({ n: 0 }), + 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 () => ({ n: 0 }), + createContext: makeContext, + }).roles.main(makeStart(), []); + + expect(timeoutSpy).toHaveBeenCalledWith(60_000); + timeoutSpy.mockRestore(); + }); +}); + +describe("backward compatibility", () => { + it("hand-written Role-based WorkflowDefinition remains valid", async () => { + type M = RoleMeta & { legacy: { id: string } }; + + const manual: WorkflowDefinition = { + name: "legacy", + roles: { + legacy: async (_start, _messages) => ({ + content: "hi", + meta: { id: "a" }, + }), + }, + moderator: (_ctx: ModeratorContext) => END, + }; + + const start = makeStart(); + const out = await manual.roles.legacy(start, []); + expect(out.content).toBe("hi"); + expect(out.meta.id).toBe("a"); + }); +}); diff --git a/packages/daemon/src/__tests__/crash-recovery.test.ts b/packages/daemon/src/__tests__/crash-recovery.test.ts index 6d09d15..8b4cd77 100644 --- a/packages/daemon/src/__tests__/crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/crash-recovery.test.ts @@ -64,6 +64,8 @@ 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 708c0aa..4624f5c 100644 --- a/packages/daemon/src/__tests__/hot-reload.test.ts +++ b/packages/daemon/src/__tests__/hot-reload.test.ts @@ -70,6 +70,8 @@ function makeWfConfig(workflows: Record = {}): NerveConf senses: {}, workflows, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; } @@ -459,6 +461,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { senses: {}, workflows: { "my-wf": { concurrency: 1, overflow: "drop" } }, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; @@ -494,6 +498,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { senses: {}, workflows: { "old-wf": { concurrency: 1, overflow: "drop" } }, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; @@ -515,6 +521,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { senses: {}, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; kernel.reloadConfig(newConfig); @@ -537,6 +545,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { senses: {}, workflows: { "my-wf": { concurrency: 1, overflow: "drop" } }, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; @@ -553,6 +563,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { senses: {}, workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } }, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; kernel.reloadConfig(newConfig); diff --git a/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts b/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts new file mode 100644 index 0000000..f840315 --- /dev/null +++ b/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts @@ -0,0 +1,144 @@ +/** + * 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.mock.calls[0][0]).toEqual(a.agents); + + 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.mock.calls[1][0]).toEqual(b.agents); + + 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 7cb61a8..e3908bc 100644 --- a/packages/daemon/src/__tests__/kernel-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-integration.test.ts @@ -37,6 +37,8 @@ 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 8fed4f6..bbdedf4 100644 --- a/packages/daemon/src/__tests__/kernel-phase6.test.ts +++ b/packages/daemon/src/__tests__/kernel-phase6.test.ts @@ -85,6 +85,8 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, }; @@ -244,6 +246,8 @@ describe("kernel — reloadConfig", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }); @@ -277,6 +281,8 @@ describe("kernel — reloadConfig", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; const kernel = createKernel(config, nerveRoot); @@ -300,6 +306,8 @@ describe("kernel — reloadConfig", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }); @@ -339,6 +347,8 @@ describe("kernel — reloadConfig", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }); diff --git a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts index dc1234c..5bf7c8a 100644 --- a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts +++ b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts @@ -105,6 +105,8 @@ 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 531a942..8e3885d 100644 --- a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts @@ -117,6 +117,8 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, }; @@ -455,6 +457,8 @@ describe("kernel + workflowManager integration", () => { }, workflows: { "new-workflow": { concurrency: 1, overflow: "drop" } }, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; kernel.reloadConfig(newConfig); @@ -531,6 +535,8 @@ describe("kernel + workflowManager integration", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; kernel.reloadConfig(newConfig); diff --git a/packages/daemon/src/__tests__/kernel.test.ts b/packages/daemon/src/__tests__/kernel.test.ts index d2cdcf7..4688452 100644 --- a/packages/daemon/src/__tests__/kernel.test.ts +++ b/packages/daemon/src/__tests__/kernel.test.ts @@ -74,6 +74,8 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, }; @@ -285,6 +287,8 @@ describe("kernel — groupForSense mapping", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; const kernel = createKernel(config, nerveRoot); diff --git a/packages/daemon/src/__tests__/log-store-integration.test.ts b/packages/daemon/src/__tests__/log-store-integration.test.ts index 420394d..ffe044f 100644 --- a/packages/daemon/src/__tests__/log-store-integration.test.ts +++ b/packages/daemon/src/__tests__/log-store-integration.test.ts @@ -38,6 +38,8 @@ describe("LogStore + SenseScheduler integration", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; const bus = createSignalBus(); @@ -74,6 +76,8 @@ describe("LogStore + SenseScheduler integration", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; const bus = createSignalBus(); @@ -113,6 +117,8 @@ describe("LogStore + SenseScheduler integration", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; const bus = createSignalBus(); diff --git a/packages/daemon/src/__tests__/phase6-integration.test.ts b/packages/daemon/src/__tests__/phase6-integration.test.ts index f6ae91f..4eb694c 100644 --- a/packages/daemon/src/__tests__/phase6-integration.test.ts +++ b/packages/daemon/src/__tests__/phase6-integration.test.ts @@ -34,6 +34,8 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, ...overrides, }; @@ -169,6 +171,8 @@ describe("phase6 — reloadConfig", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; @@ -205,6 +209,8 @@ describe("phase6 — reloadConfig", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; kernel = createKernel(config, nerveRoot, { @@ -228,6 +234,8 @@ describe("phase6 — reloadConfig", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; @@ -281,6 +289,8 @@ describe("phase6 — error isolation", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; @@ -431,6 +441,8 @@ describe("phase6 — getHealth", () => { }, workflows: {}, maxRounds: 10, + agents: {}, + extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; kernel.reloadConfig(newConfig); 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 21e437b..d7bd613 100644 --- a/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts +++ b/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts @@ -19,6 +19,8 @@ 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 c047554..c98d3e4 100644 --- a/packages/daemon/src/__tests__/sense-scheduler.test.ts +++ b/packages/daemon/src/__tests__/sense-scheduler.test.ts @@ -41,6 +41,8 @@ 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 3d56fbe..6805c18 100644 --- a/packages/daemon/src/__tests__/workflow-manager.test.ts +++ b/packages/daemon/src/__tests__/workflow-manager.test.ts @@ -89,6 +89,8 @@ 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-adapters/echo.ts b/packages/daemon/src/agent-adapters/echo.ts new file mode 100644 index 0000000..357992b --- /dev/null +++ b/packages/daemon/src/agent-adapters/echo.ts @@ -0,0 +1,9 @@ +import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; + +/** + * Echo adapter (`type: "echo"`) — returns the assembled prompt unchanged. + * Used for tests and dry-run wiring before real adapters exist. + */ +export function createEchoAgent(_config: AgentConfig): AgentFn { + return async (prompt: string, _context: WorkflowContext) => prompt; +} diff --git a/packages/daemon/src/agent-registry.ts b/packages/daemon/src/agent-registry.ts new file mode 100644 index 0000000..afff8cc --- /dev/null +++ b/packages/daemon/src/agent-registry.ts @@ -0,0 +1,45 @@ +import type { AgentConfig, AgentFn } from "@uncaged/nerve-core"; +import { KNOWN_AGENT_ADAPTER_IDS } from "@uncaged/nerve-core"; + +import { createEchoAgent } from "./agent-adapters/echo.js"; + +export type AgentRegistry = { + get(name: string): AgentFn; + /** Resolved agent defaults from `nerve.yaml` (e.g. timeout for WorkflowSpec compile). */ + getAgentConfig(name: string): AgentConfig; +}; + +function createAgentFnForConfig(config: AgentConfig): AgentFn { + if (config.type === "echo") { + return createEchoAgent(config); + } + throw new Error( + `Unknown agent adapter type: "${config.type}" (known: ${KNOWN_AGENT_ADAPTER_IDS.join(", ")})`, + ); +} + +export function createAgentRegistry(agents: Record): AgentRegistry { + const byName = new Map(); + const configs = new Map(); + for (const [name, config] of Object.entries(agents)) { + byName.set(name, createAgentFnForConfig(config)); + 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 new file mode 100644 index 0000000..05cba38 --- /dev/null +++ b/packages/daemon/src/compile-workflow-spec.ts @@ -0,0 +1,80 @@ +import type { + Role, + RoleMeta, + RoleSpec, + Schema, + StartStep, + WorkflowContext, + WorkflowDefinition, + 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). + */ + extractFn: (raw: string, schema: Schema) => Promise; + /** Builds thread context for each role invocation (workdir, cancellation, etc.). */ + createContext: (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext; +}; + +function compileRoleForSpec>( + roleSpec: RoleSpec, + 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 raw = await agentFn(roleSpec.prompt, 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. + */ +export function compileWorkflowSpec( + spec: WorkflowSpec, + deps: CompileWorkflowSpecDeps, +): WorkflowDefinition { + const roleKeys = Object.keys(spec.roles) as Array; + const roles = {} as WorkflowDefinition["roles"]; + + for (const key of roleKeys) { + roles[key] = compileRoleForSpec(spec.roles[key], deps); + } + + return { + name: spec.name, + roles, + moderator: spec.moderator, + }; +} diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 5fbe6bb..98bacc3 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -57,3 +57,9 @@ export type { export { createWorkflowManager } from "./workflow-manager.js"; export type { WorkflowManager } from "./workflow-manager.js"; + +export { createAgentRegistry } from "./agent-registry.js"; +export type { 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 d48f2fd..0925889 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -19,6 +19,8 @@ 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"; @@ -64,6 +66,8 @@ 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; @@ -126,6 +130,7 @@ export function createKernel( }); let config = initialConfig; + let agentRegistry = createAgentRegistry(config.agents); let _signalIdCounter = 0; function nextSignalId(): number { @@ -305,6 +310,14 @@ export function createKernel( const oldConfig = config; const oldWorkflows = config.workflows; config = newConfig; + agentRegistry = createAgentRegistry(newConfig.agents); + 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, @@ -477,6 +490,7 @@ export function createKernel( triggerSense, restartGroup: (group) => senseWorkerPool.restartGroup(group), reloadConfig, + getAgentRegistry: () => agentRegistry, getHealth, getDaemonHealth, }; diff --git a/packages/workflow-utils/src/__tests__/extract-layer.test.ts b/packages/workflow-utils/src/__tests__/extract-layer.test.ts new file mode 100644 index 0000000..944c24e --- /dev/null +++ b/packages/workflow-utils/src/__tests__/extract-layer.test.ts @@ -0,0 +1,188 @@ +import { ExtractError } from "@uncaged/nerve-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + type ZodMetaSchema, + createLlmExtractFn, + extractMetaOrThrow, +} from "../shared/extract-fn.js"; +import { llmExtractWithRetry } from "../shared/llm-extract.js"; +import { type ExtractConfigLayer, mergeExtractConfig } from "../shared/merge-extract-config.js"; + +const provider = { + baseUrl: "https://example.com/v1", + apiKey: "k", + model: "m", +}; + +function toolCallResponse(argsJson: string): { + ok: boolean; + status: number; + text: () => Promise; +} { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + choices: [ + { + message: { + tool_calls: [ + { + function: { + name: "extract", + arguments: argsJson, + }, + }, + ], + }, + }, + ], + }), + }; +} + +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, + ); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + 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, + ); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.value).toEqual({ provider: "openai", model: "qwen-plus" }); + }); + + it("lets role override agent and global", () => { + const agent: ExtractConfigLayer = { provider: "openai", model: "gpt-4o" }; + const role: ExtractConfigLayer = { provider: null, model: "small" }; + const result = mergeExtractConfig({ provider: "dashscope", model: "qwen-plus" }, agent, role); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.value).toEqual({ provider: "openai", model: "small" }); + }); + + it("returns error when provider cannot be resolved", () => { + const result = mergeExtractConfig(null, { provider: null, model: "m" }, emptyLayer); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error.message).toMatch(/provider/); + }); +}); + +describe("extractMetaOrThrow + llmExtractWithRetry", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("extracts structured meta on success (mock LLM)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 42 })))); + + const schema = z.object({ n: z.number() }); + const value = await extractMetaOrThrow("raw agent output", schema, { + provider, + dryRun: false, + }); + + expect(value).toEqual({ n: 42 }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("retries once after schema validation failure then succeeds", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(toolCallResponse(JSON.stringify({ n: "bad" }))) + .mockResolvedValueOnce(toolCallResponse(JSON.stringify({ n: 99 }))); + + vi.stubGlobal("fetch", fetchMock); + + const schema = z.object({ n: z.number() }); + const value = await llmExtractWithRetry({ + text: "raw", + schema, + provider, + dryRun: false, + }); + + expect(value.ok).toBe(true); + if (!value.ok) { + return; + } + expect(value.value).toEqual({ n: 99 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const secondBody = JSON.parse( + (fetchMock.mock.calls[1] as [string, RequestInit])[1].body as string, + ) as { + messages: Array<{ role: string; content: string }>; + }; + expect(secondBody.messages[1].content).toContain("The previous extraction attempt failed"); + }); + + it("throws ExtractError with raw and causeError after two failures", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(toolCallResponse(JSON.stringify({ n: "still-bad" }))); + + vi.stubGlobal("fetch", fetchMock); + + const schema = z.object({ n: z.number() }); + + try { + await extractMetaOrThrow("original-raw-text", schema, { provider, dryRun: false }); + expect.fail("expected ExtractError"); + } catch (e) { + expect(e).toBeInstanceOf(ExtractError); + const ex = e as ExtractError; + expect(ex.raw).toBe("original-raw-text"); + expect(ex.causeError).toBeInstanceOf(Error); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("createLlmExtractFn", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("implements ExtractFn using ZodMetaSchema", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ k: "v" })))); + + const zod = z.object({ k: z.string() }); + const schema: ZodMetaSchema<{ k: string }> = { witness: null, zod }; + + const fn = createLlmExtractFn<{ k: string }>({ provider, dryRun: false }); + const out = await fn("input", schema); + + expect(out).toEqual({ k: "v" }); + }); +}); diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index a0fd111..3b80e72 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -4,7 +4,13 @@ export { createHermesRole } from "./role-hermes.js"; export { createLlmRole } from "./role-llm.js"; export { createReActRole } from "./role-react.js"; export { cursorAgent } from "./shared/cursor-agent.js"; -export { llmExtract } from "./shared/llm-extract.js"; +export { llmExtract, llmExtractWithRetry } from "./shared/llm-extract.js"; +export { mergeExtractConfig, type ExtractConfigLayer } from "./shared/merge-extract-config.js"; +export { + createLlmExtractFn, + extractMetaOrThrow, + type ZodMetaSchema, +} from "./shared/extract-fn.js"; export { nerveAgentContext, readNerveYaml, diff --git a/packages/workflow-utils/src/shared/extract-fn.ts b/packages/workflow-utils/src/shared/extract-fn.ts new file mode 100644 index 0000000..60fc52a --- /dev/null +++ b/packages/workflow-utils/src/shared/extract-fn.ts @@ -0,0 +1,44 @@ +import type { ExtractFn, Schema } from "@uncaged/nerve-core"; +import { ExtractError } from "@uncaged/nerve-core"; +import type { z } from "zod"; + +import type { LlmProvider } from "./llm-extract.js"; +import { llmErrorToCause, llmExtractWithRetry } from "./llm-extract.js"; + +/** + * Runtime schema for extract: core `Schema` witness plus the Zod parser used by the LLM extract path. + */ +export type ZodMetaSchema = Schema & { readonly zod: z.ZodType }; + +export async function extractMetaOrThrow( + raw: string, + zodSchema: z.ZodType, + options: { provider: LlmProvider; dryRun: boolean }, +): Promise { + const result = await llmExtractWithRetry({ + text: raw, + schema: zodSchema, + provider: options.provider, + dryRun: options.dryRun, + }); + if (result.ok) { + return result.value; + } + throw new ExtractError("Structured extraction failed after one retry", { + raw, + causeError: llmErrorToCause(result.error), + }); +} + +export function createLlmExtractFn(deps: { + provider: LlmProvider; + dryRun: boolean; +}): ExtractFn { + return async (raw, schema) => { + const extended = schema as ZodMetaSchema; + if (!("zod" in extended)) { + throw new Error("extract: schema must be a ZodMetaSchema (include zod parser)"); + } + return extractMetaOrThrow(raw, extended.zod, deps); + }; +} diff --git a/packages/workflow-utils/src/shared/llm-extract.ts b/packages/workflow-utils/src/shared/llm-extract.ts index 9be735f..e4a4e69 100644 --- a/packages/workflow-utils/src/shared/llm-extract.ts +++ b/packages/workflow-utils/src/shared/llm-extract.ts @@ -95,12 +95,45 @@ function readToolArgumentsJson(parsed: unknown, previewSource: string): Result( - options: LlmExtractOptionsInput, +async function performLlmExtract( + options: LlmExtractOptionsInput & { userContent: string }, ): Promise> { const dryRun = resolveLlmExtractDryRun(options); if (dryRun) { @@ -122,7 +155,7 @@ export async function llmExtract( role: "system" as const, content: "Extract the requested information from the provided text. Be precise.", }, - { role: "user" as const, content: options.text }, + { role: "user" as const, content: options.userContent }, ], tools: [ { @@ -188,3 +221,49 @@ export async function llmExtract( return ok(validated.data); } + +/** + * Single LLM extract attempt (backward-compatible with callers that pass `text`). + */ +export async function llmExtract( + options: LlmExtractOptionsInput, +): Promise> { + return performLlmExtract({ ...options, userContent: options.text }); +} + +/** + * Runs extract up to two times: on the first schema/tool-args parse failure, resends the agent + * output plus the error so the model can correct the tool call (RFC-003). + */ +export async function llmExtractWithRetry( + options: LlmExtractOptionsInput, +): Promise> { + const first = await performLlmExtract({ + ...options, + userContent: options.text, + }); + if (first.ok) { + return first; + } + if (!isRetryableExtractError(first.error)) { + return first; + } + + const hint = describeRetryHint(first.error); + const correction = `The previous extraction attempt failed. + +${hint} + +Respond again with a single tool call whose \`arguments\` JSON strictly matches the schema.`; + + const secondContent = `${options.text} + +--- + +${correction}`; + + return performLlmExtract({ + ...options, + userContent: secondContent, + }); +} diff --git a/packages/workflow-utils/src/shared/merge-extract-config.ts b/packages/workflow-utils/src/shared/merge-extract-config.ts new file mode 100644 index 0000000..1530922 --- /dev/null +++ b/packages/workflow-utils/src/shared/merge-extract-config.ts @@ -0,0 +1,29 @@ +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). + */ +export type ExtractConfigLayer = { + provider: string | null; + model: string | null; +}; + +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; + + if (provider === null || provider.trim() === "") { + return err(new Error("extract: unresolved provider after merge")); + } + if (model === null || model.trim() === "") { + return err(new Error("extract: unresolved model after merge")); + } + + return ok({ provider, model }); +}