feat: add ExtractFn utility, cursor agent workspace from thread context
- New ExtractFn = <T>(schema, prompt) => (ctx) => Promise<T> - createExtract(provider) creates an LLM-backed ExtractFn - CursorAgent removes workdir config, uses ExtractFn to resolve workspace from ThreadContext at runtime - buildAgentPrompt(ctx) — reads systemPrompt from ctx.currentRole 小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -1,33 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractFn } from "@uncaged/workflow";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
const testExtract: ExtractFn = ((_schema, _prompt) => async (_ctx) => ({
|
||||
workspace: "/tmp",
|
||||
})) as ExtractFn;
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects empty workdir", () => {
|
||||
test("rejects non-function extract", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: " ",
|
||||
model: null,
|
||||
timeout: null,
|
||||
timeout: 0,
|
||||
extract: null as unknown as ExtractFn,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("workdir");
|
||||
expect(r.error).toContain("extract");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
@@ -36,9 +41,9 @@ describe("validateCursorAgentConfig", () => {
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AgentFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
@@ -46,9 +51,9 @@ describe("createCursorAgent", () => {
|
||||
test("throws on invalid config at construction", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
workdir: "",
|
||||
model: null,
|
||||
timeout: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentFn } from "@uncaged/workflow";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
@@ -8,6 +9,12 @@ export { buildAgentPrompt } from "@uncaged/workflow-util-agent";
|
||||
export type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
const cursorWorkspaceSchema = z.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.describe("Absolute path to the project/repository directory the agent should work in"),
|
||||
});
|
||||
|
||||
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
@@ -27,7 +34,7 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` in {@link CursorAgentConfig.workdir} with a prompt built from context + system prompt. */
|
||||
/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
@@ -35,22 +42,29 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
}
|
||||
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout;
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const extractWorkspace = config.extract(
|
||||
cursorWorkspaceSchema,
|
||||
"From the thread context, determine the absolute filesystem path where the project/repository is located. Look for clone paths, working directories, or repo paths mentioned in previous steps.",
|
||||
);
|
||||
|
||||
return async (ctx) => {
|
||||
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
||||
const { workspace } = await extractWorkspace(ctx);
|
||||
const fullPrompt = buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
"--model",
|
||||
modelFlag,
|
||||
"--workspace",
|
||||
workspace,
|
||||
"--output-format",
|
||||
"text",
|
||||
"--trust",
|
||||
"--force",
|
||||
];
|
||||
const run = await spawnCli("cursor-agent", args, {
|
||||
cwd: config.workdir,
|
||||
cwd: workspace,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!run.ok) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
workdir: string;
|
||||
model: string | null;
|
||||
timeout: number | null;
|
||||
timeout: number;
|
||||
extract: ExtractFn;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@ import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||
if (config.workdir.trim() === "") {
|
||||
return err("workdir must be a non-empty string");
|
||||
if (typeof config.extract !== "function") {
|
||||
return err("extract must be a function");
|
||||
}
|
||||
if (config.timeout !== null && config.timeout < 0) {
|
||||
return err("timeout must be null or a non-negative number (milliseconds)");
|
||||
if (config.timeout < 0) {
|
||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user