Merge pull request 'refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter' (#262) from feat/261-adapter-migration into main
This commit is contained in:
@@ -9,6 +9,7 @@ const agent = createCursorAgent({
|
||||
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||
model: "auto",
|
||||
timeout: 300_000,
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
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", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
...baseConfig,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
...baseConfig,
|
||||
command: "cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
@@ -25,28 +29,35 @@ describe("validateCursorAgentConfig", () => {
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
});
|
||||
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", () => {
|
||||
test("returns an AdapterFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
...baseConfig,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import { createLogger, type LogFn } from "@uncaged/workflow-util";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
@@ -33,25 +33,15 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
|
||||
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.",
|
||||
);
|
||||
}
|
||||
type CursorAgentOpt = { prompt: string; workspace: string };
|
||||
|
||||
function createCursorAgentFn(
|
||||
config: CursorAgentConfig,
|
||||
modelFlag: string,
|
||||
timeoutMs: number | null,
|
||||
logger: LogFn,
|
||||
): AgentFn<CursorAgentOpt> {
|
||||
return async (ctx, { prompt, workspace }) => {
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
@@ -75,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig) {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
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;
|
||||
model: string | null;
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
@@ -11,6 +11,8 @@ import { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
@@ -29,16 +31,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
throw new Error("hermes: unknown spawn error");
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
return async (ctx, { prompt }) => {
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
@@ -61,5 +57,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
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 { createTextAdapter } from "@uncaged/workflow-util-agent";
|
||||
import {
|
||||
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`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
|
||||
return parseAssistantText(res.value);
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
||||
type LlmAgentOpt = { prompt: string };
|
||||
|
||||
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
|
||||
return async (ctx, { prompt }) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||
}
|
||||
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 { 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({
|
||||
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
|
||||
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
|
||||
@@ -24,7 +15,6 @@ const adapter = createCursorAgent({
|
||||
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
|
||||
: 0,
|
||||
workspace: null,
|
||||
llmProvider,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
|
||||
|
||||
Reference in New Issue
Block a user