feat(workflow-utils): role factory templates #208 #209
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user