From c05fac746c5a183b803cd0d690d3be6c2e8af675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 11 May 2026 13:51:41 +0000 Subject: [PATCH 1/3] feat: cursor agent auto-extracts workspace from context via LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow-agent-cursor: workspace config now optional (string | null) - When null, uses LLM call to extract workspace path from AgentContext (previous steps' meta, start prompt) before spawning cursor-agent CLI - Requires llmProvider when workspace is null - develop bundle-entry: switched from hermes to cursor agent for all roles - solve-issue bundle-entry: created, developer role uses workflowAsAgent('develop'), preparer/submitter remain hermes 小橘 --- .../__tests__/cursor-agent.test.ts | 55 +++++++++++- packages/workflow-agent-cursor/package.json | 2 + .../src/extract-workspace.ts | 83 +++++++++++++++++++ packages/workflow-agent-cursor/src/index.ts | 18 +++- packages/workflow-agent-cursor/src/types.ts | 7 +- .../src/validate-config.ts | 9 +- .../workflow-template-develop/bundle-entry.ts | 25 +++--- .../bundle-entry.ts | 37 +++++++++ 8 files changed, 215 insertions(+), 21 deletions(-) create mode 100644 packages/workflow-agent-cursor/src/extract-workspace.ts create mode 100644 packages/workflow-template-solve-issue/bundle-entry.ts 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; From cf0540d7fa65630b38c0864d4a555ee563a5cc94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 11 May 2026 13:56:14 +0000 Subject: [PATCH 2/3] fix: stabilize kill-thread test by polling instead of fixed delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 50ms setTimeout with waitUntilPredicate polling for first role completion before issuing kill. Same pattern used by pause/resume test. 小橘 --- packages/cli-workflow/__tests__/thread-cli.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli-workflow/__tests__/thread-cli.test.ts b/packages/cli-workflow/__tests__/thread-cli.test.ts index 739efe1..28fb944 100644 --- a/packages/cli-workflow/__tests__/thread-cli.test.ts +++ b/packages/cli-workflow/__tests__/thread-cli.test.ts @@ -305,8 +305,13 @@ describe("cli thread commands", () => { } const threadId = ran.value.threadId; + const killBundleDir = getBundleDir(storageRoot, added.value.hash); - await new Promise((r) => setTimeout(r, 50)); + await waitUntilPredicate(async () => { + const idx = await readThreadsIndex(killBundleDir); + const ent = idx[threadId]; + return ent !== undefined && ent.head !== ent.start; + }, 80); const killed = await cmdKill(storageRoot, threadId); expect(killed.ok).toBe(true); From 30f158204678f55277d2897fec7aa010f7be63e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 11 May 2026 14:05:01 +0000 Subject: [PATCH 3/3] fix: address review feedback on cursor agent PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Replace manual OpenAI response parsing with createThreadReactor — workspace extraction now uses the same ReAct loop as extract/summarizer, with a zod schema and structured tool call 2. Remove non-null assertion on llmProvider, replaced with explicit guard 3. Add structured logging (LogFn) to extraction — failures, non-absolute paths, and successful extractions all logged with tag V3KM8QWP 4. export const run = wf is correct: createWorkflow returns WorkflowFn directly, old bundle-entry had stale .run access + unused 3rd arg 小橘 --- packages/workflow-agent-cursor/package.json | 1 + .../src/extract-workspace.ts | 94 +++++++++---------- packages/workflow-agent-cursor/src/index.ts | 8 +- 3 files changed, 50 insertions(+), 53 deletions(-) diff --git a/packages/workflow-agent-cursor/package.json b/packages/workflow-agent-cursor/package.json index 523fb14..0b3791a 100644 --- a/packages/workflow-agent-cursor/package.json +++ b/packages/workflow-agent-cursor/package.json @@ -11,6 +11,7 @@ "@uncaged/workflow-protocol": "workspace:*", "@uncaged/workflow-reactor": "workspace:*", "@uncaged/workflow-runtime": "workspace:*", + "@uncaged/workflow-util": "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 index 62030f5..a57ec2b 100644 --- a/packages/workflow-agent-cursor/src/extract-workspace.ts +++ b/packages/workflow-agent-cursor/src/extract-workspace.ts @@ -1,12 +1,14 @@ import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol"; -import { createLlmFn } from "@uncaged/workflow-reactor"; -import type { ChatMessage } from "@uncaged/workflow-reactor"; +import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor"; +import type { LogFn } from "@uncaged/workflow-util"; +import * as z from "zod/v4"; -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. +const workspaceSchema = z.object({ + workspace: z.string().describe("Absolute filesystem path of the project workspace"), +}); -Reply with ONLY the absolute path, nothing else. Example: /home/user/repos/my-project - -If you cannot determine the workspace path, reply with: UNKNOWN`; +const EXTRACT_SYSTEM_FN = (_toolName: string) => + `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. Call the tool with the absolute path.`; function buildExtractionInput(ctx: AgentContext): string { const lines: string[] = []; @@ -25,59 +27,47 @@ function buildExtractionInput(ctx: AgentContext): string { export async function extractWorkspacePath( ctx: AgentContext, provider: LlmProvider, + logger: LogFn, ): Promise { - const llm = createLlmFn(provider); - const messages: ChatMessage[] = [ - { role: "system", content: EXTRACT_SYSTEM }, - { role: "user", content: buildExtractionInput(ctx) }, - ]; + const reactor = createThreadReactor({ + llm: createLlmFn(provider), + maxRounds: 2, + staticTools: [], + structuredToolFromSchema: (schema) => { + const jsonSchema = z.toJSONSchema(schema); + return { + name: "set_workspace", + tool: { + type: "function" as const, + function: { + name: "set_workspace", + description: "Set the extracted workspace path", + parameters: jsonSchema as Record, + }, + }, + }; + }, + systemPromptForStructuredTool: EXTRACT_SYSTEM_FN, + toolHandler: async () => "unknown tool", + }); + + const result = await reactor({ + thread: null, + input: buildExtractionInput(ctx), + schema: workspaceSchema, + }); - const result = await llm({ messages, tools: [] }); if (!result.ok) { + logger("V3KM8QWP", `workspace extraction failed: ${result.error}`); return null; } - let parsed: unknown; - try { - parsed = JSON.parse(result.value) as unknown; - } catch { + const workspace = result.value.workspace.trim(); + if (!workspace.startsWith("/")) { + logger("V3KM8QWP", `workspace extraction returned non-absolute path: ${workspace}`); 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; + logger("V3KM8QWP", `extracted workspace: ${workspace}`); + return workspace; } diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index e02aed3..26f35ec 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -1,4 +1,5 @@ import type { AgentFn } from "@uncaged/workflow-runtime"; +import { createLogger } from "@uncaged/workflow-util"; import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent"; import { extractWorkspacePath } from "./extract-workspace.js"; @@ -36,6 +37,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { const modelFlag = resolveCursorModel(config.model); const timeoutMs = config.timeout > 0 ? config.timeout : null; + const logger = createLogger({ sink: { kind: "stderr" } }); return async (ctx) => { let workspace: string; @@ -43,7 +45,10 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { if (config.workspace !== null) { workspace = config.workspace; } else { - const extracted = await extractWorkspacePath(ctx, config.llmProvider!); + if (config.llmProvider === null) { + throw new Error("cursor-agent: llmProvider is required when workspace is null"); + } + const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger); 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.", @@ -52,6 +57,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { workspace = extracted; } + logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`); const fullPrompt = await buildAgentPrompt(ctx); const args = [ "-p",