From 2529c6806287bb4f1d1827b46c74ce9735d8e843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 01:50:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(workflow-utils):=20role=20factory=20templa?= =?UTF-8?q?tes=20=E2=80=94=20createCursorRole,=20createHermesRole,=20creat?= =?UTF-8?q?eLlmRole,=20createReActRole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add role-types.ts with all shared types (CliPromptFn, LlmPromptFn, MetaExtractConfig, etc.) - Add role-factories.ts with 4 factory functions - Add llm-chat.ts with chatCompletionText and reActIterativeChat - Add hermes-agent.ts and hermes-options.ts for Hermes CLI integration - Add threadId to StartStep meta (core + daemon) - Add model param to cursorAgent options - Tests for all 4 factories Refs #208 --- packages/core/src/workflow.ts | 3 +- packages/daemon/src/workflow-manager.ts | 4 +- packages/daemon/src/workflow-worker.ts | 36 +-- .../src/__tests__/role-factories.test.ts | 202 +++++++++++++++ packages/workflow-utils/src/cursor-agent.ts | 3 +- packages/workflow-utils/src/hermes-agent.ts | 64 +++++ packages/workflow-utils/src/hermes-options.ts | 29 +++ packages/workflow-utils/src/index.ts | 43 ++-- packages/workflow-utils/src/llm-chat.ts | 233 ++++++++++++++++++ packages/workflow-utils/src/role-factories.ts | 223 +++++++++++++++++ packages/workflow-utils/src/role-types.ts | 67 +++++ 11 files changed, 868 insertions(+), 39 deletions(-) create mode 100644 packages/workflow-utils/src/__tests__/role-factories.test.ts create mode 100644 packages/workflow-utils/src/hermes-agent.ts create mode 100644 packages/workflow-utils/src/hermes-options.ts create mode 100644 packages/workflow-utils/src/llm-chat.ts create mode 100644 packages/workflow-utils/src/role-factories.ts create mode 100644 packages/workflow-utils/src/role-types.ts diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index ade0897..cf750fc 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -39,7 +39,8 @@ export type RoleMeta = Record>; 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; }; diff --git a/packages/daemon/src/workflow-manager.ts b/packages/daemon/src/workflow-manager.ts index 6e3d332..b76e090 100644 --- a/packages/daemon/src/workflow-manager.ts +++ b/packages/daemon/src/workflow-manager.ts @@ -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, diff --git a/packages/daemon/src/workflow-worker.ts b/packages/daemon/src/workflow-worker.ts index c6a949a..d040448 100644 --- a/packages/daemon/src/workflow-worker.ts +++ b/packages/daemon/src/workflow-worker.ts @@ -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, { diff --git a/packages/workflow-utils/src/__tests__/role-factories.test.ts b/packages/workflow-utils/src/__tests__/role-factories.test.ts new file mode 100644 index 0000000..fbfc704 --- /dev/null +++ b/packages/workflow-utils/src/__tests__/role-factories.test.ts @@ -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 }); + }); +}); diff --git a/packages/workflow-utils/src/cursor-agent.ts b/packages/workflow-utils/src/cursor-agent.ts index cc9d85b..36aee10 100644 --- a/packages/workflow-utils/src/cursor-agent.ts +++ b/packages/workflow-utils/src/cursor-agent.ts @@ -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", diff --git a/packages/workflow-utils/src/hermes-agent.ts b/packages/workflow-utils/src/hermes-agent.ts new file mode 100644 index 0000000..001a93c --- /dev/null +++ b/packages/workflow-utils/src/hermes-agent.ts @@ -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; + +function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean { + return "dryRun" in options ? options.dryRun : false; +} + +export async function hermesAgent( + options: HermesAgentOptionsInput, +): Promise> { + 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); +} diff --git a/packages/workflow-utils/src/hermes-options.ts b/packages/workflow-utils/src/hermes-options.ts new file mode 100644 index 0000000..44e788c --- /dev/null +++ b/packages/workflow-utils/src/hermes-options.ts @@ -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( + options: HermesRoleRequired & Partial, +): 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, + }; +} diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 1cbf361..45e1d2d 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -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"; diff --git a/packages/workflow-utils/src/llm-chat.ts b/packages/workflow-utils/src/llm-chat.ts new file mode 100644 index 0000000..e74017a --- /dev/null +++ b/packages/workflow-utils/src/llm-chat.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function fetchChatJson( + provider: LlmProvider, + body: Record, +): Promise> { + 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 { + 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> { + 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> { + const { provider, tools, maxIterations } = options; + + const toolDefs = tools.map((t) => { + const parameters = toJSONSchema(t.schema) as Record; + 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 = { + 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" }); +} diff --git a/packages/workflow-utils/src/role-factories.ts b/packages/workflow-utils/src/role-factories.ts new file mode 100644 index 0000000..707fddd --- /dev/null +++ b/packages/workflow-utils/src/role-factories.ts @@ -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 & Partial, + d: CursorRoleDefaults, +): CursorAgentMode { + if ("mode" in o && o.mode !== undefined) { + return o.mode; + } + return d.mode; +} + +function mergeCursorModel( + o: CursorRoleRequired & Partial, + d: CursorRoleDefaults, +): string { + if ("model" in o && o.model !== undefined) { + return o.model; + } + return d.model; +} + +function mergeCursorEnv( + o: CursorRoleRequired & Partial, + d: CursorRoleDefaults, +): SpawnEnv { + if ("env" in o && o.env !== undefined) { + return o.env; + } + return d.env; +} + +function mergeCursorTimeout( + o: CursorRoleRequired & Partial, + 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`. CLI agent returns + * a single string; structured meta is read via a cheap follow-up `llmExtract`. + */ +export function createCursorRole( + options: CursorRoleRequired & Partial, +): Role { + 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( + options: HermesRoleRequired & Partial, +): Role { + 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(options: LlmRoleRequired): Role { + 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( + options: ReActRoleRequired & Partial, +): Role { + 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 }; + }; +} diff --git a/packages/workflow-utils/src/role-types.ts b/packages/workflow-utils/src/role-types.ts new file mode 100644 index 0000000..f011582 --- /dev/null +++ b/packages/workflow-utils/src/role-types.ts @@ -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; + +export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; + +export type LlmPromptFn = (threadId: string) => Promise; + +export type MetaExtractConfig = { + provider: LlmProvider; + schema: z.ZodType; +}; + +export type ReActTool = { + name: string; + description: string; + schema: z.ZodType; + execute: (args: unknown) => Promise; +}; + +export type CursorRoleRequired = { + cwd: string; + prompt: CliPromptFn; + extract: MetaExtractConfig; +}; + +export type CursorRoleDefaults = { + mode: "plan" | "ask" | "default"; + model: string; + env: SpawnEnv; + timeoutMs: number; +}; + +export type HermesRoleRequired = { + prompt: CliPromptFn; + extract: MetaExtractConfig; +}; + +export type HermesRoleDefaults = { + model: string; + provider: string; + skills: string[]; + quiet: boolean; + maxTurns: number; + env: SpawnEnv; + timeoutMs: number; +}; + +export type LlmRoleRequired = { + provider: LlmProvider; + prompt: LlmPromptFn; + extract: MetaExtractConfig; +}; + +export type ReActRoleRequired = { + provider: LlmProvider; + tools: ReActTool[]; + prompt: LlmPromptFn; + extract: MetaExtractConfig; +}; + +export type ReActRoleDefaults = { + maxIterations: number; +}; -- 2.43.0