From 2a1b7b0aeba779122875b8d48eeda51e91499342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 14 May 2026 10:22:37 +0000 Subject: [PATCH] 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 --- develop-entry.ts | 1 + .../__tests__/cursor-agent.test.ts | 35 +++++++---- packages/workflow-agent-cursor/src/index.ts | 62 ++++++++++++------- packages/workflow-agent-cursor/src/types.ts | 5 ++ .../src/validate-config.ts | 3 + packages/workflow-agent-hermes/src/index.ts | 27 +++++--- .../src/create-llm-adapter.ts | 27 ++++++-- .../workflow-template-develop/bundle-entry.ts | 10 --- 8 files changed, 110 insertions(+), 60 deletions(-) diff --git a/develop-entry.ts b/develop-entry.ts index f5621a0..24debeb 100644 --- a/develop-entry.ts +++ b/develop-entry.ts @@ -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(); diff --git a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts index dd330eb..b4b1a0f 100644 --- a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts +++ b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts @@ -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"); diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index 18b3e45..b3a7fec 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -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 { + 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 }; + }, + ); } diff --git a/packages/workflow-agent-cursor/src/types.ts b/packages/workflow-agent-cursor/src/types.ts index 03d94e3..08069c4 100644 --- a/packages/workflow-agent-cursor/src/types.ts +++ b/packages/workflow-agent-cursor/src/types.ts @@ -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; }; diff --git a/packages/workflow-agent-cursor/src/validate-config.ts b/packages/workflow-agent-cursor/src/validate-config.ts index b0aed2b..9c25d5d 100644 --- a/packages/workflow-agent-cursor/src/validate-config.ts +++ b/packages/workflow-agent-cursor/src/validate-config.ts @@ -11,5 +11,8 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result { 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 }; }); } diff --git a/packages/workflow-agent-llm/src/create-llm-adapter.ts b/packages/workflow-agent-llm/src/create-llm-adapter.ts index 099c3bc..ebf2736 100644 --- a/packages/workflow-agent-llm/src/create-llm-adapter.ts +++ b/packages/workflow-agent-llm/src/create-llm-adapter.ts @@ -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 { + 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, + })); } diff --git a/packages/workflow-template-develop/bundle-entry.ts b/packages/workflow-template-develop/bundle-entry.ts index 5c76fd7..ab3231c 100644 --- a/packages/workflow-template-develop/bundle-entry.ts +++ b/packages/workflow-template-develop/bundle-entry.ts @@ -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 });