feat(workflow-utils): role factory templates #208 #209

Merged
xiaomo merged 1 commits from feat/208-role-factories into main 2026-04-28 01:55:14 +00:00
11 changed files with 868 additions and 39 deletions
+2 -1
View File
@@ -39,7 +39,8 @@ export type RoleMeta = Record<string, Record<string, unknown>>;
export type StartStep = {
role: START;
content: string;
meta: { maxRounds: number; dryRun: boolean };
/** Thread identity (same as workflow `runId`); for role prompts and CLI `nerve thread` context. */
meta: { maxRounds: number; dryRun: boolean; threadId: string };
timestamp: number;
};
+3 -1
View File
@@ -124,6 +124,7 @@ function readLaunchFromTriggerPayload(
function ensureThreadMessagesWithStart(
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
threadId: string,
fallbackPrompt: string,
fallbackMaxRounds: number,
fallbackDryRun: boolean,
@@ -140,7 +141,7 @@ function ensureThreadMessagesWithStart(
const start: WorkflowMessage = {
role: START,
content: fallbackPrompt,
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun },
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun, threadId },
timestamp: Date.now(),
};
return [start, ...mapped];
@@ -404,6 +405,7 @@ export function createWorkflowManager(
);
const messages = ensureThreadMessagesWithStart(
rawMessages,
runId,
launch.prompt,
launch.maxRounds,
launch.dryRun,
+21 -15
View File
@@ -81,31 +81,37 @@ function validateRoleResult(
return true;
}
function isStartMeta(meta: unknown): meta is StartStep["meta"] {
return (
isPlainRecord(meta) && typeof meta.maxRounds === "number" && typeof meta.dryRun === "boolean"
);
}
function normalizeStartMeta(meta: unknown, maxRoundsFallback: number): StartStep["meta"] {
function normalizeStartMeta(
meta: unknown,
maxRoundsFallback: number,
threadIdFallback: string,
): StartStep["meta"] {
if (!isPlainRecord(meta)) {
return { maxRounds: maxRoundsFallback, dryRun: false };
return { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback };
}
const maxRounds = typeof meta.maxRounds === "number" ? meta.maxRounds : maxRoundsFallback;
const dryRun = typeof meta.dryRun === "boolean" ? meta.dryRun : false;
return { maxRounds, dryRun };
const threadId =
typeof meta.threadId === "string" && meta.threadId.length > 0
? meta.threadId
: threadIdFallback;
return { maxRounds, dryRun, threadId };
}
function startStepFromWorkflowMessage(msg: WorkflowMessage, maxRoundsFallback: number): StartStep {
function startStepFromWorkflowMessage(
msg: WorkflowMessage,
maxRoundsFallback: number,
threadIdFallback: string,
): StartStep {
if (msg.role !== START) {
return {
role: START,
content: "",
meta: { maxRounds: maxRoundsFallback, dryRun: false },
meta: { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback },
timestamp: Date.now(),
};
}
const meta = isStartMeta(msg.meta) ? msg.meta : normalizeStartMeta(msg.meta, maxRoundsFallback);
const meta = normalizeStartMeta(msg.meta, maxRoundsFallback, threadIdFallback);
return {
role: START,
content: msg.content,
@@ -131,7 +137,7 @@ function initThreadMessages(
const [first, ...rest] = resumeMessages;
if (first.role === START) {
return {
start: startStepFromWorkflowMessage(first, maxRounds),
start: startStepFromWorkflowMessage(first, maxRounds, runId),
messages: [...rest],
};
}
@@ -140,7 +146,7 @@ function initThreadMessages(
start: {
role: START,
content: prompt,
meta: { maxRounds, dryRun },
meta: { maxRounds, dryRun, threadId: runId },
timestamp: Date.now(),
},
messages: [...resumeMessages],
@@ -150,7 +156,7 @@ function initThreadMessages(
const start: StartStep = {
role: START,
content: prompt,
meta: { maxRounds, dryRun },
meta: { maxRounds, dryRun, threadId: runId },
timestamp: Date.now(),
};
sendWorkflowMessage(runId, {
@@ -0,0 +1,202 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { START } from "@uncaged/nerve-core";
import {
createCursorRole,
createHermesRole,
createLlmRole,
createReActRole,
} from "../role-factories.js";
function startFrame(dryRun: boolean, threadId: string) {
return {
role: START,
content: "user prompt",
meta: { maxRounds: 10, dryRun, threadId },
timestamp: 1,
} as const;
}
describe("createCursorRole", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("uses dry run for agent + extract and returns schema default meta", async () => {
const schema = z.object({ done: z.boolean() });
const role = createCursorRole({
cwd: process.cwd(),
prompt: async (tid) => {
expect(tid).toBe("run-1");
return "task";
},
extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema },
});
const out = await role(startFrame(true, "run-1"), []);
expect(out.content).toContain("dryRun");
expect(out.meta).toEqual({ done: false });
});
});
describe("createHermesRole", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("uses dry run stub and extract defaults", async () => {
const schema = z.object({ done: z.boolean() });
const role = createHermesRole({
prompt: async (tid) => {
expect(tid).toBe("h1");
return "hermes task";
},
extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema },
});
const out = await role(startFrame(true, "h1"), []);
expect(out.content).toContain("hermes");
expect(out.meta).toEqual({ done: false });
});
});
describe("createLlmRole", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("chat then extract (mocked fetch)", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
choices: [{ message: { content: "hello from model" } }],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{ function: { name: "extract", arguments: JSON.stringify({ n: 7 }) } },
],
},
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
const schema = z.object({ n: z.number() });
const role = createLlmRole({
provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" },
prompt: async (tid) => {
expect(tid).toBe("llm1");
return [{ role: "user" as const, content: "hi" }];
},
extract: { provider: { baseUrl: "https://ext", apiKey: "k2", model: "small" }, schema },
});
const out = await role(startFrame(false, "llm1"), []);
expect(out.content).toBe("hello from model");
expect(out.meta).toEqual({ n: 7 });
});
});
describe("createReActRole", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("iterates tool calls then final text, then extract", async () => {
const toolSchema = z.object({ q: z.string() });
const tool = {
name: "search",
description: "search",
schema: toolSchema,
execute: async (args: unknown) => {
expect(args).toEqual({ q: "x" });
return "result";
},
};
const fetchMock = vi.fn();
// First: tool call
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
choices: [
{
message: {
content: null,
tool_calls: [
{
id: "t1",
type: "function",
function: { name: "search", arguments: JSON.stringify({ q: "x" }) },
},
],
},
},
],
}),
});
// Second: final text
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
choices: [{ message: { content: "final answer" } }],
}),
});
// Third: llmExtract
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{ function: { name: "extract", arguments: JSON.stringify({ done: true }) } },
],
},
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
const extractSchema = z.object({ done: z.boolean() });
const role = createReActRole({
provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" },
tools: [tool],
prompt: async (tid) => {
expect(tid).toBe("r1");
return [{ role: "user" as const, content: "go" }];
},
extract: {
provider: { baseUrl: "https://ext", apiKey: "k2", model: "small" },
schema: extractSchema,
},
maxIterations: 5,
});
const out = await role(startFrame(false, "r1"), []);
expect(out.content).toBe("final answer");
expect(out.meta).toEqual({ done: true });
});
});
+2 -1
View File
@@ -7,6 +7,7 @@ export type CursorAgentMode = "plan" | "ask" | "default";
export type CursorAgentOptions = {
prompt: string;
mode: CursorAgentMode;
model: string;
cwd: string;
env: SpawnEnv | null;
timeoutMs: number | null;
@@ -34,7 +35,7 @@ export async function cursorAgent(
"-p",
options.prompt,
"--model",
"auto",
options.model,
"--output-format",
"text",
"--trust",
@@ -0,0 +1,64 @@
import { type Result, ok } from "@uncaged/nerve-core";
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
/**
* Spawns a non-interactive `hermes` run with YOLO enabled, argv-only
* (shell: false) following the Nerve issue #208 contract.
* Adjust argv here if the upstream CLI surface changes.
*/
export type HermesAgentOptions = {
prompt: string;
model: string;
provider: string;
skills: string[];
/** When true, suppresses interactive UI noise. */
quiet: boolean;
maxTurns: number;
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
};
type HermesAgentOptionsInput = HermesAgentOptions | Omit<HermesAgentOptions, "dryRun">;
function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
export async function hermesAgent(
options: HermesAgentOptionsInput,
): Promise<Result<string, SpawnError>> {
const dryRun = resolveHermesDryRun(options);
if (dryRun) {
return ok("[dryRun] hermes stub");
}
const args: string[] = [
"run",
"-p",
options.prompt,
"--yolo",
"--model",
options.model,
"--provider",
options.provider,
"--max-turns",
String(options.maxTurns),
];
for (const s of options.skills) {
args.push("--skill", s);
}
if (options.quiet) {
args.push("--quiet");
}
const run = await spawnSafe("hermes", args, {
cwd: null,
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
});
if (!run.ok) {
return run;
}
return ok(run.value.stdout);
}
@@ -0,0 +1,29 @@
import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js";
const HERMES_DEFAULTS: HermesRoleDefaults = {
model: "auto",
provider: "auto",
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,
};
}
+22 -21
View File
@@ -1,23 +1,24 @@
export { cursorAgent, type CursorAgentMode, type CursorAgentOptions } from "./cursor-agent.js";
// Primary API — role factory templates
export {
nerveAgentContext,
readNerveYaml,
type NerveYamlError,
type ReadNerveYamlOptions,
} from "./context.js";
export {
llmExtract,
type LlmError,
type LlmExtractOptions,
type LlmProvider,
} from "./llm-extract.js";
export { schemaDefaults } from "./schema-defaults.js";
export {
nerveCommandEnv,
spawnSafe,
type SpawnEnv,
type SpawnError,
type SpawnResult,
type SpawnSafeOptions,
} from "./spawn-safe.js";
createCursorRole,
createHermesRole,
createLlmRole,
createReActRole,
} from "./role-factories.js";
export { isDryRun } from "./start-step.js";
export type { LlmError, LlmProvider } from "./llm-extract.js";
export type {
CliPromptFn,
CursorRoleDefaults,
CursorRoleRequired,
HermesRoleDefaults,
HermesRoleRequired,
LlmMessage,
LlmPromptFn,
LlmRoleRequired,
MetaExtractConfig,
ReActRoleDefaults,
ReActRoleRequired,
ReActTool,
} from "./role-types.js";
export type { LlmChatError } from "./llm-chat.js";
+233
View File
@@ -0,0 +1,233 @@
import { type Result, err, ok } from "@uncaged/nerve-core";
import { toJSONSchema } from "zod";
import type { LlmProvider } from "./llm-extract.js";
import type { LlmMessage, ReActTool } from "./role-types.js";
type OpenAiMessage =
| { role: "system" | "user" | "assistant"; content: string }
| { role: "tool"; content: string; tool_call_id: string }
| {
role: "assistant";
content: null;
tool_calls: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
};
function chatUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
export type LlmChatError =
| { kind: "http_error"; status: number; body: string }
| { kind: "invalid_response_json"; message: string }
| { kind: "network_error"; message: string }
| { kind: "empty_choices" }
| { kind: "no_assistant_text" }
| { kind: "exhausted_iterations" };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function fetchChatJson(
provider: LlmProvider,
body: Record<string, unknown>,
): Promise<Result<unknown, LlmChatError>> {
let response: Response;
try {
response = await fetch(chatUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err({ kind: "network_error", message });
}
const responseText = await response.text();
if (!response.ok) {
return err({ kind: "http_error", status: response.status, body: responseText.slice(0, 4000) });
}
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err({ kind: "invalid_response_json", message });
}
return ok(parsed);
}
type ToolCall = {
id: string;
type: "function";
function: { name: string; arguments: string };
};
type AssistantParse =
| { kind: "text"; text: string }
| {
kind: "tool";
toolCalls: ToolCall[];
};
function parseOpenAiToolCalls(toolCallsRaw: unknown): ToolCall[] {
if (!Array.isArray(toolCallsRaw) || toolCallsRaw.length === 0) {
return [];
}
const toolCalls: ToolCall[] = [];
for (const t of toolCallsRaw) {
if (
isRecord(t) &&
isRecord(t.function) &&
typeof t.id === "string" &&
typeof t.function.name === "string" &&
typeof t.function.arguments === "string"
) {
toolCalls.push({
id: t.id,
type: "function" as const,
function: { name: t.function.name, arguments: t.function.arguments },
});
}
}
return toolCalls;
}
function parseAssistantMessage(parsed: unknown): Result<AssistantParse, LlmChatError> {
if (!isRecord(parsed)) {
return err({ kind: "invalid_response_json", message: "Not an object" });
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
return err({ kind: "empty_choices" });
}
const c0 = choices[0];
if (!isRecord(c0)) {
return err({ kind: "empty_choices" });
}
const messageObj = c0.message;
if (!isRecord(messageObj)) {
return err({ kind: "no_assistant_text" });
}
const fromTools = parseOpenAiToolCalls(messageObj.tool_calls);
if (fromTools.length > 0) {
return ok({ kind: "tool", toolCalls: fromTools });
}
const content = messageObj.content;
if (typeof content === "string") {
return ok({ kind: "text", text: content });
}
return err({ kind: "no_assistant_text" });
}
export async function chatCompletionText(options: {
provider: LlmProvider;
messages: LlmMessage[];
}): Promise<Result<string, LlmChatError>> {
const body = { model: options.provider.model, messages: options.messages };
const res = await fetchChatJson(options.provider, body);
if (!res.ok) {
return res;
}
const a = parseAssistantMessage(res.value);
if (!a.ok) {
return a;
}
if (a.value.kind !== "text") {
return err({ kind: "no_assistant_text" });
}
return ok(a.value.text);
}
export async function reActIterativeChat(options: {
provider: LlmProvider;
tools: ReActTool[];
messages: LlmMessage[];
maxIterations: number;
}): Promise<Result<string, LlmChatError>> {
const { provider, tools, maxIterations } = options;
const toolDefs = tools.map((t) => {
const parameters = toJSONSchema(t.schema) as Record<string, unknown>;
return {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters,
},
};
});
const conv: OpenAiMessage[] = options.messages.map((m) => ({
role: m.role,
content: m.content,
}));
let iter = 0;
while (iter < maxIterations) {
iter += 1;
const body: Record<string, unknown> = {
model: provider.model,
messages: conv,
tools: toolDefs,
};
const res = await fetchChatJson(provider, body);
if (!res.ok) {
return res;
}
const a = parseAssistantMessage(res.value);
if (!a.ok) {
return a;
}
if (a.value.kind === "text") {
return ok(a.value.text);
}
if (a.value.toolCalls.length === 0) {
return err({ kind: "no_assistant_text" });
}
const calls = a.value.toolCalls;
conv.push({ role: "assistant", content: null, tool_calls: calls });
const toolOutputs: OpenAiMessage[] = await Promise.all(
calls.map(async (call) => {
const name = call.function.name;
const found = tools.find((t) => t.name === name);
if (found === undefined) {
return { role: "tool" as const, tool_call_id: call.id, content: `Unknown tool: ${name}` };
}
let argsParsed: unknown;
try {
argsParsed = JSON.parse(call.function.arguments) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return {
role: "tool" as const,
tool_call_id: call.id,
content: `Invalid tool JSON: ${message}`,
};
}
const valid = found.schema.safeParse(argsParsed);
if (!valid.success) {
return {
role: "tool" as const,
tool_call_id: call.id,
content: `Invalid arguments: ${valid.error.message}`,
};
}
const out = await found.execute(valid.data);
return { role: "tool" as const, content: out, tool_call_id: call.id };
}),
);
conv.push(...toolOutputs);
}
return err({ kind: "exhausted_iterations" });
}
@@ -0,0 +1,223 @@
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 };
};
}
+67
View File
@@ -0,0 +1,67 @@
import type { z } from "zod";
import type { LlmProvider } from "./llm-extract.js";
import type { SpawnEnv } from "./spawn-safe.js";
export type CliPromptFn = (threadId: string) => Promise<string>;
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
export type LlmPromptFn = (threadId: string) => Promise<LlmMessage[]>;
export type MetaExtractConfig<T> = {
provider: LlmProvider;
schema: z.ZodType<T>;
};
export type ReActTool = {
name: string;
description: string;
schema: z.ZodType<unknown>;
execute: (args: unknown) => Promise<string>;
};
export type CursorRoleRequired<T> = {
cwd: string;
prompt: CliPromptFn;
extract: MetaExtractConfig<T>;
};
export type CursorRoleDefaults = {
mode: "plan" | "ask" | "default";
model: string;
env: SpawnEnv;
timeoutMs: number;
};
export type HermesRoleRequired<T> = {
prompt: CliPromptFn;
extract: MetaExtractConfig<T>;
};
export type HermesRoleDefaults = {
model: string;
provider: string;
skills: string[];
quiet: boolean;
maxTurns: number;
env: SpawnEnv;
timeoutMs: number;
};
export type LlmRoleRequired<T> = {
provider: LlmProvider;
prompt: LlmPromptFn;
extract: MetaExtractConfig<T>;
};
export type ReActRoleRequired<T> = {
provider: LlmProvider;
tools: ReActTool[];
prompt: LlmPromptFn;
extract: MetaExtractConfig<T>;
};
export type ReActRoleDefaults = {
maxIterations: number;
};