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 { describe, expect, test } from "bun:test";
|
||||||
|
import type { ExtractFn } from "@uncaged/workflow";
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
|
const testExtract: ExtractFn = ((_schema, _prompt) => async (_ctx) => ({
|
||||||
|
workspace: "/tmp",
|
||||||
|
})) as ExtractFn;
|
||||||
|
|
||||||
describe("validateCursorAgentConfig", () => {
|
describe("validateCursorAgentConfig", () => {
|
||||||
test("accepts valid config", () => {
|
test("accepts valid config", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
workdir: "/tmp",
|
|
||||||
model: null,
|
model: null,
|
||||||
timeout: null,
|
timeout: 0,
|
||||||
|
extract: testExtract,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects empty workdir", () => {
|
test("rejects non-function extract", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
workdir: " ",
|
|
||||||
model: null,
|
model: null,
|
||||||
timeout: null,
|
timeout: 0,
|
||||||
|
extract: null as unknown as ExtractFn,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
expect(r.error).toContain("workdir");
|
expect(r.error).toContain("extract");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects negative timeout", () => {
|
test("rejects negative timeout", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
workdir: "/tmp",
|
|
||||||
model: null,
|
model: null,
|
||||||
timeout: -1,
|
timeout: -1,
|
||||||
|
extract: testExtract,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -36,9 +41,9 @@ describe("validateCursorAgentConfig", () => {
|
|||||||
describe("createCursorAgent", () => {
|
describe("createCursorAgent", () => {
|
||||||
test("returns an AgentFn", () => {
|
test("returns an AgentFn", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
workdir: "/tmp",
|
|
||||||
model: null,
|
model: null,
|
||||||
timeout: null,
|
timeout: 0,
|
||||||
|
extract: testExtract,
|
||||||
});
|
});
|
||||||
expect(typeof agent).toBe("function");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
@@ -46,9 +51,9 @@ describe("createCursorAgent", () => {
|
|||||||
test("throws on invalid config at construction", () => {
|
test("throws on invalid config at construction", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
createCursorAgent({
|
createCursorAgent({
|
||||||
workdir: "",
|
|
||||||
model: null,
|
model: null,
|
||||||
timeout: null,
|
timeout: -1,
|
||||||
|
extract: testExtract,
|
||||||
}),
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@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 type { AgentFn } from "@uncaged/workflow";
|
||||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import type { CursorAgentConfig } from "./types.js";
|
import type { CursorAgentConfig } from "./types.js";
|
||||||
import { validateCursorAgentConfig } from "./validate-config.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 type { CursorAgentConfig } from "./types.js";
|
||||||
export { validateCursorAgentConfig } from "./validate-config.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 {
|
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||||
if (error.kind === "non_zero_exit") {
|
if (error.kind === "non_zero_exit") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -27,7 +34,7 @@ function resolveCursorModel(model: string | null): string {
|
|||||||
return model === null ? "auto" : model;
|
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 {
|
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||||
const validated = validateCursorAgentConfig(config);
|
const validated = validateCursorAgentConfig(config);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
@@ -35,22 +42,29 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modelFlag = resolveCursorModel(config.model);
|
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) => {
|
return async (ctx) => {
|
||||||
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
const { workspace } = await extractWorkspace(ctx);
|
||||||
|
const fullPrompt = buildAgentPrompt(ctx);
|
||||||
const args = [
|
const args = [
|
||||||
"-p",
|
"-p",
|
||||||
fullPrompt,
|
fullPrompt,
|
||||||
"--model",
|
"--model",
|
||||||
modelFlag,
|
modelFlag,
|
||||||
|
"--workspace",
|
||||||
|
workspace,
|
||||||
"--output-format",
|
"--output-format",
|
||||||
"text",
|
"text",
|
||||||
"--trust",
|
"--trust",
|
||||||
"--force",
|
"--force",
|
||||||
];
|
];
|
||||||
const run = await spawnCli("cursor-agent", args, {
|
const run = await spawnCli("cursor-agent", args, {
|
||||||
cwd: config.workdir,
|
cwd: workspace,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
if (!run.ok) {
|
if (!run.ok) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import type { ExtractFn } from "@uncaged/workflow";
|
||||||
|
|
||||||
export type CursorAgentConfig = {
|
export type CursorAgentConfig = {
|
||||||
workdir: string;
|
|
||||||
model: string | null;
|
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";
|
import type { CursorAgentConfig } from "./types.js";
|
||||||
|
|
||||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||||
if (config.workdir.trim() === "") {
|
if (typeof config.extract !== "function") {
|
||||||
return err("workdir must be a non-empty string");
|
return err("extract must be a function");
|
||||||
}
|
}
|
||||||
if (config.timeout !== null && config.timeout < 0) {
|
if (config.timeout < 0) {
|
||||||
return err("timeout must be null or a non-negative number (milliseconds)");
|
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||||
}
|
}
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
|||||||
const timeoutMs = config.timeout;
|
const timeoutMs = config.timeout;
|
||||||
|
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
const fullPrompt = buildAgentPrompt(ctx);
|
||||||
const args = [
|
const args = [
|
||||||
"chat",
|
"chat",
|
||||||
"-q",
|
"-q",
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ describe("buildAgentPrompt", () => {
|
|||||||
start: startTask("fix the bug"),
|
start: startTask("fix the bug"),
|
||||||
steps: [],
|
steps: [],
|
||||||
threadId: "01TEST000000000000000000TR",
|
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("You are an agent.");
|
||||||
expect(text).toContain("## Task");
|
expect(text).toContain("## Task");
|
||||||
expect(text).toContain("fix the bug");
|
expect(text).toContain("fix the bug");
|
||||||
@@ -31,7 +31,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
const ctx: ThreadContext = {
|
const ctx: ThreadContext = {
|
||||||
start: startTask("user task"),
|
start: startTask("user task"),
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "coder", systemPrompt: "" },
|
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "coder",
|
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("## Task");
|
||||||
expect(text).toContain("user task");
|
expect(text).toContain("user task");
|
||||||
expect(text).toContain("## Step: coder");
|
expect(text).toContain("## Step: coder");
|
||||||
@@ -55,7 +55,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
const ctx: ThreadContext = {
|
const ctx: ThreadContext = {
|
||||||
start: startTask("first message full: task content here"),
|
start: startTask("first message full: task content here"),
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "coder", systemPrompt: "" },
|
currentRole: { name: "coder", systemPrompt: "System." },
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "planner",
|
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("first message full: task content here");
|
||||||
expect(text).toContain("## Previous Steps");
|
expect(text).toContain("## Previous Steps");
|
||||||
expect(text).toContain("### Step 1: planner");
|
expect(text).toContain("### Step 1: planner");
|
||||||
@@ -88,7 +88,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
const ctx: ThreadContext = {
|
const ctx: ThreadContext = {
|
||||||
start: startTask("start"),
|
start: startTask("start"),
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "c", systemPrompt: "" },
|
currentRole: { name: "c", systemPrompt: "S" },
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "a",
|
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_A");
|
||||||
expect(text).not.toContain("HIDDEN_B_MIDDLE");
|
expect(text).not.toContain("HIDDEN_B_MIDDLE");
|
||||||
expect(text).toContain('Summary: {"n":1}');
|
expect(text).toContain('Summary: {"n":1}');
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ThreadContext } from "@uncaged/workflow";
|
import type { ThreadContext } from "@uncaged/workflow";
|
||||||
|
|
||||||
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
/** 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[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(systemPrompt);
|
lines.push(ctx.currentRole.systemPrompt);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("## Task");
|
lines.push("## Task");
|
||||||
lines.push(ctx.start.content);
|
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,
|
type PrefilledDiskStep,
|
||||||
} from "./engine.js";
|
} from "./engine.js";
|
||||||
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.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 { extractMetaOrThrow } from "./extract-meta.js";
|
||||||
export {
|
export {
|
||||||
buildForkPlan,
|
buildForkPlan,
|
||||||
|
|||||||
Reference in New Issue
Block a user