refactor: three-phase context (Moderator/Agent/Extract) + extractPrompt + unified ExtractFn

- ModeratorContext → AgentContext → ExtractContext progressive types
- ThreadContext is now alias for AgentContext
- RoleDefinition adds extractPrompt field
- ExtractFn = (schema, ctx: ExtractContext) => Promise<T>
- createWorkflow takes ExtractFn, engine loop: moderator → agent → extract
- Remove ExtractConfig, extractMetaOrThrow, extract-meta.ts

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-07 01:05:31 +00:00
parent 99a137422c
commit d472de1247
19 changed files with 137 additions and 152 deletions
+7 -4
View File
@@ -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<Roles["greeter"]> = {
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<Roles>(
{
@@ -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 <T extends Record<string, unknown>>(
_schema: z.ZodType<T>,
_ctx: ExtractContext,
): Promise<T> => ({ workspace: "/tmp" }) as unknown as T;
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
+8 -6
View File
@@ -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",
@@ -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: [
@@ -16,5 +16,7 @@ export const coderRole: RoleDefinition<CoderMeta> = {
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,
};
@@ -28,5 +28,7 @@ Do not attempt to fix failures yourself.`;
export const committerRole: RoleDefinition<CommitterMeta> = {
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,
};
@@ -22,5 +22,7 @@ Order phases so earlier steps unblock later ones. Cover root cause, edge cases,
export const plannerRole: RoleDefinition<PlannerMeta> = {
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,
};
@@ -18,5 +18,7 @@ Only reject for blocking issues. End with your verdict.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
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,
};
@@ -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<Record<string, unkno
};
}
function makeStart(maxRounds: number): ThreadContext<SolveIssueMeta>["start"] {
function makeStart(maxRounds: number): ModeratorContext<SolveIssueMeta>["start"] {
return {
role: START,
content: "Fix the flaky login test",
@@ -90,11 +91,10 @@ function makeStart(maxRounds: number): ThreadContext<SolveIssueMeta>["start"] {
function makeCtx(
maxRounds: number,
steps: ThreadContext<SolveIssueMeta>["steps"],
): ThreadContext<SolveIssueMeta> {
steps: ModeratorContext<SolveIssueMeta>["steps"],
): ModeratorContext<SolveIssueMeta> {
return {
threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "" },
start: makeStart(maxRounds),
steps,
};
@@ -138,9 +138,11 @@ function committerStep(): RoleStep<SolveIssueMeta> {
};
}
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<SolveIssueMeta>["steps"] = [
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
@@ -167,7 +169,7 @@ describe("solveIssueModerator", () => {
});
test("reviewer rejects → END when max rounds exhausted", () => {
const steps: ThreadContext<SolveIssueMeta>["steps"] = [
const steps: ModeratorContext<SolveIssueMeta>["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<SolveIssueMeta>["steps"] = [plannerStep(phases), coderStep("p1")];
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [plannerStep(phases), coderStep("p1")];
expect(solveIssueModerator(makeCtx(3, steps))).toBe(END);
});
});
@@ -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<SolveIssueMeta> =
moderator: solveIssueModerator,
};
export function createSolveIssueRun(binding: AgentBinding, extract: ExtractConfig): WorkflowFn {
export function createSolveIssueRun(binding: AgentBinding, extract: ExtractFn): WorkflowFn {
return createWorkflow(solveIssueWorkflowDefinition, binding, extract);
}
@@ -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<SolveIssueMeta>,
ctx: ModeratorContext<SolveIssueMeta>,
maxRounds: number,
): (keyof SolveIssueMeta & string) | typeof END {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
@@ -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("");
@@ -20,6 +20,7 @@ describe("buildDescriptor", () => {
analyst: {
description: "Analyzes input",
systemPrompt: "You are an analyst.",
extractPrompt: "Extract title and count from the analysis.",
schema,
},
},
+8 -3
View File
@@ -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<Record<string, unkno
};
}
const demoExtract = {
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" },
} as const;
const demoExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
});
const demoWorkflow = createWorkflow<DemoMeta>(
{
@@ -84,11 +87,13 @@ const demoWorkflow = createWorkflow<DemoMeta>(
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,
},
},
+18 -43
View File
@@ -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<M extends RoleMeta>(
return next !== END;
}
function moderatorThreadContext<M extends RoleMeta>(params: {
threadId: string;
start: ThreadContext<M>["start"];
steps: RoleStep<M>[];
roles: Pick<WorkflowDefinition<M>, "roles">["roles"];
}): ThreadContext<M> {
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<M extends RoleMeta>(params: {
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
extract: ExtractConfig,
extract: ExtractFn,
): WorkflowFn {
return async function* workflowLoop(
input: ThreadInput,
options: WorkflowFnOptions,
): AsyncGenerator<RoleOutput, WorkflowResult> {
const nowMs = Date.now();
const start: ThreadContext<M>["start"] = {
const start: ModeratorContext<M>["start"] = {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
@@ -85,12 +59,11 @@ export function createWorkflow<M extends RoleMeta>(
};
}
const modCtx = moderatorThreadContext({
const modCtx: ModeratorContext<M> = {
threadId: options.threadId,
start,
steps,
roles: def.roles,
});
};
const next = def.moderator(modCtx);
@@ -103,20 +76,22 @@ export function createWorkflow<M extends RoleMeta>(
return { returnCode: 1, summary: `unknown role: ${next}` };
}
const ctx: ThreadContext<M> = {
threadId: options.threadId,
const agentCtx: AgentContext<M> = {
...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<M> = {
...agentCtx,
agentContent: raw,
extractPrompt: roleDef.extractPrompt,
};
const meta = await extract(roleDef.schema, extractCtx as unknown as ExtractContext);
const ts = Date.now();
const step = {
+13 -13
View File
@@ -1,26 +1,24 @@
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 = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
) => (ctx: ThreadContext) => Promise<T>;
ctx: ExtractContext,
) => 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.
* Builds prompt text from {@link ExtractContext} and calls 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> => {
return async <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
ctx: ExtractContext,
): Promise<T> => {
const lines: string[] = [];
lines.push("## Current Role");
lines.push(`## Role: ${ctx.currentRole.name}`);
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
lines.push("## Task");
@@ -35,8 +33,11 @@ export function createExtract(provider: LlmProvider): ExtractFn {
lines.push("");
}
}
lines.push("## Agent Output");
lines.push(ctx.agentContent);
lines.push("");
lines.push("## Extraction Instruction");
lines.push(prompt);
lines.push(ctx.extractPrompt);
const text = lines.join("\n");
const result = await llmExtractWithRetry({ text, schema, provider });
@@ -45,5 +46,4 @@ export function createExtract(provider: LlmProvider): ExtractFn {
}
return result.value;
};
};
}
-23
View File
@@ -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<T extends Record<string, unknown>>(
roleName: string,
raw: string,
schema: z.ZodType<T>,
options: { provider: LlmProvider },
): Promise<T> {
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;
}
+3 -2
View File
@@ -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,
+22 -13
View File
@@ -58,19 +58,32 @@ export type RoleStep<M extends RoleMeta> = {
[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<M extends RoleMeta = RoleMeta> = {
/** Phase 1: Moderator decides next role. */
export type ModeratorContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
currentRole: {
name: string;
systemPrompt: string;
};
start: StartStep;
steps: RoleStep<M>[];
};
/** Phase 2: Agent executes — knows its role and prompt. */
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
currentRole: {
name: string;
systemPrompt: string;
};
};
/** Phase 3: Extractor runs — has agent output and extract instruction. */
export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
agentContent: string;
extractPrompt: string;
};
/** Alias — most external consumers see the agent-phase context. */
export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
export type AgentFn = (ctx: ThreadContext) => Promise<string>;
export type AgentFn = (ctx: AgentContext) => Promise<string>;
/** Runtime agent assignment (optional per-role overrides). */
export type AgentBinding = {
@@ -78,15 +91,11 @@ export type AgentBinding = {
overrides?: Partial<Record<string, AgentFn>>;
};
/** Structured extraction settings for the workflow engine. */
export type ExtractConfig = {
provider: LlmProvider;
};
/** Role wiring: prompts, schema, and human-readable description. */
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
};
@@ -97,7 +106,7 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
* Returns the next role name or END to terminate.
*/
export type Moderator<M extends RoleMeta> = (
ctx: ThreadContext<M>,
ctx: ModeratorContext<M>,
) => (keyof M & string) | typeof END;
/** Complete workflow definition as authored by users. */