diff --git a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts index 9df5e7a..b08301c 100644 --- a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts +++ b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts @@ -2,20 +2,32 @@ import { describe, expect, test } from "bun:test"; import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js"; describe("validateCursorAgentConfig", () => { - test("accepts valid config", () => { + test("accepts valid config with explicit workspace", () => { const r = validateCursorAgentConfig({ model: null, timeout: 0, workspace: "/tmp/test-project", + llmProvider: null, }); expect(r.ok).toBe(true); }); - test("rejects non-function extract", () => { + test("accepts valid config with null workspace and llmProvider", () => { + const r = validateCursorAgentConfig({ + model: null, + timeout: 0, + workspace: null, + llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" }, + }); + expect(r.ok).toBe(true); + }); + + test("rejects empty workspace string", () => { const r = validateCursorAgentConfig({ model: null, timeout: 0, workspace: "", + llmProvider: null, }); expect(r.ok).toBe(false); if (!r.ok) { @@ -23,22 +35,47 @@ describe("validateCursorAgentConfig", () => { } }); + test("rejects null workspace without llmProvider", () => { + const r = validateCursorAgentConfig({ + model: null, + timeout: 0, + workspace: null, + llmProvider: null, + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain("llmProvider"); + } + }); + test("rejects negative timeout", () => { const r = validateCursorAgentConfig({ model: null, timeout: -1, workspace: "/tmp/test-project", + llmProvider: null, }); expect(r.ok).toBe(false); }); }); describe("createCursorAgent", () => { - test("returns an AgentFn", () => { + test("returns an AgentFn with explicit workspace", () => { const agent = createCursorAgent({ model: null, timeout: 0, workspace: "/tmp/test-project", + llmProvider: null, + }); + expect(typeof agent).toBe("function"); + }); + + test("returns an AgentFn with null workspace and llmProvider", () => { + const agent = createCursorAgent({ + model: null, + timeout: 0, + workspace: null, + llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" }, }); expect(typeof agent).toBe("function"); }); @@ -49,6 +86,18 @@ describe("createCursorAgent", () => { model: null, timeout: -1, workspace: "/tmp/test-project", + llmProvider: null, + }), + ).toThrow(); + }); + + test("throws when null workspace without llmProvider", () => { + expect(() => + createCursorAgent({ + model: null, + timeout: 0, + workspace: null, + llmProvider: null, }), ).toThrow(); }); diff --git a/packages/workflow-agent-cursor/package.json b/packages/workflow-agent-cursor/package.json index 2008bca..523fb14 100644 --- a/packages/workflow-agent-cursor/package.json +++ b/packages/workflow-agent-cursor/package.json @@ -8,6 +8,8 @@ "test": "bun test" }, "dependencies": { + "@uncaged/workflow-protocol": "workspace:*", + "@uncaged/workflow-reactor": "workspace:*", "@uncaged/workflow-runtime": "workspace:*", "@uncaged/workflow-util-agent": "workspace:*", "zod": "^4.0.0" diff --git a/packages/workflow-agent-cursor/src/extract-workspace.ts b/packages/workflow-agent-cursor/src/extract-workspace.ts new file mode 100644 index 0000000..62030f5 --- /dev/null +++ b/packages/workflow-agent-cursor/src/extract-workspace.ts @@ -0,0 +1,83 @@ +import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol"; +import { createLlmFn } from "@uncaged/workflow-reactor"; +import type { ChatMessage } from "@uncaged/workflow-reactor"; + +const EXTRACT_SYSTEM = `You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. + +Reply with ONLY the absolute path, nothing else. Example: /home/user/repos/my-project + +If you cannot determine the workspace path, reply with: UNKNOWN`; + +function buildExtractionInput(ctx: AgentContext): string { + const lines: string[] = []; + lines.push("## Task"); + lines.push(ctx.start.content); + + for (const step of ctx.steps) { + lines.push(""); + lines.push(`## Step: ${step.role}`); + lines.push(`Meta: ${JSON.stringify(step.meta)}`); + } + + return lines.join("\n"); +} + +export async function extractWorkspacePath( + ctx: AgentContext, + provider: LlmProvider, +): Promise { + const llm = createLlmFn(provider); + const messages: ChatMessage[] = [ + { role: "system", content: EXTRACT_SYSTEM }, + { role: "user", content: buildExtractionInput(ctx) }, + ]; + + const result = await llm({ messages, tools: [] }); + if (!result.ok) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(result.value) as unknown; + } catch { + return null; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("choices" in parsed) || + !Array.isArray((parsed as Record).choices) + ) { + return null; + } + + const choices = (parsed as Record).choices as unknown[]; + if (choices.length === 0) { + return null; + } + + const first = choices[0]; + if ( + typeof first !== "object" || + first === null || + !("message" in first) || + typeof (first as Record).message !== "object" + ) { + return null; + } + + const message = (first as Record).message as Record; + const content = message.content; + if (typeof content !== "string") { + return null; + } + + const trimmed = content.trim(); + if (trimmed === "UNKNOWN" || !trimmed.startsWith("/")) { + return null; + } + + return trimmed; +} diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index 7f783eb..e02aed3 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -1,6 +1,7 @@ import type { AgentFn } from "@uncaged/workflow-runtime"; import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent"; +import { extractWorkspacePath } from "./extract-workspace.js"; import type { CursorAgentConfig } from "./types.js"; import { validateCursorAgentConfig } from "./validate-config.js"; @@ -26,7 +27,7 @@ function resolveCursorModel(model: string | null): string { return model === null ? "auto" : model; } -/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */ +/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */ export function createCursorAgent(config: CursorAgentConfig): AgentFn { const validated = validateCursorAgentConfig(config); if (!validated.ok) { @@ -37,7 +38,20 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { const timeoutMs = config.timeout > 0 ? config.timeout : null; return async (ctx) => { - const workspace = config.workspace; + let workspace: string; + + if (config.workspace !== null) { + workspace = config.workspace; + } else { + const extracted = await extractWorkspacePath(ctx, config.llmProvider!); + if (extracted === null) { + throw new Error( + "cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.", + ); + } + workspace = extracted; + } + const fullPrompt = await buildAgentPrompt(ctx); const args = [ "-p", diff --git a/packages/workflow-agent-cursor/src/types.ts b/packages/workflow-agent-cursor/src/types.ts index 0470054..00aae10 100644 --- a/packages/workflow-agent-cursor/src/types.ts +++ b/packages/workflow-agent-cursor/src/types.ts @@ -1,5 +1,10 @@ +import type { LlmProvider } from "@uncaged/workflow-protocol"; + export type CursorAgentConfig = { model: string | null; timeout: number; - workspace: string; + /** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */ + workspace: string | null; + /** Required when `workspace` is `null` — LLM provider used for workspace extraction. */ + llmProvider: LlmProvider | null; }; diff --git a/packages/workflow-agent-cursor/src/validate-config.ts b/packages/workflow-agent-cursor/src/validate-config.ts index a6b9cb3..05e17df 100644 --- a/packages/workflow-agent-cursor/src/validate-config.ts +++ b/packages/workflow-agent-cursor/src/validate-config.ts @@ -1,10 +1,13 @@ -import { err, ok, type Result } from "@uncaged/workflow-runtime"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import type { CursorAgentConfig } from "./types.js"; export function validateCursorAgentConfig(config: CursorAgentConfig): Result { - if (typeof config.workspace !== "string" || config.workspace.length === 0) { - return err("workspace must be a non-empty string (absolute path)"); + if (config.workspace !== null && config.workspace.length === 0) { + return err("workspace must be a non-empty string (absolute path) or null for auto-detection"); + } + if (config.workspace === null && config.llmProvider === null) { + return err("llmProvider is required when workspace is null (needed for workspace extraction)"); } if (config.timeout < 0) { return err("timeout must be a non-negative number (milliseconds); use 0 for no limit"); diff --git a/packages/workflow-template-develop/bundle-entry.ts b/packages/workflow-template-develop/bundle-entry.ts index 905c1d7..78358c9 100644 --- a/packages/workflow-template-develop/bundle-entry.ts +++ b/packages/workflow-template-develop/bundle-entry.ts @@ -1,8 +1,9 @@ /** * develop bundle entry — 小橘 🍊 + * + * All roles use cursor-agent with workspace auto-extracted from context. */ -import { createHermesAgent } from "@uncaged/workflow-agent-hermes"; -import { createExtract } from "@uncaged/workflow-execute"; +import { createCursorAgent } from "@uncaged/workflow-agent-cursor"; import { createWorkflow } from "@uncaged/workflow-runtime"; import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js"; @@ -22,23 +23,23 @@ function optionalEnv(name: string): string | null { return value; } -const provider = { +const llmProvider = { baseUrl: optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1", apiKey: requireEnv("WORKFLOW_LLM_API_KEY"), model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus", }; -const agent = createHermesAgent({ - model: optionalEnv("WORKFLOW_HERMES_MODEL"), - timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT") - ? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT")) - : null, +const agent = createCursorAgent({ + model: optionalEnv("WORKFLOW_CURSOR_MODEL"), + timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT") + ? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT")) + : 0, + workspace: null, + llmProvider, }); -const extract = createExtract(provider); - -const wf = createWorkflow(developWorkflowDefinition, { agent }, extract); +const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null }); export const descriptor = buildDevelopDescriptor(); -export const run = wf.run; +export const run = wf; diff --git a/packages/workflow-template-solve-issue/bundle-entry.ts b/packages/workflow-template-solve-issue/bundle-entry.ts new file mode 100644 index 0000000..09b3dd9 --- /dev/null +++ b/packages/workflow-template-solve-issue/bundle-entry.ts @@ -0,0 +1,37 @@ +/** + * solve-issue bundle entry — 小橘 🍊 + * + * preparer + submitter → hermes agent + * developer → workflow-as-agent (delegates to "develop" workflow) + */ +import { createHermesAgent } from "@uncaged/workflow-agent-hermes"; +import { workflowAsAgent } from "@uncaged/workflow-execute"; +import { createWorkflow } from "@uncaged/workflow-runtime"; +import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js"; + +function optionalEnv(name: string): string | null { + const value = process.env[name]; + if (value === undefined || value === "") { + return null; + } + return value; +} + +const hermesAgent = createHermesAgent({ + model: optionalEnv("WORKFLOW_HERMES_MODEL"), + timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT") + ? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT")) + : null, +}); + +const developerAgent = workflowAsAgent("develop"); + +const wf = createWorkflow(solveIssueWorkflowDefinition, { + agent: hermesAgent, + overrides: { + developer: developerAgent, + }, +}); + +export const descriptor = buildSolveIssueDescriptor(); +export const run = wf;