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);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx) => {
|
||||
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
||||
const fullPrompt = buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
|
||||
@@ -18,9 +18,9 @@ describe("buildAgentPrompt", () => {
|
||||
start: startTask("fix the bug"),
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "" },
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
};
|
||||
const text = buildAgentPrompt("You are an agent.", ctx);
|
||||
const text = buildAgentPrompt(ctx);
|
||||
expect(text).toContain("You are an agent.");
|
||||
expect(text).toContain("## Task");
|
||||
expect(text).toContain("fix the bug");
|
||||
@@ -31,7 +31,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("user task"),
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "" },
|
||||
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||
steps: [
|
||||
{
|
||||
role: "coder",
|
||||
@@ -41,7 +41,7 @@ describe("buildAgentPrompt", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = buildAgentPrompt("Be helpful.", ctx);
|
||||
const text = buildAgentPrompt(ctx);
|
||||
expect(text).toContain("## Task");
|
||||
expect(text).toContain("user task");
|
||||
expect(text).toContain("## Step: coder");
|
||||
@@ -55,7 +55,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("first message full: task content here"),
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "" },
|
||||
currentRole: { name: "coder", systemPrompt: "System." },
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
@@ -71,7 +71,7 @@ describe("buildAgentPrompt", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = buildAgentPrompt("System.", ctx);
|
||||
const text = buildAgentPrompt(ctx);
|
||||
expect(text).toContain("first message full: task content here");
|
||||
expect(text).toContain("## Previous Steps");
|
||||
expect(text).toContain("### Step 1: planner");
|
||||
@@ -88,7 +88,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("start"),
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "c", systemPrompt: "" },
|
||||
currentRole: { name: "c", systemPrompt: "S" },
|
||||
steps: [
|
||||
{
|
||||
role: "a",
|
||||
@@ -110,7 +110,7 @@ describe("buildAgentPrompt", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = buildAgentPrompt("S", ctx);
|
||||
const text = buildAgentPrompt(ctx);
|
||||
expect(text).not.toContain("HIDDEN_A");
|
||||
expect(text).not.toContain("HIDDEN_B_MIDDLE");
|
||||
expect(text).toContain('Summary: {"n":1}');
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||
export function buildAgentPrompt(systemPrompt: string, ctx: ThreadContext): string {
|
||||
export function buildAgentPrompt(ctx: ThreadContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(systemPrompt);
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { llmExtractWithRetry } from "./llm-extract.js";
|
||||
import type { LlmProvider, ThreadContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* Curried extract: bind a schema + prompt, get a function that extracts from ThreadContext.
|
||||
*/
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
) => (ctx: ThreadContext) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Create an ExtractFn backed by an LLM provider.
|
||||
* The returned function uses the thread context (currentRole.systemPrompt + steps) as source text
|
||||
* for structured extraction.
|
||||
*/
|
||||
export function createExtract(provider: LlmProvider): ExtractFn {
|
||||
return <T extends Record<string, unknown>>(schema: z.ZodType<T>, prompt: string) => {
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Current Role");
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
lines.push("");
|
||||
if (ctx.steps.length > 0) {
|
||||
lines.push("## Thread History");
|
||||
for (const step of ctx.steps) {
|
||||
lines.push(`### ${step.role}`);
|
||||
lines.push(step.content);
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push("## Extraction Instruction");
|
||||
lines.push(prompt);
|
||||
|
||||
const text = lines.join("\n");
|
||||
const result = await llmExtractWithRetry({ text, schema, provider });
|
||||
if (!result.ok) {
|
||||
throw new Error(`extract failed: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
type PrefilledDiskStep,
|
||||
} from "./engine.js";
|
||||
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
||||
export { createExtract, type ExtractFn } from "./extract-fn.js";
|
||||
export { extractMetaOrThrow } from "./extract-meta.js";
|
||||
export {
|
||||
buildForkPlan,
|
||||
|
||||
Reference in New Issue
Block a user