feat: cursor agent auto-extracts workspace from context via LLM

- 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

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-11 13:51:41 +00:00
parent 34efd25e91
commit c05fac746c
8 changed files with 215 additions and 21 deletions
@@ -2,20 +2,32 @@ import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js"; import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => { describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => { test("accepts valid config with explicit workspace", () => {
const r = validateCursorAgentConfig({ const r = validateCursorAgentConfig({
model: null, model: null,
timeout: 0, timeout: 0,
workspace: "/tmp/test-project", workspace: "/tmp/test-project",
llmProvider: null,
}); });
expect(r.ok).toBe(true); 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({ const r = validateCursorAgentConfig({
model: null, model: null,
timeout: 0, timeout: 0,
workspace: "", workspace: "",
llmProvider: null,
}); });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
if (!r.ok) { 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", () => { test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({ const r = validateCursorAgentConfig({
model: null, model: null,
timeout: -1, timeout: -1,
workspace: "/tmp/test-project", workspace: "/tmp/test-project",
llmProvider: null,
}); });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
}); });
describe("createCursorAgent", () => { describe("createCursorAgent", () => {
test("returns an AgentFn", () => { test("returns an AgentFn with explicit workspace", () => {
const agent = createCursorAgent({ const agent = createCursorAgent({
model: null, model: null,
timeout: 0, timeout: 0,
workspace: "/tmp/test-project", 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"); expect(typeof agent).toBe("function");
}); });
@@ -49,6 +86,18 @@ describe("createCursorAgent", () => {
model: null, model: null,
timeout: -1, timeout: -1,
workspace: "/tmp/test-project", 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(); ).toThrow();
}); });
@@ -8,6 +8,8 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*", "@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*", "@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0" "zod": "^4.0.0"
@@ -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<string | null> {
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<string, unknown>).choices)
) {
return null;
}
const choices = (parsed as Record<string, unknown>).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<string, unknown>).message !== "object"
) {
return null;
}
const message = (first as Record<string, unknown>).message as Record<string, unknown>;
const content = message.content;
if (typeof content !== "string") {
return null;
}
const trimmed = content.trim();
if (trimmed === "UNKNOWN" || !trimmed.startsWith("/")) {
return null;
}
return trimmed;
}
+16 -2
View File
@@ -1,6 +1,7 @@
import type { AgentFn } from "@uncaged/workflow-runtime"; import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent"; import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js"; import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js"; import { validateCursorAgentConfig } from "./validate-config.js";
@@ -26,7 +27,7 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model; 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 { export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const validated = validateCursorAgentConfig(config); const validated = validateCursorAgentConfig(config);
if (!validated.ok) { if (!validated.ok) {
@@ -37,7 +38,20 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const timeoutMs = config.timeout > 0 ? config.timeout : null; const timeoutMs = config.timeout > 0 ? config.timeout : null;
return async (ctx) => { 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 fullPrompt = await buildAgentPrompt(ctx);
const args = [ const args = [
"-p", "-p",
+6 -1
View File
@@ -1,5 +1,10 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = { export type CursorAgentConfig = {
model: string | null; model: string | null;
timeout: number; 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;
}; };
@@ -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"; import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> { export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
if (typeof config.workspace !== "string" || config.workspace.length === 0) { if (config.workspace !== null && config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path)"); 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) { 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");
@@ -1,8 +1,9 @@
/** /**
* develop bundle entry — 小橘 🍊 * develop bundle entry — 小橘 🍊
*
* All roles use cursor-agent with workspace auto-extracted from context.
*/ */
import { createHermesAgent } from "@uncaged/workflow-agent-hermes"; import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
import { createExtract } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime"; import { createWorkflow } from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js"; import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
@@ -22,23 +23,23 @@ function optionalEnv(name: string): string | null {
return value; return value;
} }
const provider = { const llmProvider = {
baseUrl: baseUrl:
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1", optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"), apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus", model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
}; };
const agent = createHermesAgent({ const agent = createCursorAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"), model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT") timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT")) ? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
: null, : 0,
workspace: null,
llmProvider,
}); });
const extract = createExtract(provider); const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null });
const wf = createWorkflow(developWorkflowDefinition, { agent }, extract);
export const descriptor = buildDevelopDescriptor(); export const descriptor = buildDevelopDescriptor();
export const run = wf.run; export const run = wf;
@@ -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;