Merge pull request 'refactor: cursor-agent uses runtime.extract for workspace detection' (#246) from fix/cursor-agent-runtime-extract into main

This commit is contained in:
2026-05-13 15:57:36 +00:00
16 changed files with 259 additions and 164 deletions
+2 -8
View File
@@ -2,16 +2,10 @@
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@uncaged/*"
]
],
"fixed": [["@uncaged/*"]],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@uncaged/workflow-dashboard"
]
"ignore": ["@uncaged/workflow-dashboard"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"files": {
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
},
+15
View File
@@ -0,0 +1,15 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
@@ -2,24 +2,11 @@ import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => {
test("accepts valid config with explicit workspace", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(true);
});
test("accepts valid config with null workspace and llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(r.ok).toBe(true);
});
@@ -29,8 +16,6 @@ describe("validateCursorAgentConfig", () => {
command: "cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
@@ -38,65 +23,22 @@ describe("validateCursorAgentConfig", () => {
}
});
test("rejects empty workspace string", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workspace");
}
});
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
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({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
});
});
describe("createCursorAgent", () => {
test("returns an AdapterFn with explicit workspace", () => {
test("returns an AdapterFn", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("returns an AdapterFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(typeof agent).toBe("function");
});
@@ -106,19 +48,6 @@ describe("createCursorAgent", () => {
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
+1 -1
View File
@@ -12,8 +12,8 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
@@ -1,5 +1,5 @@
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { LogFn } from "@uncaged/workflow-util";
import * as z from "zod/v4";
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
});
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 {
function buildExtractionInput(ctx: ThreadContext): string {
const lines: string[] = [];
lines.push("## Task");
lines.push(ctx.start.content);
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
}
lines.push("");
lines.push(
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
);
return lines.join("\n");
}
export async function extractWorkspacePath(
ctx: AgentContext,
provider: LlmProvider,
ctx: ThreadContext,
runtime: WorkflowRuntime,
logger: LogFn,
): Promise<string | null> {
const reactor = createThreadReactor<null>({
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<string, unknown>,
},
},
};
},
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
toolHandler: async () => "unknown tool",
});
const input = buildExtractionInput(ctx);
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
const result = await reactor({
thread: null,
input: buildExtractionInput(ctx),
schema: workspaceSchema,
});
const result = await runtime.extract(workspaceSchema, contentHash);
const workspace = result.meta.workspace.trim();
if (!result.ok) {
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
return null;
}
const workspace = result.value.workspace.trim();
if (!workspace.startsWith("/")) {
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
return null;
+9 -20
View File
@@ -1,4 +1,4 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import {
buildThreadInput,
@@ -33,34 +33,23 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
/** 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) => {
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
let workspace: string;
if (config.workspace !== null) {
workspace = config.workspace;
} else {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
const extracted = await extractWorkspacePath(agentCtx, 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.",
);
}
workspace = extracted;
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.",
);
}
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
@@ -1,12 +1,6 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = {
/** Absolute path to the cursor-agent CLI binary. */
command: string;
model: string | null;
timeout: number;
/** 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;
};
@@ -8,12 +8,6 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
if (!isAbsolute(config.command)) {
return err("command must be an absolute path to the cursor-agent CLI binary");
}
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");
}
+1 -1
View File
@@ -33,7 +33,7 @@ function throwHermesSpawnError(error: SpawnCliError): never {
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt) => {
return createTextAdapter(async (ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
@@ -93,7 +93,7 @@ export async function chatCompletionText(options: {
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt) => {
return createTextAdapter(async (ctx, prompt, _runtime) => {
const result = await chatCompletionText({
provider,
messages: [
@@ -48,10 +48,7 @@ function ExpandedWorkflowBody({
const hasGraph = descriptor !== null && edgeCount > 0;
return (
<div
className="pt-3 border-t flex gap-4"
style={{ borderColor: "var(--color-border)" }}
>
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
@@ -83,7 +80,11 @@ function ExpandedWorkflowBody({
{hasGraph ? (
<div
className="rounded-lg border overflow-hidden flex-1"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg)",
minHeight: 500,
}}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
@@ -0,0 +1,101 @@
/**
* greet workflow — smoke test entry
* Single role: greeter takes a prompt and returns a structured greeting.
* 小橘 🍊
*/
import type {
AdapterFn,
ModeratorTable,
RoleFn,
RoleResult,
ThreadContext,
WorkflowDefinition,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
type GreetMeta = {
greeter: { greeting: string; language: string };
};
const greeterSchema = z.object({
greeting: z.string().describe("A friendly greeting message"),
language: z.string().describe("The language of the greeting"),
});
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
greeter: {
description: "Generates a friendly greeting",
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
extractRefs: null,
},
};
const table: ModeratorTable<GreetMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
export const descriptor = {
name: "greet",
description: "A simple greeting workflow for smoke testing",
graph: { [START]: ["greeter"], greeter: [END] },
roles: { greeter: { description: "Generates a friendly greeting" } },
};
function createLazyAdapter(): AdapterFn {
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
function getProvider() {
if (cached !== null) return cached;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
cached = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey,
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
};
return cached;
}
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const provider = getProvider();
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
},
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
}
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
const text = data.choices[0]?.message?.content;
if (!text) throw new Error("Empty LLM response");
const parsed = schema.parse(JSON.parse(text));
return { meta: parsed, childThread: null };
};
}) as AdapterFn;
}
export const run = createWorkflow<GreetMeta>(
{ roles, table },
{ adapter: createLazyAdapter(), overrides: null },
);
@@ -63,5 +63,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM,
schema: plannerMetaSchema,
extractRefs: (meta) => meta.status === "planned" ? meta.phases.map((p) => p.hash) : [],
extractRefs: (meta) => (meta.status === "planned" ? meta.phases.map((p) => p.hash) : []),
};
@@ -7,6 +7,8 @@ import type {
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
export type { WorkflowRuntime } from "@uncaged/workflow-runtime";
/**
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
@@ -23,6 +25,7 @@ export type TextAdapterResult = {
export type TextProducerFn = (
ctx: ThreadContext,
prompt: string,
runtime: WorkflowRuntime,
) => Promise<string | TextAdapterResult>;
/**
@@ -37,7 +40,7 @@ export type TextProducerFn = (
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const result = await producer(ctx, prompt);
const result = await producer(ctx, prompt, runtime);
const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
+101
View File
@@ -0,0 +1,101 @@
/**
* greet workflow — smoke test entry
* Single role: greeter takes a prompt and returns a structured greeting.
* 小橘 🍊
*/
import type {
AdapterFn,
ModeratorTable,
RoleFn,
RoleResult,
ThreadContext,
WorkflowDefinition,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
type GreetMeta = {
greeter: { greeting: string; language: string };
};
const greeterSchema = z.object({
greeting: z.string().describe("A friendly greeting message"),
language: z.string().describe("The language of the greeting"),
});
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
greeter: {
description: "Generates a friendly greeting",
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
extractRefs: null,
},
};
const table: ModeratorTable<GreetMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
export const descriptor = {
name: "greet",
description: "A simple greeting workflow for smoke testing",
graph: { [START]: ["greeter"], greeter: [END] },
roles: { greeter: { description: "Generates a friendly greeting" } },
};
function createLazyAdapter(): AdapterFn {
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
function getProvider() {
if (cached !== null) return cached;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
cached = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey,
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
};
return cached;
}
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const provider = getProvider();
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
},
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
}
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
const text = data.choices[0]?.message?.content;
if (!text) throw new Error("Empty LLM response");
const parsed = schema.parse(JSON.parse(text));
return { meta: parsed, childThread: null };
};
}) as AdapterFn;
}
export const run = createWorkflow<GreetMeta>(
{ roles, table },
{ adapter: createLazyAdapter(), overrides: null },
);