refactor: replace WorkflowSpec with createRole helper #253
@@ -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)",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user