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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user