Merge pull request 'refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter' (#262) from feat/261-adapter-migration into main

This commit is contained in:
2026-05-14 12:19:37 +00:00
8 changed files with 110 additions and 60 deletions
+1
View File
@@ -9,6 +9,7 @@ const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent", command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto", model: "auto",
timeout: 300_000, timeout: 300_000,
workspace: null,
}); });
export const descriptor = buildDevelopDescriptor(); export const descriptor = buildDevelopDescriptor();
@@ -1,21 +1,25 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js"; import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
const baseConfig = {
command: "/usr/local/bin/cursor-agent",
model: null as string | null,
timeout: 0,
workspace: null as string | null,
};
describe("validateCursorAgentConfig", () => { describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => { test("accepts valid config", () => {
const r = validateCursorAgentConfig({ const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent", ...baseConfig,
model: null,
timeout: 0,
}); });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
}); });
test("rejects non-absolute command", () => { test("rejects non-absolute command", () => {
const r = validateCursorAgentConfig({ const r = validateCursorAgentConfig({
...baseConfig,
command: "cursor-agent", command: "cursor-agent",
model: null,
timeout: 0,
}); });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
if (!r.ok) { if (!r.ok) {
@@ -25,28 +29,35 @@ describe("validateCursorAgentConfig", () => {
test("rejects negative timeout", () => { test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({ const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent", ...baseConfig,
model: null,
timeout: -1, timeout: -1,
}); });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
test("rejects non-absolute workspace when set", () => {
const r = validateCursorAgentConfig({
...baseConfig,
workspace: "relative/path",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workspace");
}
});
}); });
describe("createCursorAgent", () => { describe("createCursorAgent", () => {
test("returns an AdapterFn", () => { test("returns an AdapterFn", () => {
const agent = createCursorAgent({ const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent", ...baseConfig,
model: null,
timeout: 0,
}); });
expect(typeof agent).toBe("function"); expect(typeof agent).toBe("function");
}); });
test("defers validation to call time (invalid config does not throw at construction)", () => { test("defers validation to call time (invalid config does not throw at construction)", () => {
const agent = createCursorAgent({ const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent", ...baseConfig,
model: null,
timeout: -1, timeout: -1,
}); });
expect(typeof agent).toBe("function"); expect(typeof agent).toBe("function");
+40 -22
View File
@@ -1,8 +1,8 @@
import type { WorkflowRuntime } from "@uncaged/workflow-runtime"; import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util"; import { createLogger, type LogFn } from "@uncaged/workflow-util";
import { import {
buildThreadInput, buildThreadInput,
createTextAdapter, createAgentAdapter,
type SpawnCliError, type SpawnCliError,
spawnCli, spawnCli,
} from "@uncaged/workflow-util-agent"; } from "@uncaged/workflow-util-agent";
@@ -33,25 +33,15 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model; return model === null ? "auto" : model;
} }
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */ type CursorAgentOpt = { prompt: string; workspace: string };
export function createCursorAgent(config: CursorAgentConfig) {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const workspace = await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
);
}
function createCursorAgentFn(
config: CursorAgentConfig,
modelFlag: string,
timeoutMs: number | null,
logger: LogFn,
): AgentFn<CursorAgentOpt> {
return async (ctx, { prompt, workspace }) => {
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`); logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const threadInput = await buildThreadInput(ctx); const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`; const fullPrompt = `${prompt}\n\n${threadInput}`;
@@ -75,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig) {
throwCursorSpawnError(run.error); throwCursorSpawnError(run.error);
} }
return run.value; return run.value;
}); };
}
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createAgentAdapter(
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const workspace =
config.workspace !== null
? config.workspace
: await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
);
}
return { prompt, workspace };
},
);
} }
@@ -3,4 +3,9 @@ export type CursorAgentConfig = {
command: string; command: string;
model: string | null; model: string | null;
timeout: number; timeout: number;
/**
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
* from the thread via runtime extraction.
*/
workspace: string | null;
}; };
@@ -11,5 +11,8 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
if (config.timeout < 0) { if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit"); return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
} }
if (config.workspace !== null && !isAbsolute(config.workspace)) {
return err("workspace must be an absolute filesystem path when set");
}
return ok(undefined); return ok(undefined);
} }
+17 -10
View File
@@ -1,7 +1,7 @@
import type { AdapterFn } from "@uncaged/workflow-runtime"; import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
import { import {
buildThreadInput, buildThreadInput,
createTextAdapter, createAgentAdapter,
type SpawnCliError, type SpawnCliError,
spawnCli, spawnCli,
} from "@uncaged/workflow-util-agent"; } from "@uncaged/workflow-util-agent";
@@ -11,6 +11,8 @@ import { validateHermesAgentConfig } from "./validate-config.js";
const HERMES_DEFAULT_MAX_TURNS = 90; const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export type { HermesAgentConfig } from "./types.js"; export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js"; export { validateHermesAgentConfig } from "./validate-config.js";
@@ -29,16 +31,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
throw new Error("hermes: unknown spawn error"); throw new Error("hermes: unknown spawn error");
} }
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */ function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
const timeoutMs = config.timeout; const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt, _runtime) => { return async (ctx, { prompt }) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const threadInput = await buildThreadInput(ctx); const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`; const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [ const args = [
@@ -61,5 +57,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
throwHermesSpawnError(run.error); throwHermesSpawnError(run.error);
} }
return run.value; return run.value;
};
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return { prompt };
}); });
} }
@@ -1,5 +1,12 @@
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime"; import {
import { createTextAdapter } from "@uncaged/workflow-util-agent"; type AdapterFn,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */ /** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value); return parseAssistantText(res.value);
} }
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */ type LlmAgentOpt = { prompt: string };
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt, _runtime) => { function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
return async (ctx, { prompt }) => {
const result = await chatCompletionText({ const result = await chatCompletionText({
provider, provider,
messages: [ messages: [
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
throw new Error(`llm: ${formatLlmChatError(result.error)}`); throw new Error(`llm: ${formatLlmChatError(result.error)}`);
} }
return result.value; return result.value;
}); };
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
prompt,
}));
} }
@@ -8,15 +8,6 @@ import { createWorkflow } from "@uncaged/workflow-runtime";
import { optionalEnv, requireEnv } from "@uncaged/workflow-util"; import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js"; import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
const llmProvider = {
baseUrl: optionalEnv(
"WORKFLOW_LLM_BASE_URL",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
};
const adapter = createCursorAgent({ const adapter = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"), command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
model: optionalEnv("WORKFLOW_CURSOR_MODEL"), model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
@@ -24,7 +15,6 @@ const adapter = createCursorAgent({
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT")) ? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
: 0, : 0,
workspace: null, workspace: null,
llmProvider,
}); });
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null }); const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });