diff --git a/packages/adapter-cursor/src/index.ts b/packages/adapter-cursor/src/index.ts index dd436fe..649e65d 100644 --- a/packages/adapter-cursor/src/index.ts +++ b/packages/adapter-cursor/src/index.ts @@ -84,16 +84,22 @@ function throwCursorSpawnError(error: SpawnError): never { /** Default adapter config: model auto-selection and 300s wall-clock cap (milliseconds). */ const CURSOR_ADAPTER_DEFAULT_MS = 300_000; +export type CursorAdapterConfig = AgentConfig & { + /** When set, passes `--mode=ask` or `--mode=plan` to `cursor-agent` (default runs without extra mode). */ + mode?: CursorAgentMode; +}; + /** * Builds a Cursor CLI `AgentFn` from adapter config (model, timeout). */ -export function createCursorAdapter(config: AgentConfig): AgentFn { +export function createCursorAdapter(config: CursorAdapterConfig): AgentFn { const timeoutMs = config.timeout; + const mode = config.mode ?? "default"; return async (prompt: string, context: WorkflowContext): Promise => { const run = await cursorAgent({ prompt, - mode: "default", + mode, model: config.model, cwd: context.workdir, env: null, diff --git a/packages/cli/src/__tests__/validate-workflow-agents.test.ts b/packages/cli/src/__tests__/validate-workflow-agents.test.ts index 1bb599c..fd02ee0 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 adapter usage and extract. + * RFC-003 Phase 5: nerve validate — workflow adapter usage and extract. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -38,9 +38,8 @@ describe("validateAgentConfigurationLayer", () => { writeFileSync( join(nerveRoot, "workflows", "demo", "src", "index.ts"), ` -import type { WorkflowSpec } from "@uncaged/nerve-core"; const adapter = async () => ""; -const spec: WorkflowSpec<{ r: { x: number } }> = { +const spec = { name: "demo", roles: { r: { adapter: adapter, prompt: "p", meta: {} as never }, diff --git a/packages/cli/src/workflow-agent-validation.ts b/packages/cli/src/workflow-agent-validation.ts index 4a90b5c..049b155 100644 --- a/packages/cli/src/workflow-agent-validation.ts +++ b/packages/cli/src/workflow-agent-validation.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; import type { NerveConfig } from "@uncaged/nerve-core"; /** - * Detects RoleSpec `adapter:` usage in workflow TypeScript sources. + * Detects `adapter:` usage in workflow TypeScript sources (e.g. createRole wiring). * NOTE: This regex can match occurrences inside comments. */ const WORKFLOW_SPEC_ADAPTER_PATTERN = /adapter:\s*[a-zA-Z_$]/; @@ -26,7 +26,7 @@ function collectTsSourceFiles(dir: string, acc: string[]): void { } /** - * Returns true when any workflow `src` tree appears to use WorkflowSpec roles with adapters. + * Returns true when any workflow `src` tree appears to use roles with adapters. */ export function workflowSourcesDeclareAdapterRoles(nerveRoot: string): boolean { const workflowsRoot = join(nerveRoot, "workflows"); @@ -66,7 +66,7 @@ export function validateAgentConfigurationLayer( return { ok: false, message: - "extract: required when WorkflowSpec roles use adapters (configure extract.provider and extract.model)", + "extract: required when workflow roles use adapters (configure extract.provider and extract.model)", }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7145aa9..727cfad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,6 @@ export type { WorkflowDefinition, } from "./workflow.js"; export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js"; -export type { PromptInput, RoleSpec, WorkflowSpec } 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 10ddd01..8a614c6 100644 --- a/packages/core/src/parse-nerve-config.ts +++ b/packages/core/src/parse-nerve-config.ts @@ -330,7 +330,7 @@ export function parseNerveConfig(raw: string): Result { if (Object.hasOwn(obj, "agents")) { return err( new Error( - "agents: key is no longer supported — declare adapters on WorkflowSpec roles (RFC-003)", + "agents: key is no longer supported — declare adapters on workflow roles (RFC-003)", ), ); } diff --git a/packages/core/src/workflow-spec.ts b/packages/core/src/workflow-spec.ts deleted file mode 100644 index 056ee63..0000000 --- a/packages/core/src/workflow-spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Schema } from "./extract-layer.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 = - | string - | ((start: StartStep, messages: WorkflowMessage[]) => Promise); - -/** - * Authoring-time role: adapter function, prompt, extract schema (RFC-003). - * Compiles to runtime `Role` via `compileWorkflowSpec`. - */ -export type RoleSpec> = { - adapter: AgentFn; - prompt: PromptInput; - meta: Schema; -}; - -/** User-facing workflow authoring shape; compiles to `WorkflowDefinition`. */ -export type WorkflowSpec = { - name: string; - roles: { [K in keyof M]: RoleSpec }; - moderator: Moderator; -}; diff --git a/packages/daemon/src/__tests__/compile-workflow-spec.test.ts b/packages/daemon/src/__tests__/compile-workflow-spec.test.ts deleted file mode 100644 index 2ebeee1..0000000 --- a/packages/daemon/src/__tests__/compile-workflow-spec.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it } 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 { compileWorkflowSpec } from "../compile-workflow-spec.js"; - -type DemoMeta = { n: number }; - -function echoAdapter(): AgentFn { - return async (prompt: string, _ctx: WorkflowContext) => prompt; -} - -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: { - adapter: echoAdapter(), - prompt: "hello", - meta: schema, - }, - }, - moderator: (_ctx: ModeratorContext<{ main: DemoMeta }>) => END, - }; - - const def = compileWorkflowSpec(spec, { - extractFn: async (raw: string, _s: Schema) => ({ n: raw.length }) as T, - 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 baseEcho = echoAdapter(); - const spyAgent: AgentFn = async (prompt, ctx) => { - order.push("agent"); - return baseEcho(prompt, ctx); - }; - - const spec: WorkflowSpec<{ main: DemoMeta }> = { - name: "order-test", - roles: { - main: { - adapter: spyAgent, - prompt: "ping", - meta: schema, - }, - }, - moderator: () => END, - }; - - const def = compileWorkflowSpec(spec, { - extractFn: async (raw: string, _sch: Schema) => { - order.push("extract"); - return { n: raw.length } as T; - }, - createContext: makeContext, - }); - - const start = makeStart(); - await def.roles.main(start, []); - - expect(order).toEqual(["agent", "extract"]); - }); - - it("passes WorkflowContext from createContext to AgentFn (adapter owns timeout)", async () => { - const witness: DemoMeta | null = null; - const schema: Schema = { witness }; - - const seenCtx: WorkflowContext[] = []; - - const adapter: AgentFn = async (_prompt, ctx) => { - seenCtx.push(ctx); - return "x"; - }; - - const spec: WorkflowSpec<{ main: DemoMeta }> = { - name: "ctx", - roles: { - main: { - adapter, - prompt: "x", - meta: schema, - }, - }, - moderator: () => END, - }; - - await compileWorkflowSpec(spec, { - extractFn: async (_raw: string, _s: Schema) => ({ n: 0 }) as T, - createContext: makeContext, - }).roles.main(makeStart(), []); - - 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 spec: WorkflowSpec<{ main: DemoMeta }> = { - name: "dyn", - roles: { - main: { - adapter: echoAdapter(), - prompt: async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`, - meta: schema, - }, - }, - moderator: () => END, - }; - - const def = compileWorkflowSpec(spec, { - extractFn: async (raw: string, _s: Schema) => ({ n: raw.length }) as T, - createContext: makeContext, - }); - - const start = makeStart("thread-x"); - const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }]; - const out = await def.roles.main(start, msgs); - expect(out.content).toBe("tid=thread-x n=1"); - expect(out.meta.n).toBe(out.content.length); - }); -}); - -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/compile-workflow-spec.ts b/packages/daemon/src/compile-workflow-spec.ts deleted file mode 100644 index eb7e570..0000000 --- a/packages/daemon/src/compile-workflow-spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { - Role, - RoleMeta, - RoleSpec, - Schema, - StartStep, - WorkflowContext, - WorkflowDefinition, - WorkflowMessage, - WorkflowSpec, -} from "@uncaged/nerve-core"; - -export type CompileWorkflowSpecDeps = { - /** - * 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.). */ - createContext: (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext; -}; - -function compileRoleForSpec>( - roleSpec: RoleSpec, - deps: CompileWorkflowSpecDeps, -): Role { - return async (start: StartStep, messages: WorkflowMessage[]) => { - const ctx = deps.createContext(start, messages); - - const promptText = - typeof roleSpec.prompt === "string" - ? roleSpec.prompt - : await roleSpec.prompt(start, messages); - - 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`: wires adapters and 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 6ef1fad..b1b38c3 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -58,6 +58,4 @@ export type { export { createWorkflowManager } from "./workflow-manager.js"; export type { WorkflowManager } from "./workflow-manager.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/workflow-utils/src/__tests__/create-role.test.ts b/packages/workflow-utils/src/__tests__/create-role.test.ts new file mode 100644 index 0000000..6b16c1d --- /dev/null +++ b/packages/workflow-utils/src/__tests__/create-role.test.ts @@ -0,0 +1,180 @@ +import type { + AgentFn, + ModeratorContext, + RoleMeta, + WorkflowContext, + WorkflowDefinition, + WorkflowMessage, +} from "@uncaged/nerve-core"; +import { END, START } from "@uncaged/nerve-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { createRole } from "../create-role.js"; +import * as extractFn from "../shared/extract-fn.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, + }, + }, + ], + }, + }, + ], + }), + }; +} + +function makeStart(threadId: string): { + role: typeof START; + content: string; + meta: { maxRounds: number; dryRun: boolean; threadId: string }; + timestamp: number; +} { + return { + role: START, + content: "", + meta: { maxRounds: 10, dryRun: false, threadId }, + timestamp: Date.now(), + }; +} + +describe("createRole", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("runs AgentFn then structured extract", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 3 })))); + + const schema = z.object({ n: z.number() }); + const adapter: AgentFn = async (prompt) => prompt; + const role = createRole(adapter, "hello", schema, { provider }); + + const out = await role(makeStart("t1"), []); + expect(out.content).toBe("hello"); + expect(out.meta).toEqual({ n: 3 }); + }); + + it("passes WorkflowContext with workdir defaulting to process.cwd()", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 0 })))); + + const seen: WorkflowContext[] = []; + const adapter: AgentFn = async (_prompt, ctx) => { + seen.push(ctx); + return "x"; + }; + const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider }); + await role(makeStart("t1"), []); + + expect(seen).toHaveLength(1); + expect(seen[0].workdir).toBe(process.cwd()); + }); + + it("resolves dynamic prompt functions before AgentFn", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 99 })))); + + const schema = z.object({ n: z.number() }); + const adapter: AgentFn = async (prompt) => prompt; + const role = createRole( + adapter, + async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`, + schema, + { provider }, + ); + + const start = makeStart("thread-x"); + const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }]; + const out = await role(start, msgs); + expect(out.content).toBe("tid=thread-x n=1"); + expect(out.meta).toEqual({ n: 99 }); + }); + + it("uses start.meta.dryRun when extract.dryRun is omitted", async () => { + const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); + + const adapter: AgentFn = async () => "raw"; + const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider }); + const start = { + role: START, + content: "", + meta: { maxRounds: 10, dryRun: true, threadId: "x" }, + timestamp: 1, + }; + await role(start, []); + + expect(spy).toHaveBeenCalledWith( + "raw", + expect.anything(), + expect.objectContaining({ provider, dryRun: true }), + ); + }); + + it("prefers extract.dryRun over start.meta.dryRun", async () => { + const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); + + const adapter: AgentFn = async () => "raw"; + const role = createRole(adapter, "p", z.object({ n: z.number() }), { + provider, + dryRun: false, + }); + const start = { + role: START, + content: "", + meta: { maxRounds: 10, dryRun: true, threadId: "x" }, + timestamp: 1, + }; + await role(start, []); + + expect(spy).toHaveBeenCalledWith( + "raw", + expect.anything(), + expect.objectContaining({ dryRun: false }), + ); + }); +}); + +describe("WorkflowDefinition 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("t1"); + const out = await manual.roles.legacy(start, []); + expect(out.content).toBe("hi"); + expect(out.meta.id).toBe("a"); + }); +}); diff --git a/packages/workflow-utils/src/create-role.ts b/packages/workflow-utils/src/create-role.ts new file mode 100644 index 0000000..c938c44 --- /dev/null +++ b/packages/workflow-utils/src/create-role.ts @@ -0,0 +1,55 @@ +import type { + AgentFn, + Role, + StartStep, + WorkflowContext, + WorkflowMessage, +} from "@uncaged/nerve-core"; +import type { z } from "zod"; + +import { extractMetaOrThrow } from "./shared/extract-fn.js"; +import type { LlmProvider } from "./shared/llm-extract.js"; + +type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise); + +export type LlmExtractorConfig = { + provider: LlmProvider; + /** When omitted, uses `start.meta.dryRun` at runtime. */ + dryRun?: boolean; +}; + +type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null }; + +function resolveWorkdir(start: StartStep): string { + const m = start.meta as StartMetaWithWorkdir; + return m.workdir ?? process.cwd(); +} + +function resolveDryRun(extract: LlmExtractorConfig, start: StartStep): boolean { + return extract.dryRun ?? start.meta.dryRun; +} + +/** Builds a Role from an AgentFn, prompt, Zod meta schema, and LLM extract config. */ +export function createRole>( + adapter: AgentFn, + prompt: PromptInput, + meta: z.ZodType, + extract: LlmExtractorConfig, +): Role { + return async (start: StartStep, messages: WorkflowMessage[]) => { + const ctx: WorkflowContext = { + start, + messages, + workdir: resolveWorkdir(start), + signal: new AbortController().signal, + }; + + const promptText = typeof prompt === "string" ? prompt : await prompt(start, messages); + const raw = await adapter(promptText, ctx); + const result = await extractMetaOrThrow(raw, meta, { + provider: extract.provider, + dryRun: resolveDryRun(extract, start), + }); + return { content: raw, meta: result }; + }; +} diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 6df3169..376b25f 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -1,4 +1,5 @@ // Primary API — role factory templates +export { createRole, type LlmExtractorConfig } from "./create-role.js"; export { createCursorRole } from "./role-cursor.js"; export { createHermesRole } from "./role-hermes.js"; export { createLlmRole } from "./role-llm.js"; @@ -9,6 +10,7 @@ export { assertZodMetaSchemas, createLlmExtractFn, extractMetaOrThrow, + zodMeta, type ZodMetaSchema, } from "./shared/extract-fn.js"; export { diff --git a/packages/workflow-utils/src/shared/extract-fn.ts b/packages/workflow-utils/src/shared/extract-fn.ts index f8471d5..b66dbbf 100644 --- a/packages/workflow-utils/src/shared/extract-fn.ts +++ b/packages/workflow-utils/src/shared/extract-fn.ts @@ -10,6 +10,11 @@ import { llmErrorToCause, llmExtractWithRetry } from "./llm-extract.js"; */ export type ZodMetaSchema = Schema & { readonly zod: z.ZodType }; +/** Builds a core `Schema` plus Zod parser for `createRole` meta / `createLlmExtractFn`. */ +export function zodMeta(zod: z.ZodType): ZodMetaSchema { + return { witness: null, zod }; +} + export async function extractMetaOrThrow( raw: string, zodSchema: z.ZodType, @@ -45,8 +50,8 @@ export function createLlmExtractFn(deps: { } /** - * Validate that all schemas in a WorkflowSpec are ZodMetaSchema at compile time, - * before any role is ever invoked. Call this once at daemon startup / hot-reload. + * Validate that all schemas are ZodMetaSchema before any role is invoked. + * Call this once at daemon startup / hot-reload when wiring roles manually. */ export function assertZodMetaSchemas(schemas: Record>): void { for (const [roleName, schema] of Object.entries(schemas)) {