refactor: replace WorkflowSpec with createRole helper #253

Merged
xiaomo merged 2 commits from refactor/252-create-role into main 2026-04-29 09:55:58 +00:00
13 changed files with 258 additions and 285 deletions
+8 -2
View File
@@ -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<string> => {
const run = await cursorAgent({
prompt,
mode: "default",
mode,
model: config.model,
cwd: context.workdir,
env: null,
@@ -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 },
@@ -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)",
};
}
-1
View File
@@ -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";
+1 -1
View File
@@ -330,7 +330,7 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
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)",
),
);
}
-24
View File
@@ -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<string>);
/**
* Authoring-time role: adapter function, prompt, extract schema (RFC-003).
* Compiles to runtime `Role<Meta>` via `compileWorkflowSpec`.
*/
export type RoleSpec<Meta extends Record<string, unknown>> = {
adapter: AgentFn;
prompt: PromptInput;
meta: Schema<Meta>;
};
/** User-facing workflow authoring shape; compiles to `WorkflowDefinition`. */
export type WorkflowSpec<M extends RoleMeta> = {
name: string;
roles: { [K in keyof M]: RoleSpec<M[K]> };
moderator: Moderator<M>;
};
@@ -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<DemoMeta> = { 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 <T>(raw: string, _s: Schema<T>) => ({ 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<DemoMeta> = { 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 <T>(raw: string, _sch: Schema<T>) => {
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<DemoMeta> = { 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 <T>(_raw: string, _s: Schema<T>) => ({ 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<DemoMeta> = { 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 <T>(raw: string, _s: Schema<T>) => ({ 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<M> = {
name: "legacy",
roles: {
legacy: async (_start, _messages) => ({
content: "hi",
meta: { id: "a" },
}),
},
moderator: (_ctx: ModeratorContext<M>) => END,
};
const start = makeStart();
const out = await manual.roles.legacy(start, []);
expect(out.content).toBe("hi");
expect(out.meta.id).toBe("a");
});
});
@@ -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: <T>(raw: string, schema: Schema<T>) => Promise<T>;
/** Builds thread context for each role invocation (workdir, cancellation, etc.). */
createContext: (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext;
};
function compileRoleForSpec<Meta extends Record<string, unknown>>(
roleSpec: RoleSpec<Meta>,
deps: CompileWorkflowSpecDeps,
): Role<Meta> {
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<M extends RoleMeta>(
spec: WorkflowSpec<M>,
deps: CompileWorkflowSpecDeps,
): WorkflowDefinition<M> {
const roleKeys = Object.keys(spec.roles) as Array<keyof M & string>;
const roles = {} as WorkflowDefinition<M>["roles"];
for (const key of roleKeys) {
roles[key] = compileRoleForSpec(spec.roles[key], deps);
}
return {
name: spec.name,
roles,
moderator: spec.moderator,
};
}
-2
View File
@@ -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";
@@ -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<string>;
} {
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<M> = {
name: "legacy",
roles: {
legacy: async (_start, _messages) => ({
content: "hi",
meta: { id: "a" },
}),
},
moderator: (_ctx: ModeratorContext<M>) => END,
};
const start = makeStart("t1");
const out = await manual.roles.legacy(start, []);
expect(out.content).toBe("hi");
expect(out.meta.id).toBe("a");
});
});
@@ -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<string>);
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<M extends Record<string, unknown>>(
adapter: AgentFn,
prompt: PromptInput,
meta: z.ZodType<M>,
extract: LlmExtractorConfig,
): Role<M> {
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 };
};
}
+2
View File
@@ -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 {
@@ -10,6 +10,11 @@ import { llmErrorToCause, llmExtractWithRetry } from "./llm-extract.js";
*/
export type ZodMetaSchema<T> = Schema<T> & { readonly zod: z.ZodType<T> };
/** Builds a core `Schema<T>` plus Zod parser for `createRole` meta / `createLlmExtractFn`. */
export function zodMeta<T>(zod: z.ZodType<T>): ZodMetaSchema<T> {
return { witness: null, zod };
}
export async function extractMetaOrThrow<T>(
raw: string,
zodSchema: z.ZodType<T>,
@@ -45,8 +50,8 @@ export function createLlmExtractFn<T>(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<string, Schema<unknown>>): void {
for (const [roleName, schema] of Object.entries(schemas)) {