feat: cursor agent auto-extracts workspace from context #193
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
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 {
|
||||
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,
|
||||
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 result = await reactor({
|
||||
thread: null,
|
||||
input: buildExtractionInput(ctx),
|
||||
schema: workspaceSchema,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
logger("V3KM8QWP", `workspace extraction failed: ${result.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
xiaomo
commented
Nit: 3 个 log 调用共用 tag Nit: 3 个 log 调用共用 tag `V3KM8QWP`,按项目约定(CLAUDE.md: "one tag per call site")应该各用独立 tag。
|
||||
const workspace = result.value.workspace.trim();
|
||||
if (!workspace.startsWith("/")) {
|
||||
logger("V3KM8QWP", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger("V3KM8QWP", `extracted workspace: ${workspace}`);
|
||||
return workspace;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
@@ -26,7 +28,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) {
|
||||
@@ -35,9 +37,27 @@ 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) => {
|
||||
const workspace = config.workspace;
|
||||
let workspace: string;
|
||||
|
||||
|
xiaomo
commented
`config.llmProvider!` — 用显式 guard 替代 non-null assertion:
```ts
if (config.llmProvider === null) {
throw new Error("llmProvider required when workspace is null");
}
```
|
||||
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 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.",
|
||||
);
|
||||
}
|
||||
workspace = extracted;
|
||||
}
|
||||
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<void, string> {
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
这 40 行手动 JSON 解析 + type narrowing 太脆弱了。
createLlmFn返回的到底是原始 HTTP body string 还是已解析的 content?如果是前者,用 zod schema 做一次解析;如果是后者,这段代码可以砍掉大半。另外所有
return null都应该加 log(createLogger+ tag),不然 extraction 失败了完全无从排查。