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 { 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();
}); });
+2 -1
View File
@@ -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"
} }
} }
+18 -4
View File
@@ -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) {
+4 -2
View File
@@ -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);
} }
+1 -1
View File
@@ -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);
+49
View File
@@ -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;
};
};
}
+1
View File
@@ -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,