refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter
- LLM: AgentFn<{prompt}> + createAgentAdapter, chatCompletionText unchanged
- Hermes: AgentFn<{prompt}> + createAgentAdapter, config validation in extract
- Cursor: AgentFn<{prompt, workspace}> + createAgentAdapter, workspace
extraction moved to extract fn, AgentFn itself only receives resolved options
All public API signatures preserved. createTextAdapter/TextProducerFn retained.
Closes #261, Phase 2 of #252
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user