diff --git a/examples/hello-world.ts b/examples/hello-world.ts index 1abe3a4..25559bf 100644 --- a/examples/hello-world.ts +++ b/examples/hello-world.ts @@ -1,4 +1,4 @@ -import { createWorkflow, END, type RoleDefinition } from "@uncaged/workflow"; +import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow"; import * as z from "zod/v4"; type Roles = { @@ -26,12 +26,15 @@ export const descriptor = { const greeter: RoleDefinition = { description: "Generates a greeting", systemPrompt: "You greet the user briefly.", + extractPrompt: "Extract the greeting string produced for the user.", schema: greeterMetaSchema, }; -const extract = { - provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "" }, -} as const; +const extract = createExtract({ + baseUrl: "http://127.0.0.1:9", + apiKey: "", + model: "", +}); export const run = createWorkflow( { diff --git a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts index aee7755..ae9cfd1 100644 --- a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts +++ b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; -import type { ExtractFn } from "@uncaged/workflow"; +import type { ExtractContext, ExtractFn } from "@uncaged/workflow"; +import type * as z from "zod/v4"; import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js"; -const testExtract: ExtractFn = ((_schema, _prompt) => async (_ctx) => ({ - workspace: "/tmp", -})) as ExtractFn; +const testExtract: ExtractFn = async >( + _schema: z.ZodType, + _ctx: ExtractContext, +): Promise => ({ workspace: "/tmp" }) as unknown as T; describe("validateCursorAgentConfig", () => { test("accepts valid config", () => { diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index 4660a13..7165457 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -1,4 +1,4 @@ -import type { AgentFn } from "@uncaged/workflow"; +import type { AgentFn, ExtractContext } from "@uncaged/workflow"; import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent"; import * as z from "zod/v4"; @@ -43,13 +43,15 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { const modelFlag = resolveCursorModel(config.model); 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 { workspace } = await extractWorkspace(ctx); + const extractCtx: ExtractContext = { + ...ctx, + agentContent: "", + extractPrompt: + "From the thread context, determine the absolute filesystem path where the project/repository is located.", + }; + const { workspace } = await config.extract(cursorWorkspaceSchema, extractCtx); const fullPrompt = buildAgentPrompt(ctx); const args = [ "-p", diff --git a/packages/workflow-agent-llm/src/create-llm-adapter.ts b/packages/workflow-agent-llm/src/create-llm-adapter.ts index 8780ec8..ac614d1 100644 --- a/packages/workflow-agent-llm/src/create-llm-adapter.ts +++ b/packages/workflow-agent-llm/src/create-llm-adapter.ts @@ -1,10 +1,10 @@ import { + type AgentContext, type AgentFn, err, type LlmProvider, ok, type Result, - type ThreadContext, } from "@uncaged/workflow"; /** OpenAI chat completion message shape (passed to `/chat/completions`). */ @@ -97,9 +97,9 @@ export async function chatCompletionText(options: { return parseAssistantText(res.value); } -/** Single-turn chat adapter: system prompt comes from {@link ThreadContext.currentRole}. */ +/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */ export function createLlmAdapter(provider: LlmProvider): AgentFn { - return async (ctx: ThreadContext) => { + return async (ctx: AgentContext) => { const result = await chatCompletionText({ provider, messages: [ diff --git a/packages/workflow-role-coder/src/coder.ts b/packages/workflow-role-coder/src/coder.ts index 651c7ab..37f732c 100644 --- a/packages/workflow-role-coder/src/coder.ts +++ b/packages/workflow-role-coder/src/coder.ts @@ -16,5 +16,7 @@ export const coderRole: RoleDefinition = { description: "Implements the next incomplete planner phase and reports structured completion metadata.", systemPrompt: CODER_SYSTEM, + extractPrompt: + "Extract which phase was completed, which files were changed, and a summary of the work done.", schema: coderMetaSchema, }; diff --git a/packages/workflow-role-committer/src/committer.ts b/packages/workflow-role-committer/src/committer.ts index e921fa6..b61c8dd 100644 --- a/packages/workflow-role-committer/src/committer.ts +++ b/packages/workflow-role-committer/src/committer.ts @@ -28,5 +28,7 @@ Do not attempt to fix failures yourself.`; export const committerRole: RoleDefinition = { description: "Creates branch, commits, and pushes when review passes.", systemPrompt: COMMITTER_SYSTEM, + extractPrompt: + "Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.", schema: committerMetaSchema, }; diff --git a/packages/workflow-role-planner/src/planner.ts b/packages/workflow-role-planner/src/planner.ts index 2cb7a82..6ace1bf 100644 --- a/packages/workflow-role-planner/src/planner.ts +++ b/packages/workflow-role-planner/src/planner.ts @@ -22,5 +22,7 @@ Order phases so earlier steps unblock later ones. Cover root cause, edge cases, export const plannerRole: RoleDefinition = { description: "Breaks the task into sequential phases for the coder.", systemPrompt: PLANNER_SYSTEM, + extractPrompt: + "Extract the implementation phases from the agent's analysis. Each phase needs a name, description, and acceptance criteria.", schema: plannerMetaSchema, }; diff --git a/packages/workflow-role-reviewer/src/reviewer.ts b/packages/workflow-role-reviewer/src/reviewer.ts index 5feb5f9..00d8c2d 100644 --- a/packages/workflow-role-reviewer/src/reviewer.ts +++ b/packages/workflow-role-reviewer/src/reviewer.ts @@ -18,5 +18,7 @@ Only reject for blocking issues. End with your verdict.`; export const reviewerRole: RoleDefinition = { description: "Runs git diff checks and sets approved when the change is ready.", systemPrompt: REVIEWER_SYSTEM, + extractPrompt: + "Extract the review verdict: approved or rejected. If rejected, list the blocking issues.", schema: reviewerMetaSchema, }; diff --git a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts index a35ea1b..919721f 100644 --- a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts +++ b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { + createExtract, END, + type ModeratorContext, type RoleStep, START, - type ThreadContext, validateWorkflowDescriptor, } from "@uncaged/workflow"; @@ -79,7 +80,7 @@ function installMockChatCompletions(sequence: ReadonlyArray["start"] { +function makeStart(maxRounds: number): ModeratorContext["start"] { return { role: START, content: "Fix the flaky login test", @@ -90,11 +91,10 @@ function makeStart(maxRounds: number): ThreadContext["start"] { function makeCtx( maxRounds: number, - steps: ThreadContext["steps"], -): ThreadContext { + steps: ModeratorContext["steps"], +): ModeratorContext { return { threadId: "01TEST000000000000000000TR", - currentRole: { name: START, systemPrompt: "" }, start: makeStart(maxRounds), steps, }; @@ -138,9 +138,11 @@ function committerStep(): RoleStep { }; } -const stubExtract = { - provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, -} as const; +const stubExtract = createExtract({ + baseUrl: "http://127.0.0.1:9", + apiKey: "", + model: "test", +}); describe("solveIssueModerator", () => { test("routes planner → coder → reviewer → committer → END", () => { @@ -158,7 +160,7 @@ describe("solveIssueModerator", () => { }); test("reviewer rejects → coder retry when budget allows", () => { - const steps: ThreadContext["steps"] = [ + const steps: ModeratorContext["steps"] = [ plannerStep(), coderStep(), reviewerStep(false), @@ -167,7 +169,7 @@ describe("solveIssueModerator", () => { }); test("reviewer rejects → END when max rounds exhausted", () => { - const steps: ThreadContext["steps"] = [ + const steps: ModeratorContext["steps"] = [ plannerStep(), coderStep(), reviewerStep(false), @@ -192,7 +194,7 @@ describe("solveIssueModerator", () => { { name: "p1", description: "first", acceptance: "a1" }, { name: "p2", description: "second", acceptance: "a2" }, ]; - const steps: ThreadContext["steps"] = [plannerStep(phases), coderStep("p1")]; + const steps: ModeratorContext["steps"] = [plannerStep(phases), coderStep("p1")]; expect(solveIssueModerator(makeCtx(3, steps))).toBe(END); }); }); diff --git a/packages/workflow-template-solve-issue/src/index.ts b/packages/workflow-template-solve-issue/src/index.ts index c8bda11..1245e4b 100644 --- a/packages/workflow-template-solve-issue/src/index.ts +++ b/packages/workflow-template-solve-issue/src/index.ts @@ -1,7 +1,7 @@ import { type AgentBinding, createWorkflow, - type ExtractConfig, + type ExtractFn, type WorkflowDefinition, type WorkflowFn, } from "@uncaged/workflow"; @@ -45,6 +45,6 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition = moderator: solveIssueModerator, }; -export function createSolveIssueRun(binding: AgentBinding, extract: ExtractConfig): WorkflowFn { +export function createSolveIssueRun(binding: AgentBinding, extract: ExtractFn): WorkflowFn { return createWorkflow(solveIssueWorkflowDefinition, binding, extract); } diff --git a/packages/workflow-template-solve-issue/src/moderator.ts b/packages/workflow-template-solve-issue/src/moderator.ts index 20387a7..20b41ab 100644 --- a/packages/workflow-template-solve-issue/src/moderator.ts +++ b/packages/workflow-template-solve-issue/src/moderator.ts @@ -1,10 +1,10 @@ -import type { Moderator, ThreadContext } from "@uncaged/workflow"; +import type { Moderator, ModeratorContext } from "@uncaged/workflow"; import { END } from "@uncaged/workflow"; import type { SolveIssueMeta } from "./roles.js"; function nextAfterCoder( - ctx: ThreadContext, + ctx: ModeratorContext, maxRounds: number, ): (keyof SolveIssueMeta & string) | typeof END { const plannerStep = ctx.steps.find((s) => s.role === "planner"); diff --git a/packages/workflow-util-agent/src/build-agent-prompt.ts b/packages/workflow-util-agent/src/build-agent-prompt.ts index e9271bf..9564840 100644 --- a/packages/workflow-util-agent/src/build-agent-prompt.ts +++ b/packages/workflow-util-agent/src/build-agent-prompt.ts @@ -1,7 +1,7 @@ -import type { ThreadContext } from "@uncaged/workflow"; +import type { AgentContext } from "@uncaged/workflow"; /** Builds the full agent prompt: system instructions plus summarized thread history. */ -export function buildAgentPrompt(ctx: ThreadContext): string { +export function buildAgentPrompt(ctx: AgentContext): string { const lines: string[] = []; lines.push(ctx.currentRole.systemPrompt); lines.push(""); diff --git a/packages/workflow/__tests__/build-descriptor.test.ts b/packages/workflow/__tests__/build-descriptor.test.ts index 6e1bb78..5523aa2 100644 --- a/packages/workflow/__tests__/build-descriptor.test.ts +++ b/packages/workflow/__tests__/build-descriptor.test.ts @@ -20,6 +20,7 @@ describe("buildDescriptor", () => { analyst: { description: "Analyzes input", systemPrompt: "You are an analyst.", + extractPrompt: "Extract title and count from the analysis.", schema, }, }, diff --git a/packages/workflow/__tests__/engine.test.ts b/packages/workflow/__tests__/engine.test.ts index cea2876..cf718da 100644 --- a/packages/workflow/__tests__/engine.test.ts +++ b/packages/workflow/__tests__/engine.test.ts @@ -6,6 +6,7 @@ import * as z from "zod/v4"; import { createWorkflow } from "../src/create-workflow.js"; import { executeThread } from "../src/engine.js"; +import { createExtract } from "../src/extract-fn.js"; import { createLogger } from "../src/logger.js"; import { END } from "../src/types.js"; @@ -74,9 +75,11 @@ function installMockChatCompletions(sequence: ReadonlyArray( { @@ -84,11 +87,13 @@ const demoWorkflow = createWorkflow( planner: { description: "Demo planner", systemPrompt: "You are a planner.", + extractPrompt: "Extract plan text and affected files list.", schema: plannerMetaSchema, }, coder: { description: "Demo coder", systemPrompt: "You are a coder.", + extractPrompt: "Extract the code diff summary.", schema: coderMetaSchema, }, }, diff --git a/packages/workflow/src/create-workflow.ts b/packages/workflow/src/create-workflow.ts index f59be99..4d32e3c 100644 --- a/packages/workflow/src/create-workflow.ts +++ b/packages/workflow/src/create-workflow.ts @@ -1,13 +1,14 @@ -import { extractMetaOrThrow } from "./extract-meta.js"; +import type { ExtractFn } from "./extract-fn.js"; import { type AgentBinding, + type AgentContext, END, - type ExtractConfig, + type ExtractContext, + type ModeratorContext, type RoleMeta, type RoleOutput, type RoleStep, START, - type ThreadContext, type ThreadInput, type WorkflowDefinition, type WorkflowFn, @@ -21,33 +22,6 @@ function isRoleNext( return next !== END; } -function moderatorThreadContext(params: { - threadId: string; - start: ThreadContext["start"]; - steps: RoleStep[]; - roles: Pick, "roles">["roles"]; -}): ThreadContext { - const { threadId, start, steps, roles } = params; - const last = steps[steps.length - 1]; - if (last === undefined) { - return { - threadId, - currentRole: { name: START, systemPrompt: "" }, - start, - steps, - }; - } - const roleName = last.role as keyof M & string; - const roleDef = roles[roleName]; - const systemPrompt = roleDef !== undefined ? roleDef.systemPrompt : ""; - return { - threadId, - currentRole: { name: roleName, systemPrompt }, - start, - steps, - }; -} - /** * Binds pure role definitions + moderator to runtime agents and structured extraction. * Assign with `export const run = createWorkflow(def, binding, extract)`. @@ -55,14 +29,14 @@ function moderatorThreadContext(params: { export function createWorkflow( def: Pick, "roles" | "moderator">, binding: AgentBinding, - extract: ExtractConfig, + extract: ExtractFn, ): WorkflowFn { return async function* workflowLoop( input: ThreadInput, options: WorkflowFnOptions, ): AsyncGenerator { const nowMs = Date.now(); - const start: ThreadContext["start"] = { + const start: ModeratorContext["start"] = { role: START, content: input.prompt, meta: { maxRounds: options.maxRounds }, @@ -85,12 +59,11 @@ export function createWorkflow( }; } - const modCtx = moderatorThreadContext({ + const modCtx: ModeratorContext = { threadId: options.threadId, start, steps, - roles: def.roles, - }); + }; const next = def.moderator(modCtx); @@ -103,20 +76,22 @@ export function createWorkflow( return { returnCode: 1, summary: `unknown role: ${next}` }; } - const ctx: ThreadContext = { - threadId: options.threadId, + const agentCtx: AgentContext = { + ...modCtx, currentRole: { name: next, systemPrompt: roleDef.systemPrompt }, - start, - steps, }; const agent = binding.overrides?.[next] ?? binding.agent; - const raw = await agent(ctx as unknown as ThreadContext); + const raw = await agent(agentCtx as unknown as AgentContext); - const meta = await extractMetaOrThrow(next, raw, roleDef.schema, { - provider: extract.provider, - }); + const extractCtx: ExtractContext = { + ...agentCtx, + agentContent: raw, + extractPrompt: roleDef.extractPrompt, + }; + + const meta = await extract(roleDef.schema, extractCtx as unknown as ExtractContext); const ts = Date.now(); const step = { diff --git a/packages/workflow/src/extract-fn.ts b/packages/workflow/src/extract-fn.ts index c0bb773..a97bb7f 100644 --- a/packages/workflow/src/extract-fn.ts +++ b/packages/workflow/src/extract-fn.ts @@ -1,49 +1,49 @@ import type * as z from "zod/v4"; import { llmExtractWithRetry } from "./llm-extract.js"; -import type { LlmProvider, ThreadContext } from "./types.js"; +import type { ExtractContext, LlmProvider } 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; + ctx: ExtractContext, +) => 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. + * Builds prompt text from {@link ExtractContext} and calls 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(""); - } + return async >( + schema: z.ZodType, + ctx: ExtractContext, + ): Promise => { + const lines: string[] = []; + lines.push(`## Role: ${ctx.currentRole.name}`); + 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); + } + lines.push("## Agent Output"); + lines.push(ctx.agentContent); + lines.push(""); + lines.push("## Extraction Instruction"); + lines.push(ctx.extractPrompt); - 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; - }; + 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/extract-meta.ts b/packages/workflow/src/extract-meta.ts deleted file mode 100644 index 6376645..0000000 --- a/packages/workflow/src/extract-meta.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type * as z from "zod/v4"; - -import { llmExtractWithRetry } from "./llm-extract.js"; -import type { LlmProvider } from "./types.js"; - -export async function extractMetaOrThrow>( - roleName: string, - raw: string, - schema: z.ZodType, - options: { provider: LlmProvider }, -): Promise { - const result = await llmExtractWithRetry({ - text: raw, - schema, - provider: options.provider, - }); - if (!result.ok) { - throw new Error( - `Role "${roleName}": structured extraction failed after retry: ${JSON.stringify(result.error)}`, - ); - } - return result.value; -} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index c936186..47d4564 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -16,7 +16,6 @@ export { } 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, type ForkHistoricalStep, @@ -59,11 +58,13 @@ export { getDefaultWorkflowStorageRoot } from "./storage-root.js"; export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js"; export { type AgentBinding, + type AgentContext, type AgentFn, END, - type ExtractConfig, + type ExtractContext, type LlmProvider, type Moderator, + type ModeratorContext, type RoleDefinition, type RoleMeta, type RoleOutput, diff --git a/packages/workflow/src/types.ts b/packages/workflow/src/types.ts index 4e8c4bd..1483f23 100644 --- a/packages/workflow/src/types.ts +++ b/packages/workflow/src/types.ts @@ -58,19 +58,32 @@ export type RoleStep = { [K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number }; }[keyof M & string]; -/** Thread-scoped context passed to agents and moderator. */ -export type ThreadContext = { +/** Phase 1: Moderator decides next role. */ +export type ModeratorContext = { threadId: string; - currentRole: { - name: string; - systemPrompt: string; - }; start: StartStep; steps: RoleStep[]; }; +/** Phase 2: Agent executes — knows its role and prompt. */ +export type AgentContext = ModeratorContext & { + currentRole: { + name: string; + systemPrompt: string; + }; +}; + +/** Phase 3: Extractor runs — has agent output and extract instruction. */ +export type ExtractContext = AgentContext & { + agentContent: string; + extractPrompt: string; +}; + +/** Alias — most external consumers see the agent-phase context. */ +export type ThreadContext = AgentContext; + /** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */ -export type AgentFn = (ctx: ThreadContext) => Promise; +export type AgentFn = (ctx: AgentContext) => Promise; /** Runtime agent assignment (optional per-role overrides). */ export type AgentBinding = { @@ -78,15 +91,11 @@ export type AgentBinding = { overrides?: Partial>; }; -/** Structured extraction settings for the workflow engine. */ -export type ExtractConfig = { - provider: LlmProvider; -}; - /** Role wiring: prompts, schema, and human-readable description. */ export type RoleDefinition> = { description: string; systemPrompt: string; + extractPrompt: string; schema: z.ZodType; }; @@ -97,7 +106,7 @@ export type RoleDefinition> = { * Returns the next role name or END to terminate. */ export type Moderator = ( - ctx: ThreadContext, + ctx: ModeratorContext, ) => (keyof M & string) | typeof END; /** Complete workflow definition as authored by users. */