refactor(workflow-utils): reorganize — roles top-level, shared internals in shared/ #228

Merged
xingyue merged 1 commits from refactor/227-workflow-utils-reorg into main 2026-04-28 08:51:06 +00:00
21 changed files with 257 additions and 281 deletions
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { llmExtract } from "../llm-extract.js";
import { llmExtract } from "../shared/llm-extract.js";
describe("llmExtract", () => {
afterEach(() => {
@@ -3,12 +3,10 @@ import { z } from "zod";
import { START } from "@uncaged/nerve-core";
import {
createCursorRole,
createHermesRole,
createLlmRole,
createReActRole,
} from "../role-factories.js";
import { createCursorRole } from "../role-cursor.js";
import { createHermesRole } from "../role-hermes.js";
import { createLlmRole } from "../role-llm.js";
import { createReActRole } from "../role-react.js";
function startFrame(dryRun: boolean, threadId: string) {
return {
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { schemaDefaults } from "../schema-defaults.js";
import { schemaDefaults } from "../shared/schema-defaults.js";
describe("schemaDefaults", () => {
it("fills nested objects with primitive placeholders", () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { spawnSafe } from "../spawn-safe.js";
import { spawnSafe } from "../shared/spawn-safe.js";
describe("spawnSafe", () => {
it("passes argv literally without shell interpretation (injection-safe)", async () => {
@@ -1,29 +0,0 @@
import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js";
const HERMES_DEFAULTS: HermesRoleDefaults = {
model: null,
provider: null,
skills: [],
quiet: true,
maxTurns: 90,
env: {},
timeoutMs: 600_000,
};
export function resolveHermesOptions<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): HermesRoleDefaults {
const d = HERMES_DEFAULTS;
return {
model: "model" in options && options.model !== undefined ? options.model : d.model,
provider:
"provider" in options && options.provider !== undefined ? options.provider : d.provider,
skills: "skills" in options && options.skills !== undefined ? options.skills : d.skills,
quiet: "quiet" in options && options.quiet !== undefined ? options.quiet : d.quiet,
maxTurns:
"maxTurns" in options && options.maxTurns !== undefined ? options.maxTurns : d.maxTurns,
env: "env" in options && options.env !== undefined ? options.env : d.env,
timeoutMs:
"timeoutMs" in options && options.timeoutMs !== undefined ? options.timeoutMs : d.timeoutMs,
};
}
+9 -11
View File
@@ -1,17 +1,15 @@
// Primary API — role factory templates
export {
createCursorRole,
createHermesRole,
createLlmRole,
createReActRole,
} from "./role-factories.js";
export { createCursorRole } from "./role-cursor.js";
export { createHermesRole } from "./role-hermes.js";
export { createLlmRole } from "./role-llm.js";
export { createReActRole } from "./role-react.js";
export {
nerveAgentContext,
readNerveYaml,
type NerveYamlError,
type ReadNerveYamlOptions,
} from "./context.js";
export { isDryRun } from "./start-step.js";
} from "./shared/context.js";
export { isDryRun } from "./role-types.js";
export {
nerveCommandEnv,
spawnSafe,
@@ -19,8 +17,8 @@ export {
type SpawnError,
type SpawnResult,
type SpawnSafeOptions,
} from "./spawn-safe.js";
export type { LlmError, LlmProvider } from "./llm-extract.js";
} from "./shared/spawn-safe.js";
export type { LlmError, LlmProvider } from "./shared/llm-extract.js";
export type {
CliPromptFn,
CursorRoleDefaults,
@@ -35,4 +33,4 @@ export type {
ReActRoleRequired,
ReActTool,
} from "./role-types.js";
export type { LlmChatError } from "./llm-chat.js";
export type { LlmChatError } from "./shared/llm-chat.js";
@@ -0,0 +1,72 @@
import type { Role } from "@uncaged/nerve-core";
import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { type CursorAgentMode, cursorAgent } from "./shared/cursor-agent.js";
import { formatLlmError } from "./shared/format-error.js";
import { llmExtract } from "./shared/llm-extract.js";
import type { SpawnEnv } from "./shared/spawn-safe.js";
const CURSOR_DEFAULTS: CursorRoleDefaults = {
mode: "default",
model: "auto",
env: {},
timeoutMs: 300_000,
};
function pick<T>(opts: Record<string, unknown>, key: string, fallback: T): T {
if (key in opts && opts[key] !== undefined) {
return opts[key] as T;
}
return fallback;
}
/**
* `cursor-agent` + `llmExtract` to produce `RoleResult<T>`. CLI agent returns
* a single string; structured meta is read via a cheap follow-up `llmExtract`.
*/
export function createCursorRole<T>(
options: CursorRoleRequired<T> & Partial<CursorRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const d = CURSOR_DEFAULTS;
const mode = pick<CursorAgentMode>(options as Record<string, unknown>, "mode", d.mode);
const model = pick<string>(options as Record<string, unknown>, "model", d.model);
const env = pick<SpawnEnv>(options as Record<string, unknown>, "env", d.env);
const timeoutMs = pick<number>(options as Record<string, unknown>, "timeoutMs", d.timeoutMs);
const prompt = await options.prompt(start.meta.threadId);
const run = await cursorAgent({
prompt,
mode,
model,
cwd: options.cwd,
env: Object.keys(env).length === 0 ? null : env,
timeoutMs,
dryRun: dry,
});
if (!run.ok) {
const e = run.error;
if (e.kind === "non_zero_exit") {
throw new Error(
`cursor-agent: exitCode=${e.exitCode} stdout=${e.stdout} stderr=${e.stderr}`,
);
}
if (e.kind === "timeout") {
throw new Error("cursor-agent: timeout");
}
throw new Error(`cursor-agent: ${e.message}`);
}
const text = run.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
@@ -1,223 +0,0 @@
import type { Role } from "@uncaged/nerve-core";
import { cursorAgent } from "./cursor-agent.js";
import type { CursorAgentMode } from "./cursor-agent.js";
import { hermesAgent } from "./hermes-agent.js";
import { resolveHermesOptions } from "./hermes-options.js";
import { type LlmChatError, chatCompletionText, reActIterativeChat } from "./llm-chat.js";
import { type LlmError, llmExtract } from "./llm-extract.js";
import type {
CursorRoleDefaults,
CursorRoleRequired,
HermesRoleDefaults,
HermesRoleRequired,
LlmMessage,
LlmRoleRequired,
ReActRoleDefaults,
ReActRoleRequired,
} from "./role-types.js";
import type { SpawnEnv } from "./spawn-safe.js";
import { isDryRun } from "./start-step.js";
const CURSOR_DEFAULTS: CursorRoleDefaults = {
mode: "default",
model: "auto",
env: {},
timeoutMs: 300_000,
};
const REACT_DEFAULTS: ReActRoleDefaults = {
maxIterations: 10,
};
function mergeMode(
o: CursorRoleRequired<unknown> & Partial<CursorRoleDefaults>,
d: CursorRoleDefaults,
): CursorAgentMode {
if ("mode" in o && o.mode !== undefined) {
return o.mode;
}
return d.mode;
}
function mergeCursorModel(
o: CursorRoleRequired<unknown> & Partial<CursorRoleDefaults>,
d: CursorRoleDefaults,
): string {
if ("model" in o && o.model !== undefined) {
return o.model;
}
return d.model;
}
function mergeCursorEnv(
o: CursorRoleRequired<unknown> & Partial<CursorRoleDefaults>,
d: CursorRoleDefaults,
): SpawnEnv {
if ("env" in o && o.env !== undefined) {
return o.env;
}
return d.env;
}
function mergeCursorTimeout(
o: CursorRoleRequired<unknown> & Partial<CursorRoleDefaults>,
d: CursorRoleDefaults,
): number {
if ("timeoutMs" in o && o.timeoutMs !== undefined) {
return o.timeoutMs;
}
return d.timeoutMs;
}
function formatLlmError(e: LlmError | LlmChatError): string {
return JSON.stringify(e);
}
/**
* `cursor-agent` + `llmExtract` to produce `RoleResult<T>`. CLI agent returns
* a single string; structured meta is read via a cheap follow-up `llmExtract`.
*/
export function createCursorRole<T>(
options: CursorRoleRequired<T> & Partial<CursorRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const d = CURSOR_DEFAULTS;
const mode = mergeMode(options, d);
const model = mergeCursorModel(options, d);
const env = mergeCursorEnv(options, d);
const timeoutMs = mergeCursorTimeout(options, d);
const prompt = await options.prompt(start.meta.threadId);
const run = await cursorAgent({
prompt,
mode,
model,
cwd: options.cwd,
env: Object.keys(env).length === 0 ? null : env,
timeoutMs,
dryRun: dry,
});
if (!run.ok) {
const e = run.error;
if (e.kind === "non_zero_exit") {
throw new Error(
`cursor-agent: exitCode=${e.exitCode} stdout=${e.stdout} stderr=${e.stderr}`,
);
}
if (e.kind === "timeout") {
throw new Error("cursor-agent: timeout");
}
throw new Error(`cursor-agent: ${e.message}`);
}
const text = run.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
export function createHermesRole<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const h = resolveHermesOptions(options);
const prompt = await options.prompt(start.meta.threadId);
const run = await hermesAgent({
prompt,
model: h.model,
provider: h.provider,
skills: h.skills,
quiet: h.quiet,
maxTurns: h.maxTurns,
env: Object.keys(h.env).length === 0 ? null : h.env,
timeoutMs: h.timeoutMs,
dryRun: dry,
});
if (!run.ok) {
const e = run.error;
if (e.kind === "non_zero_exit") {
throw new Error(`hermes: exitCode=${e.exitCode} stdout=${e.stdout} stderr=${e.stderr}`);
}
if (e.kind === "timeout") {
throw new Error("hermes: timeout");
}
throw new Error(`hermes: ${e.message}`);
}
const text = run.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
export function createLlmRole<T>(options: LlmRoleRequired<T>): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const result = await chatCompletionText({ provider: options.provider, messages });
if (!result.ok) {
throw new Error(`llm: ${formatLlmError(result.error)}`);
}
const text = result.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
export function createReActRole<T>(
options: ReActRoleRequired<T> & Partial<ReActRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const def = REACT_DEFAULTS;
const maxIt =
"maxIterations" in options && options.maxIterations !== undefined
? options.maxIterations
: def.maxIterations;
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const result = await reActIterativeChat({
provider: options.provider,
tools: options.tools,
messages,
maxIterations: maxIt,
});
if (!result.ok) {
throw new Error(`react: ${formatLlmError(result.error)}`);
}
const text = result.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
@@ -0,0 +1,49 @@
import type { Role } from "@uncaged/nerve-core";
import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { hermesAgent, resolveHermesOptions } from "./shared/hermes-agent.js";
import { llmExtract } from "./shared/llm-extract.js";
export function createHermesRole<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const h = resolveHermesOptions(options);
const prompt = await options.prompt(start.meta.threadId);
const run = await hermesAgent({
prompt,
model: h.model,
provider: h.provider,
skills: h.skills,
quiet: h.quiet,
maxTurns: h.maxTurns,
env: Object.keys(h.env).length === 0 ? null : h.env,
timeoutMs: h.timeoutMs,
dryRun: dry,
});
if (!run.ok) {
const e = run.error;
if (e.kind === "non_zero_exit") {
throw new Error(`hermes: exitCode=${e.exitCode} stdout=${e.stdout} stderr=${e.stderr}`);
}
if (e.kind === "timeout") {
throw new Error("hermes: timeout");
}
throw new Error(`hermes: ${e.message}`);
}
const text = run.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
+29
View File
@@ -0,0 +1,29 @@
import type { Role } from "@uncaged/nerve-core";
import type { LlmMessage, LlmRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { chatCompletionText } from "./shared/llm-chat.js";
import { llmExtract } from "./shared/llm-extract.js";
export function createLlmRole<T>(options: LlmRoleRequired<T>): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const result = await chatCompletionText({ provider: options.provider, messages });
if (!result.ok) {
throw new Error(`llm: ${formatLlmError(result.error)}`);
}
const text = result.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
+45
View File
@@ -0,0 +1,45 @@
import type { Role } from "@uncaged/nerve-core";
import type { LlmMessage, ReActRoleDefaults, ReActRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { reActIterativeChat } from "./shared/llm-chat.js";
import { llmExtract } from "./shared/llm-extract.js";
const REACT_DEFAULTS: ReActRoleDefaults = {
maxIterations: 10,
};
export function createReActRole<T>(
options: ReActRoleRequired<T> & Partial<ReActRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const def = REACT_DEFAULTS;
const maxIt =
"maxIterations" in options && options.maxIterations !== undefined
? options.maxIterations
: def.maxIterations;
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const result = await reActIterativeChat({
provider: options.provider,
tools: options.tools,
messages,
maxIterations: maxIt,
});
if (!result.ok) {
throw new Error(`react: ${formatLlmError(result.error)}`);
}
const text = result.value;
const metaR = await llmExtract({
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
}
return { content: text, meta: metaR.value };
};
}
+8 -2
View File
@@ -1,7 +1,13 @@
import type { StartStep } from "@uncaged/nerve-core";
import type { z } from "zod";
import type { LlmProvider } from "./llm-extract.js";
import type { SpawnEnv } from "./spawn-safe.js";
import type { LlmProvider } from "./shared/llm-extract.js";
import type { SpawnEnv } from "./shared/spawn-safe.js";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartStep): boolean {
return start.meta.dryRun;
}
export type CliPromptFn = (threadId: string) => Promise<string>;
@@ -0,0 +1,6 @@
import type { LlmChatError } from "./llm-chat.js";
import type { LlmError } from "./llm-extract.js";
export function formatLlmError(e: LlmError | LlmChatError): string {
return JSON.stringify(e);
}
@@ -1,5 +1,6 @@
import { type Result, ok } from "@uncaged/nerve-core";
import type { HermesRoleDefaults, HermesRoleRequired } from "../role-types.js";
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
/**
@@ -64,3 +65,33 @@ export async function hermesAgent(
}
return ok(run.value.stdout);
}
// --- Hermes options resolution (absorbed from hermes-options.ts) ---
const HERMES_DEFAULTS: HermesRoleDefaults = {
model: null,
provider: null,
skills: [],
quiet: true,
maxTurns: 90,
env: {},
timeoutMs: 600_000,
};
export function resolveHermesOptions<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): HermesRoleDefaults {
const d = HERMES_DEFAULTS;
return {
model: "model" in options && options.model !== undefined ? options.model : d.model,
provider:
"provider" in options && options.provider !== undefined ? options.provider : d.provider,
skills: "skills" in options && options.skills !== undefined ? options.skills : d.skills,
quiet: "quiet" in options && options.quiet !== undefined ? options.quiet : d.quiet,
maxTurns:
"maxTurns" in options && options.maxTurns !== undefined ? options.maxTurns : d.maxTurns,
env: "env" in options && options.env !== undefined ? options.env : d.env,
timeoutMs:
"timeoutMs" in options && options.timeoutMs !== undefined ? options.timeoutMs : d.timeoutMs,
};
}
@@ -1,8 +1,8 @@
import { type Result, err, ok } from "@uncaged/nerve-core";
import { toJSONSchema } from "zod";
import type { LlmMessage, ReActTool } from "../role-types.js";
import type { LlmProvider } from "./llm-extract.js";
import type { LlmMessage, ReActTool } from "./role-types.js";
type OpenAiMessage =
| { role: "system" | "user" | "assistant"; content: string }
@@ -1,6 +0,0 @@
import type { StartStep } from "@uncaged/nerve-core";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartStep): boolean {
return start.meta.dryRun;
}