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:
2026-05-07 00:17:31 +00:00
parent d351343aa8
commit 99a137422c
10 changed files with 105 additions and 33 deletions
@@ -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();
});
+2 -1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
}
}
+18 -4
View File
@@ -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) {
+4 -2
View File
@@ -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);
}