diff --git a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts index 910ca75..aee7755 100644 --- a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts +++ b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts @@ -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(); }); diff --git a/packages/workflow-agent-cursor/package.json b/packages/workflow-agent-cursor/package.json index 4442db1..68cce9a 100644 --- a/packages/workflow-agent-cursor/package.json +++ b/packages/workflow-agent-cursor/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-util-agent": "workspace:*" + "@uncaged/workflow-util-agent": "workspace:*", + "zod": "^4.0.0" } } diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index 901c43a..4660a13 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -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) { diff --git a/packages/workflow-agent-cursor/src/types.ts b/packages/workflow-agent-cursor/src/types.ts index 05ca08b..dfed660 100644 --- a/packages/workflow-agent-cursor/src/types.ts +++ b/packages/workflow-agent-cursor/src/types.ts @@ -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; }; diff --git a/packages/workflow-agent-cursor/src/validate-config.ts b/packages/workflow-agent-cursor/src/validate-config.ts index 9181205..b633098 100644 --- a/packages/workflow-agent-cursor/src/validate-config.ts +++ b/packages/workflow-agent-cursor/src/validate-config.ts @@ -3,11 +3,11 @@ import { err, ok, type Result } from "@uncaged/workflow"; import type { CursorAgentConfig } from "./types.js"; export function validateCursorAgentConfig(config: CursorAgentConfig): Result { - 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); } diff --git a/packages/workflow-agent-hermes/src/index.ts b/packages/workflow-agent-hermes/src/index.ts index 2cdf581..59c5838 100644 --- a/packages/workflow-agent-hermes/src/index.ts +++ b/packages/workflow-agent-hermes/src/index.ts @@ -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", diff --git a/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts b/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts index 920cb61..94c087d 100644 --- a/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts +++ b/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts @@ -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}'); diff --git a/packages/workflow-util-agent/src/build-agent-prompt.ts b/packages/workflow-util-agent/src/build-agent-prompt.ts index 35b70b7..e9271bf 100644 --- a/packages/workflow-util-agent/src/build-agent-prompt.ts +++ b/packages/workflow-util-agent/src/build-agent-prompt.ts @@ -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); diff --git a/packages/workflow/src/extract-fn.ts b/packages/workflow/src/extract-fn.ts new file mode 100644 index 0000000..c0bb773 --- /dev/null +++ b/packages/workflow/src/extract-fn.ts @@ -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 = >( + schema: z.ZodType, + prompt: string, +) => (ctx: ThreadContext) => Promise; + +/** + * 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 >(schema: z.ZodType, prompt: string) => { + return async (ctx: ThreadContext): Promise => { + 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; + }; + }; +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index b366b7a..c936186 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -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,