diff --git a/examples/hello-world.ts b/examples/hello-world.ts index b79ec03..e6c6ae5 100644 --- a/examples/hello-world.ts +++ b/examples/hello-world.ts @@ -1,4 +1,4 @@ -import { createRoleModerator, END, type RoleDefinition } from "@uncaged/workflow"; +import { createWorkflow, END, type RoleDefinition } from "@uncaged/workflow"; import * as z from "zod/v4"; type Roles = { @@ -25,16 +25,25 @@ export const descriptor = { const greeter: RoleDefinition = { description: "Generates a greeting", + systemPrompt: "You greet the user briefly.", schema: greeterMetaSchema, - run: async (ctx) => ({ - content: `Hello, ${ctx.start.content}`, - meta: { greeting: "Hello!" }, - }), + dryRunMeta: { greeting: "Hello!" }, }; -export const run = createRoleModerator({ - roles: { greeter }, - moderator(ctx) { - return ctx.steps.length === 0 ? "greeter" : END; +const extract = { + provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "" }, + dryRun: true, +} as const; + +export const run = createWorkflow( + { + roles: { greeter }, + moderator(ctx) { + return ctx.steps.length === 0 ? "greeter" : END; + }, }, -}); + { + agent: async (ctx) => `Hello, ${ctx.start.content}`, + }, + extract, +); diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index 6ff7862..901c43a 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -37,8 +37,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { const modelFlag = resolveCursorModel(config.model); const timeoutMs = config.timeout; - return async (ctx, systemPrompt) => { - const fullPrompt = buildAgentPrompt(systemPrompt, ctx); + return async (ctx) => { + const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx); const args = [ "-p", fullPrompt, diff --git a/packages/workflow-agent-hermes/src/index.ts b/packages/workflow-agent-hermes/src/index.ts index da7525d..2cdf581 100644 --- a/packages/workflow-agent-hermes/src/index.ts +++ b/packages/workflow-agent-hermes/src/index.ts @@ -34,8 +34,8 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn { const timeoutMs = config.timeout; - return async (ctx, systemPrompt) => { - const fullPrompt = buildAgentPrompt(systemPrompt, ctx); + return async (ctx) => { + const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx); const args = [ "chat", "-q", diff --git a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts index 34ead8d..f80e189 100644 --- a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts +++ b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts @@ -13,6 +13,7 @@ function makeCtx(userContent: string): ThreadContext { }, steps: [], threadId: "01TEST000000000000000000TR", + currentRole: { name: "planner", systemPrompt: "system instructions" }, }; } @@ -30,7 +31,7 @@ describe("createLlmAdapter", () => { const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const adapter = createLlmAdapter(provider); - const out = await adapter(makeCtx("trigger text"), "system instructions"); + const out = await adapter(makeCtx("trigger text")); globalThis.fetch = originalFetch; @@ -49,7 +50,7 @@ describe("createLlmAdapter", () => { const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const adapter = createLlmAdapter(provider); - await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:"); + await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:"); globalThis.fetch = originalFetch; }); @@ -59,7 +60,7 @@ describe("createLlmAdapter", () => { const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const adapter = createLlmAdapter(provider); - await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow(); + await expect(adapter(makeCtx("hi"))).rejects.toThrow(); globalThis.fetch = originalFetch; }); }); diff --git a/packages/workflow-agent-llm/package.json b/packages/workflow-agent-llm/package.json index 9cedb35..a2ed73a 100644 --- a/packages/workflow-agent-llm/package.json +++ b/packages/workflow-agent-llm/package.json @@ -4,16 +4,11 @@ "type": "module", "main": "src/index.ts", "types": "src/index.ts", - "exports": { - ".": "./src/index.ts" - }, "scripts": { "build": "echo 'TODO'", "test": "bun test" }, "dependencies": { - "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-util-role": "workspace:*", - "zod": "^4.0.0" + "@uncaged/workflow": "workspace:*" } } diff --git a/packages/workflow-agent-llm/src/create-llm-adapter.ts b/packages/workflow-agent-llm/src/create-llm-adapter.ts index ba18233..8780ec8 100644 --- a/packages/workflow-agent-llm/src/create-llm-adapter.ts +++ b/packages/workflow-agent-llm/src/create-llm-adapter.ts @@ -1,6 +1,14 @@ -import { type AgentFn, err, ok, type Result, type ThreadContext } from "@uncaged/workflow"; +import { + type AgentFn, + err, + type LlmProvider, + ok, + type Result, + type ThreadContext, +} from "@uncaged/workflow"; -import type { LlmMessage, LlmProvider } from "@uncaged/workflow-util-role"; +/** OpenAI chat completion message shape (passed to `/chat/completions`). */ +export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; export type LlmChatError = | { kind: "http_error"; status: number; body: string } @@ -89,13 +97,13 @@ export async function chatCompletionText(options: { return parseAssistantText(res.value); } -/** Single-turn chat adapter: system comes from `createRole` prompt; user is the thread start frame. */ +/** Single-turn chat adapter: system prompt comes from {@link ThreadContext.currentRole}. */ export function createLlmAdapter(provider: LlmProvider): AgentFn { - return async (ctx: ThreadContext, systemPrompt: string) => { + return async (ctx: ThreadContext) => { const result = await chatCompletionText({ provider, messages: [ - { role: "system", content: systemPrompt }, + { role: "system", content: ctx.currentRole.systemPrompt }, { role: "user", content: ctx.start.content }, ], }); diff --git a/packages/workflow-agent-llm/src/index.ts b/packages/workflow-agent-llm/src/index.ts index bff293a..3038f2e 100644 --- a/packages/workflow-agent-llm/src/index.ts +++ b/packages/workflow-agent-llm/src/index.ts @@ -1,20 +1,6 @@ export { - type CreateRoleArgs, - createRole, - decorateRole, - extractMetaOrThrow, - type LlmError, - type LlmExtractArgs, + chatCompletionText, + createLlmAdapter, + type LlmChatError, type LlmMessage, - type LlmProvider, - llmErrorToCause, - llmExtract, - llmExtractWithRetry, - type MetaExtractConfig, - type OnFailOptions, - onFail, - type RoleDecorator, - type WithDryRunOptions, - withDryRun, -} from "@uncaged/workflow-util-role"; -export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js"; +} from "./create-llm-adapter.js"; diff --git a/packages/workflow-agent-llm/tsconfig.json b/packages/workflow-agent-llm/tsconfig.json index 9227ea6..2816fef 100644 --- a/packages/workflow-agent-llm/tsconfig.json +++ b/packages/workflow-agent-llm/tsconfig.json @@ -6,5 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }] + "references": [{ "path": "../workflow" }] } diff --git a/packages/workflow-role-coder/package.json b/packages/workflow-role-coder/package.json index 9a35ac2..c70d800 100644 --- a/packages/workflow-role-coder/package.json +++ b/packages/workflow-role-coder/package.json @@ -10,8 +10,6 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-agent-llm": "workspace:*", - "@uncaged/workflow-util-role": "workspace:*", "zod": "^4.0.0" } } diff --git a/packages/workflow-role-coder/src/coder.ts b/packages/workflow-role-coder/src/coder.ts index 811de3c..3c17f2d 100644 --- a/packages/workflow-role-coder/src/coder.ts +++ b/packages/workflow-role-coder/src/coder.ts @@ -1,6 +1,4 @@ -import type { AgentFn, Role } from "@uncaged/workflow"; -import { createRole } from "@uncaged/workflow-agent-llm"; -import type { LlmProvider } from "@uncaged/workflow-util-role"; +import type { RoleDefinition } from "@uncaged/workflow"; import * as z from "zod/v4"; export const coderMetaSchema = z.object({ @@ -11,41 +9,17 @@ export const coderMetaSchema = z.object({ export type CoderMeta = z.infer; -export type CoderConfig = { - cwd: string; +const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only. +Report which phase you completed. List the files you changed and summarize what you did.`; + +export const coderRole: RoleDefinition = { + description: + "Implements the next incomplete planner phase and reports structured completion metadata.", + systemPrompt: CODER_SYSTEM, + schema: coderMetaSchema, + dryRunMeta: { + completedPhase: "phase-1", + filesChanged: [], + summary: "", + }, }; - -export const DEFAULT_CODER_CONFIG: CoderConfig = { - cwd: ".", -}; - -function coderSystemPrompt(config: CoderConfig): string { - return `You are a **coder**. The project is at \`${config.cwd}\`. - -Read the thread: the planner produced ordered **phases**. Identify the **next** phase that is not yet completed according to prior coder steps (each coder step reports a completedPhase). - -Implement **only that phase** — do not tackle multiple phases in one turn unless the planner defined a single phase. Follow project conventions; summarize what changed and list touched files. - -When done with the phase you worked on, set **completedPhase** to that phase's **name** exactly as given by the planner.`; -} - -/** - * Coder role: implements the next incomplete planner phase and reports structured completion metadata. - */ -export function createCoderRole( - adapter: AgentFn, - extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CoderMeta }, - config: CoderConfig = DEFAULT_CODER_CONFIG, -): Role { - return createRole({ - name: "coder", - schema: coderMetaSchema, - systemPrompt: coderSystemPrompt(config), - agent: adapter, - extract: { - provider: extract.provider, - dryRun: extract.dryRun, - dryRunMeta: extract.dryRunMeta, - }, - }); -} diff --git a/packages/workflow-role-coder/src/index.ts b/packages/workflow-role-coder/src/index.ts index a4d7498..03beed8 100644 --- a/packages/workflow-role-coder/src/index.ts +++ b/packages/workflow-role-coder/src/index.ts @@ -1,7 +1 @@ -export { - type CoderConfig, - type CoderMeta, - coderMetaSchema, - createCoderRole, - DEFAULT_CODER_CONFIG, -} from "./coder.js"; +export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js"; diff --git a/packages/workflow-role-coder/tsconfig.json b/packages/workflow-role-coder/tsconfig.json index ff7d45c..2816fef 100644 --- a/packages/workflow-role-coder/tsconfig.json +++ b/packages/workflow-role-coder/tsconfig.json @@ -6,9 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [ - { "path": "../workflow" }, - { "path": "../workflow-agent-llm" }, - { "path": "../workflow-util-role" } - ] + "references": [{ "path": "../workflow" }] } diff --git a/packages/workflow-role-committer/__tests__/committer.test.ts b/packages/workflow-role-committer/__tests__/committer.test.ts index 3eeb54c..36f63fe 100644 --- a/packages/workflow-role-committer/__tests__/committer.test.ts +++ b/packages/workflow-role-committer/__tests__/committer.test.ts @@ -1,135 +1,15 @@ -import { describe, expect, spyOn, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; -import type { AgentFn, ThreadContext } from "@uncaged/workflow"; -import { START } from "@uncaged/workflow"; -import * as utilRole from "@uncaged/workflow-util-role"; +import { committerMetaSchema, committerRole } from "../src/committer.js"; -import { createCommitterRole } from "../src/committer.js"; - -function makeCtx(): ThreadContext { - return { - threadId: "01TEST000000000000000000TR", - start: { - role: START, - content: "do thing", - meta: { maxRounds: 10 }, - timestamp: Date.now(), - }, - steps: [], - }; -} - -const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" }; - -const dryRunMeta = { - status: "committed" as const, - branch: "dry-run/placeholder", - commitSha: "0000000", -}; - -describe("createCommitterRole", () => { - test("dry-run skips pipeline", async () => { - const agent: AgentFn = async () => { - throw new Error("agent should not run"); - }; - const role = createCommitterRole(agent, { - provider, - dryRun: true, - dryRunMeta, - }); - const out = await role(makeCtx()); - expect(out.content).toBe("[dry-run] committer skipped"); - expect(out.meta).toEqual(dryRunMeta); +describe("committerRole", () => { + test("dryRunMeta validates against schema", () => { + const parsed = committerMetaSchema.safeParse(committerRole.dryRunMeta); + expect(parsed.success).toBe(true); }); - test("returns committed meta when extraction succeeds", async () => { - const committed = { - status: "committed" as const, - branch: "feat/widget", - commitSha: "deadbeef".repeat(5).slice(0, 40), - }; - - const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(committed); - - const agent: AgentFn = async (_ctx, prompt) => - `Created branch ${committed.branch}, pushed. SHA ${committed.commitSha}.\n${prompt.slice(0, 80)}…`; - - const role = createCommitterRole(agent, { - provider, - dryRun: null, - dryRunMeta, - }); - - const out = await role(makeCtx()); - expect(out.meta).toEqual(committed); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - test("returns failed meta when extraction reports failure", async () => { - const failed = { - status: "recoverable" as const, - error: "working tree clean; nothing to commit", - logRef: null as string | null, - }; - - const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed); - - const agent: AgentFn = async () => "git status shows no changes; skipping branch and commit."; - - const role = createCommitterRole(agent, { - provider, - dryRun: null, - dryRunMeta, - }); - - const out = await role(makeCtx()); - expect(out.meta).toEqual(failed); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - test("returns failed meta with logRef when extraction includes it", async () => { - const failed = { - status: "recoverable" as const, - error: "push rejected", - logRef: "LOGREF01", - }; - - spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed); - - const agent: AgentFn = async () => "Remote rejected non-fast-forward."; - - const role = createCommitterRole(agent, { - provider, - dryRun: null, - dryRunMeta, - }); - - const out = await role(makeCtx()); - expect(out.meta).toEqual(failed); - }); - - test("onFail wraps extraction errors", async () => { - spyOn(utilRole, "extractMetaOrThrow").mockRejectedValue( - new Error("structured extraction failed"), - ); - - const agent: AgentFn = async () => "opaque agent output"; - - const role = createCommitterRole(agent, { - provider, - dryRun: null, - dryRunMeta, - }); - - const out = await role(makeCtx()); - expect(out.meta).toEqual({ - status: "unrecoverable", - error: "committer role threw before structured result", - logRef: null, - }); - expect(out.content).toContain("committer failed:"); - expect(out.content).toContain("structured extraction failed"); + test("exposes generic committer system prompt", () => { + expect(committerRole.systemPrompt).toContain("git committer"); + expect(committerRole.systemPrompt).not.toContain("project is at"); }); }); diff --git a/packages/workflow-role-committer/package.json b/packages/workflow-role-committer/package.json index 7ee3649..27cfd4d 100644 --- a/packages/workflow-role-committer/package.json +++ b/packages/workflow-role-committer/package.json @@ -10,7 +10,6 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-util-role": "workspace:*", "zod": "^4.0.0" } } diff --git a/packages/workflow-role-committer/src/committer.ts b/packages/workflow-role-committer/src/committer.ts index 349362c..06778ed 100644 --- a/packages/workflow-role-committer/src/committer.ts +++ b/packages/workflow-role-committer/src/committer.ts @@ -1,11 +1,4 @@ -import type { AgentFn, Role } from "@uncaged/workflow"; -import { - createRole, - decorateRole, - type LlmProvider, - onFail, - withDryRun, -} from "@uncaged/workflow-util-role"; +import type { RoleDefinition } from "@uncaged/workflow"; import * as z from "zod/v4"; export const committerMetaSchema = z.discriminatedUnion("status", [ @@ -28,69 +21,17 @@ export const committerMetaSchema = z.discriminatedUnion("status", [ export type CommitterMeta = z.infer; -export type CommitterConfig = { - cwd: string; +const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push. +Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable. +Do not attempt to fix failures yourself.`; + +export const committerRole: RoleDefinition = { + description: "Creates branch, commits, and pushes when review passes.", + systemPrompt: COMMITTER_SYSTEM, + schema: committerMetaSchema, + dryRunMeta: { + status: "committed", + branch: "dry-run/placeholder", + commitSha: "0000000", + }, }; - -export const DEFAULT_COMMITTER_CONFIG: CommitterConfig = { - cwd: ".", -}; - -const DRY_RUN_COMMITTED_META: CommitterMeta = { - status: "committed", - branch: "dry-run/placeholder", - commitSha: "0000000", -}; - -function resolveExtractDryRun(extractDryRun: boolean | null): boolean { - return extractDryRun === true; -} - -function committerSystemPrompt(config: CommitterConfig): string { - return `You are the git committer for this workflow. The project is at \`${config.cwd}\`. - -## Task - -Create a branch, commit the changes, and push. Report whether the push succeeded or failed, the branch name, and the commit SHA. - -## On failure - -If any git operation fails, **do not attempt to fix it yourself**. Capture the key error output and classify it: - -- **Recoverable**: failures that a coder can fix (lint/test hook rejection, merge conflict, commit validation errors) -- **Unrecoverable**: failures beyond code changes (no push permission, remote not found, authentication denied, disk full)`; -} - -/** - * Git committer role: the agent runs git (branch, commit, push); structured extraction yields {@link CommitterMeta}. - * Dry-run skips the agent and returns a stable committed placeholder; unexpected throws yield `status: "failed"`. - */ -export function createCommitterRole( - adapter: AgentFn, - extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterMeta }, - config: CommitterConfig = DEFAULT_COMMITTER_CONFIG, -): Role { - const inner: Role = createRole({ - name: "committer", - schema: committerMetaSchema, - systemPrompt: committerSystemPrompt(config), - agent: adapter, - extract, - }); - - return decorateRole(inner, [ - withDryRun({ - label: "committer", - meta: DRY_RUN_COMMITTED_META, - dryRun: resolveExtractDryRun(extract.dryRun), - }), - onFail({ - label: "committer", - meta: { - status: "unrecoverable", - error: "committer role threw before structured result", - logRef: null, - }, - }), - ]); -} diff --git a/packages/workflow-role-committer/src/index.ts b/packages/workflow-role-committer/src/index.ts index 6691db1..199622c 100644 --- a/packages/workflow-role-committer/src/index.ts +++ b/packages/workflow-role-committer/src/index.ts @@ -1,7 +1 @@ -export { - type CommitterConfig, - type CommitterMeta, - committerMetaSchema, - createCommitterRole, - DEFAULT_COMMITTER_CONFIG, -} from "./committer.js"; +export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js"; diff --git a/packages/workflow-role-committer/tsconfig.json b/packages/workflow-role-committer/tsconfig.json index b88bcaf..2816fef 100644 --- a/packages/workflow-role-committer/tsconfig.json +++ b/packages/workflow-role-committer/tsconfig.json @@ -3,13 +3,8 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "composite": true, - "types": ["bun-types"] + "composite": true }, "include": ["src/**/*.ts"], - "references": [ - { "path": "../workflow" }, - { "path": "../workflow-util-agent" }, - { "path": "../workflow-util-role" } - ] + "references": [{ "path": "../workflow" }] } diff --git a/packages/workflow-role-planner/package.json b/packages/workflow-role-planner/package.json index 43e70e2..a9f9fc4 100644 --- a/packages/workflow-role-planner/package.json +++ b/packages/workflow-role-planner/package.json @@ -10,8 +10,6 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-agent-llm": "workspace:*", - "@uncaged/workflow-util-role": "workspace:*", "zod": "^4.0.0" } } diff --git a/packages/workflow-role-planner/src/index.ts b/packages/workflow-role-planner/src/index.ts index 20f12b4..50334a1 100644 --- a/packages/workflow-role-planner/src/index.ts +++ b/packages/workflow-role-planner/src/index.ts @@ -1,8 +1,6 @@ export { - createPlannerRole, - DEFAULT_PLANNER_CONFIG, - type PlannerConfig, type PlannerMeta, phaseSchema, plannerMetaSchema, + plannerRole, } from "./planner.js"; diff --git a/packages/workflow-role-planner/src/planner.ts b/packages/workflow-role-planner/src/planner.ts index 89f5276..cb7bb87 100644 --- a/packages/workflow-role-planner/src/planner.ts +++ b/packages/workflow-role-planner/src/planner.ts @@ -1,6 +1,4 @@ -import type { AgentFn, Role } from "@uncaged/workflow"; -import { createRole } from "@uncaged/workflow-agent-llm"; -import type { LlmProvider } from "@uncaged/workflow-util-role"; +import type { RoleDefinition } from "@uncaged/workflow"; import * as z from "zod/v4"; export const phaseSchema = z.object({ @@ -15,34 +13,17 @@ export const plannerMetaSchema = z.object({ export type PlannerMeta = z.infer; -/** Reserved for future planner options; empty for now. */ -export type PlannerConfig = Record; - -export const DEFAULT_PLANNER_CONFIG: PlannerConfig = {}; - const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time. Each phase must have: a short **name** (stable identifier), a **description** of what to do in that phase, and **acceptance** criteria for when that phase is done. Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases. Do not emit separate file lists or a free-form "approach" field — put that detail inside phase descriptions.`; -/** - * Planner role: produces ordered implementation phases for the coder to execute sequentially. - */ -export function createPlannerRole( - adapter: AgentFn, - extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: PlannerMeta }, - _config: PlannerConfig = DEFAULT_PLANNER_CONFIG, -): Role { - return createRole({ - name: "planner", - schema: plannerMetaSchema, - systemPrompt: PLANNER_SYSTEM, - agent: adapter, - extract: { - provider: extract.provider, - dryRun: extract.dryRun, - dryRunMeta: extract.dryRunMeta, - }, - }); -} +export const plannerRole: RoleDefinition = { + description: "Breaks the task into sequential phases for the coder.", + systemPrompt: PLANNER_SYSTEM, + schema: plannerMetaSchema, + dryRunMeta: { + phases: [{ name: "phase-1", description: "placeholder", acceptance: "placeholder" }], + }, +}; diff --git a/packages/workflow-role-planner/tsconfig.json b/packages/workflow-role-planner/tsconfig.json index ff7d45c..2816fef 100644 --- a/packages/workflow-role-planner/tsconfig.json +++ b/packages/workflow-role-planner/tsconfig.json @@ -6,9 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [ - { "path": "../workflow" }, - { "path": "../workflow-agent-llm" }, - { "path": "../workflow-util-role" } - ] + "references": [{ "path": "../workflow" }] } diff --git a/packages/workflow-role-reviewer/__tests__/reviewer.test.ts b/packages/workflow-role-reviewer/__tests__/reviewer.test.ts index d4003e9..beb6fcf 100644 --- a/packages/workflow-role-reviewer/__tests__/reviewer.test.ts +++ b/packages/workflow-role-reviewer/__tests__/reviewer.test.ts @@ -1,113 +1,15 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; -import type { AgentFn, ThreadContext } from "@uncaged/workflow"; -import { START } from "@uncaged/workflow"; +import { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js"; -import { createReviewerRole, DEFAULT_REVIEWER_CONFIG } from "../src/reviewer.js"; - -function toolCallResponse(argsJson: string): Response { - return new Response( - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { - function: { - name: "extract", - arguments: argsJson, - }, - }, - ], - }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); -} - -function makeCtx(): ThreadContext { - return { - threadId: "01TEST000000000000000000TR", - start: { - role: START, - content: "task", - meta: { maxRounds: 10 }, - timestamp: Date.now(), - }, - steps: [], - }; -} - -const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" }; - -describe("createReviewerRole", () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - mock.restore(); +describe("reviewerRole", () => { + test("dryRunMeta validates against schema", () => { + const parsed = reviewerMetaSchema.safeParse(reviewerRole.dryRunMeta); + expect(parsed.success).toBe(true); }); - test("approved verdict", async () => { - globalThis.fetch = (() => - Promise.resolve( - toolCallResponse(JSON.stringify({ status: "approved" })), - )) as unknown as typeof fetch; - - const agent: AgentFn = async (_ctx, prompt) => { - expect(prompt).toContain("code reviewer"); - expect(prompt).toContain(DEFAULT_REVIEWER_CONFIG.cwd); - return "review done"; - }; - - const role = createReviewerRole(agent, { - provider, - dryRun: null, - dryRunMeta: { status: "approved" }, - }); - const out = await role(makeCtx()); - expect(out.meta).toEqual({ status: "approved" }); - }); - - test("rejected verdict with issues", async () => { - globalThis.fetch = (() => - Promise.resolve( - toolCallResponse(JSON.stringify({ status: "rejected", issues: ["secrets in code"] })), - )) as unknown as typeof fetch; - - const agent: AgentFn = async () => "found problems"; - - const role = createReviewerRole(agent, { - provider, - dryRun: null, - dryRunMeta: { status: "approved" }, - }); - const out = await role(makeCtx()); - expect(out.meta).toEqual({ status: "rejected", issues: ["secrets in code"] }); - }); - - test("system prompt includes configured cwd (thread CLI hint comes from agent layer)", async () => { - globalThis.fetch = (() => - Promise.resolve( - toolCallResponse(JSON.stringify({ status: "approved" })), - )) as unknown as typeof fetch; - - let seen = ""; - const agent: AgentFn = async (_ctx, prompt) => { - seen = prompt; - return "x"; - }; - - const role = createReviewerRole( - agent, - { provider, dryRun: null, dryRunMeta: { status: "approved" } }, - { cwd: "/proj" }, - ); - await role(makeCtx()); - expect(seen).toContain("/proj"); - expect(seen).toContain("code reviewer"); - expect(seen).not.toContain("uncaged-workflow thread"); + test("system prompt is generic (no cwd)", () => { + expect(reviewerRole.systemPrompt).toContain("code reviewer"); + expect(reviewerRole.systemPrompt).not.toContain("project is at"); }); }); diff --git a/packages/workflow-role-reviewer/package.json b/packages/workflow-role-reviewer/package.json index 300d4f2..ace13d3 100644 --- a/packages/workflow-role-reviewer/package.json +++ b/packages/workflow-role-reviewer/package.json @@ -10,8 +10,6 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-agent-llm": "workspace:*", - "@uncaged/workflow-util-role": "workspace:*", "zod": "^4.0.0" } } diff --git a/packages/workflow-role-reviewer/src/index.ts b/packages/workflow-role-reviewer/src/index.ts index d39a797..0677b49 100644 --- a/packages/workflow-role-reviewer/src/index.ts +++ b/packages/workflow-role-reviewer/src/index.ts @@ -1,7 +1 @@ -export { - createReviewerRole, - DEFAULT_REVIEWER_CONFIG, - type ReviewerConfig, - type ReviewerMeta, - reviewerMetaSchema, -} from "./reviewer.js"; +export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js"; diff --git a/packages/workflow-role-reviewer/src/reviewer.ts b/packages/workflow-role-reviewer/src/reviewer.ts index c3e9afe..15fd3ce 100644 --- a/packages/workflow-role-reviewer/src/reviewer.ts +++ b/packages/workflow-role-reviewer/src/reviewer.ts @@ -1,6 +1,4 @@ -import type { AgentFn, Role } from "@uncaged/workflow"; -import { createRole } from "@uncaged/workflow-agent-llm"; -import type { LlmProvider } from "@uncaged/workflow-util-role"; +import type { RoleDefinition } from "@uncaged/workflow"; import * as z from "zod/v4"; export const reviewerMetaSchema = z.discriminatedUnion("status", [ @@ -14,45 +12,12 @@ export const reviewerMetaSchema = z.discriminatedUnion("status", [ ]); export type ReviewerMeta = z.infer; -export type ReviewerConfig = { - cwd: string; +const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict. +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, + schema: reviewerMetaSchema, + dryRunMeta: { status: "approved" }, }; - -export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = { - cwd: ".", -}; - -function reviewerPrompt(config: ReviewerConfig): string { - const { cwd } = config; - - return `You are a code reviewer. The project is at \`${cwd}\`. - -## Task - -Review the current git diff in \`${cwd}\`. Give a clear **approve** or **reject** verdict. - -Only reject for **blocking issues** — things that must be fixed before merge. Do not mention minor style preferences or non-blocking suggestions; they will be ignored. - -End with your verdict — clearly state whether the code is approved or rejected, and if rejected, list the blocking issues.`; -} - -/** - * Code review role: agent inspects git diffs; structured extract yields approve/reject verdict. - */ -export function createReviewerRole( - adapter: AgentFn, - extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: ReviewerMeta }, - config: ReviewerConfig = DEFAULT_REVIEWER_CONFIG, -): Role { - return createRole({ - name: "reviewer", - schema: reviewerMetaSchema, - systemPrompt: reviewerPrompt(config), - agent: adapter, - extract: { - provider: extract.provider, - dryRun: extract.dryRun, - dryRunMeta: extract.dryRunMeta, - }, - }); -} diff --git a/packages/workflow-role-reviewer/tsconfig.json b/packages/workflow-role-reviewer/tsconfig.json index ff7d45c..2816fef 100644 --- a/packages/workflow-role-reviewer/tsconfig.json +++ b/packages/workflow-role-reviewer/tsconfig.json @@ -6,9 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [ - { "path": "../workflow" }, - { "path": "../workflow-agent-llm" }, - { "path": "../workflow-util-role" } - ] + "references": [{ "path": "../workflow" }] } 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 44a5d03..d46bcfc 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,6 +1,5 @@ import { describe, expect, test } from "bun:test"; import { - type AgentFn, END, type RoleStep, START, @@ -11,8 +10,8 @@ import { import type { PlannerMeta } from "@uncaged/workflow-role-planner"; import { buildSolveIssueDescriptor } from "../src/descriptor.js"; -import { solveIssueModerator } from "../src/moderator.js"; -import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js"; +import { createSolveIssueRun, plannerRole, solveIssueModerator } from "../src/index.js"; +import type { SolveIssueMeta } from "../src/roles.js"; const DEFAULT_PHASES: PlannerMeta["phases"] = [ { name: "phase-a", description: "Do the work", acceptance: "Done" }, @@ -33,6 +32,7 @@ function makeCtx( ): ThreadContext { return { threadId: "01TEST000000000000000000TR", + currentRole: { name: START, systemPrompt: "" }, start: makeStart(maxRounds), steps, }; @@ -76,6 +76,11 @@ function committerStep(): RoleStep { }; } +const stubExtract = { + provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, + dryRun: true, +} as const; + describe("solveIssueModerator", () => { test("routes planner → coder → reviewer → committer → END", () => { expect(solveIssueModerator(makeCtx(20, []))).toBe("planner"); @@ -131,57 +136,53 @@ describe("solveIssueModerator", () => { }); }); -describe("createSolveIssueRoles", () => { - test("returns all four role callables", async () => { - const agent = async () => '{"phases":[{"name":"x","description":"d","acceptance":"a"}]}'; - const roles = createSolveIssueRoles({ - agent, - workdir: "/tmp/repo", - extract: null, - }); - - expect(typeof roles.planner.run).toBe("function"); - expect(typeof roles.coder.run).toBe("function"); - expect(typeof roles.reviewer.run).toBe("function"); - expect(typeof roles.committer.run).toBe("function"); - - const ctx = makeCtx(10, []); - const plannerOut = await roles.planner.run(ctx as unknown as ThreadContext); - expect(plannerOut.meta.phases).toEqual([ - { name: "phase-1", description: "placeholder", acceptance: "placeholder" }, - ]); +describe("createSolveIssueRun", () => { + test("dry-run extraction yields role dryRunMeta for planner", async () => { + const run = createSolveIssueRun({ agent: async () => "" }, stubExtract); + const gen = run( + { prompt: "task", steps: [] }, + { threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 }, + ); + const first = await gen.next(); + expect(first.done).toBe(false); + if (first.done) { + throw new Error("expected yield"); + } + expect(first.value.role).toBe("planner"); + expect(first.value.meta).toEqual(plannerRole.dryRunMeta); }); - test("per-role agents override default agent", async () => { + test("per-role agent overrides default", async () => { const calls: string[] = []; - const tag = - (label: string): AgentFn => - async () => { - calls.push(label); - return ""; - }; - - const roles = createSolveIssueRoles({ - agent: tag("default"), - agents: { - planner: tag("planner"), - coder: tag("coder"), + const run = createSolveIssueRun( + { + agent: async () => { + calls.push("default"); + return ""; + }, + overrides: { + planner: async () => { + calls.push("planner"); + return ""; + }, + coder: async () => { + calls.push("coder"); + return ""; + }, + }, }, - workdir: "/tmp/repo", - extract: null, - }); - - const ctx = makeCtx(10, []); - await roles.planner.run(ctx as unknown as ThreadContext); + stubExtract, + ); + const gen = run( + { prompt: "task", steps: [] }, + { threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 }, + ); + await gen.next(); expect(calls).toEqual(["planner"]); calls.length = 0; - await roles.coder.run(ctx as unknown as ThreadContext); + await gen.next(); expect(calls).toEqual(["coder"]); - - calls.length = 0; - await roles.reviewer.run(ctx as unknown as ThreadContext); - expect(calls).toEqual(["default"]); }); }); diff --git a/packages/workflow-template-solve-issue/package.json b/packages/workflow-template-solve-issue/package.json index ca2d697..fc0abfd 100644 --- a/packages/workflow-template-solve-issue/package.json +++ b/packages/workflow-template-solve-issue/package.json @@ -10,11 +10,9 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-agent-cursor": "workspace:*", "@uncaged/workflow-role-committer": "workspace:*", "@uncaged/workflow-role-coder": "workspace:*", "@uncaged/workflow-role-planner": "workspace:*", - "@uncaged/workflow-role-reviewer": "workspace:*", - "@uncaged/workflow-util-role": "workspace:*" + "@uncaged/workflow-role-reviewer": "workspace:*" } } diff --git a/packages/workflow-template-solve-issue/src/descriptor.ts b/packages/workflow-template-solve-issue/src/descriptor.ts index 2306fc4..21d1832 100644 --- a/packages/workflow-template-solve-issue/src/descriptor.ts +++ b/packages/workflow-template-solve-issue/src/descriptor.ts @@ -1,22 +1,12 @@ import { buildDescriptor } from "@uncaged/workflow"; import { solveIssueModerator } from "./moderator.js"; -import { - createSolveIssueRoles, - SOLVE_ISSUE_WORKFLOW_DESCRIPTION, - type SolveIssueRolesConfig, -} from "./roles.js"; - -const BUILD_DESCRIPTOR_CONFIG: SolveIssueRolesConfig = { - agent: async () => "", - workdir: "/tmp/uncaged-workflow-descriptor-stub", - extract: null, -}; +import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js"; export function buildSolveIssueDescriptor() { return buildDescriptor({ description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION, - roles: createSolveIssueRoles(BUILD_DESCRIPTOR_CONFIG), + roles: solveIssueRoles, moderator: solveIssueModerator, }); } diff --git a/packages/workflow-template-solve-issue/src/index.ts b/packages/workflow-template-solve-issue/src/index.ts index ce2393b..c8bda11 100644 --- a/packages/workflow-template-solve-issue/src/index.ts +++ b/packages/workflow-template-solve-issue/src/index.ts @@ -1,49 +1,50 @@ -import { createRoleModerator, type WorkflowDefinition, type WorkflowFn } from "@uncaged/workflow"; +import { + type AgentBinding, + createWorkflow, + type ExtractConfig, + type WorkflowDefinition, + type WorkflowFn, +} from "@uncaged/workflow"; import { solveIssueModerator } from "./moderator.js"; -import { - createSolveIssueRoles, - SOLVE_ISSUE_WORKFLOW_DESCRIPTION, - type SolveIssueMeta, - type SolveIssueRolesConfig, -} from "./roles.js"; +import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js"; -export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor"; export { type CoderMeta, coderMetaSchema, - createCoderRole, + coderRole, } from "@uncaged/workflow-role-coder"; export { - createPlannerRole, + type CommitterMeta, + committerMetaSchema, + committerRole, +} from "@uncaged/workflow-role-committer"; +export { type PlannerMeta, phaseSchema, plannerMetaSchema, + plannerRole, } from "@uncaged/workflow-role-planner"; +export { + type ReviewerMeta, + reviewerMetaSchema, + reviewerRole, +} from "@uncaged/workflow-role-reviewer"; export { buildSolveIssueDescriptor } from "./descriptor.js"; export { solveIssueModerator } from "./moderator.js"; export { - createSolveIssueRoles, SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, type SolveIssueRoles, - type SolveIssueRolesConfig, + solveIssueRoles, } from "./roles.js"; -export function createSolveIssueWorkflowDefinition( - config: SolveIssueRolesConfig, -): WorkflowDefinition { - return { - description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION, - roles: createSolveIssueRoles(config), - moderator: solveIssueModerator, - }; -} +export const solveIssueWorkflowDefinition: WorkflowDefinition = { + description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION, + roles: solveIssueRoles, + moderator: solveIssueModerator, +}; -/** - * Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result - * to the bundle `run` export pattern (`createRoleModerator` is already applied). - */ -export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn { - return createRoleModerator(createSolveIssueWorkflowDefinition(config)); +export function createSolveIssueRun(binding: AgentBinding, extract: ExtractConfig): WorkflowFn { + return createWorkflow(solveIssueWorkflowDefinition, binding, extract); } diff --git a/packages/workflow-template-solve-issue/src/roles.ts b/packages/workflow-template-solve-issue/src/roles.ts index 2091114..6cab9d3 100644 --- a/packages/workflow-template-solve-issue/src/roles.ts +++ b/packages/workflow-template-solve-issue/src/roles.ts @@ -1,27 +1,8 @@ -import type { AgentFn, RoleDefinition } from "@uncaged/workflow"; -import { type CoderMeta, coderMetaSchema, createCoderRole } from "@uncaged/workflow-role-coder"; -import { - type CommitterMeta, - committerMetaSchema, - createCommitterRole, -} from "@uncaged/workflow-role-committer"; -import { - createPlannerRole, - type PlannerMeta, - plannerMetaSchema, -} from "@uncaged/workflow-role-planner"; -import { - createReviewerRole, - type ReviewerMeta, - reviewerMetaSchema, -} from "@uncaged/workflow-role-reviewer"; -import type { LlmProvider } from "@uncaged/workflow-util-role"; - -const DRY_RUN_PROVIDER: LlmProvider = { - baseUrl: "http://127.0.0.1:9", - apiKey: "", - model: "template-dry-run", -}; +import type { RoleDefinition } from "@uncaged/workflow"; +import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder"; +import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer"; +import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner"; +import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer"; export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION = "Phased plan, incremental implementation per phase, review, and commit to resolve an issue end-to-end (planner → coder [repeat per phase] → reviewer → committer)."; @@ -33,142 +14,13 @@ export type SolveIssueMeta = { committer: CommitterMeta; }; -const PLANNER_DRY_RUN_META: PlannerMeta = { - phases: [ - { - name: "phase-1", - description: "placeholder", - acceptance: "placeholder", - }, - ], -}; - -const CODER_DRY_RUN_META: CoderMeta = { - completedPhase: "phase-1", - filesChanged: [], - summary: "", -}; - -const REVIEWER_DRY_RUN_META: ReviewerMeta = { - status: "approved", -}; - -const COMMITTER_DRY_RUN_META: CommitterMeta = { - status: "committed", - branch: "dry-run/placeholder", - commitSha: "0000000", -}; - -/** Wiring for workflow-role LLM structured extraction. Use `null` for stub extract (dry-run meta from built-in placeholders). */ -export type SolveIssueRolesConfig = { - agent: AgentFn; - agents?: Partial<{ - planner: AgentFn; - coder: AgentFn; - reviewer: AgentFn; - committer: AgentFn; - }>; - workdir: string; - extract: { provider: LlmProvider; dryRun: boolean | null } | null; -}; - -function resolveRoleAgent( - config: SolveIssueRolesConfig, - role: keyof NonNullable, -): AgentFn { - return config.agents?.[role] ?? config.agent; -} - -function resolveExtract(config: SolveIssueRolesConfig): { - provider: LlmProvider; - dryRun: boolean | null; -} { - if (config.extract === null) { - return { provider: DRY_RUN_PROVIDER, dryRun: true }; - } - return config.extract; -} - export type SolveIssueRoles = { - planner: RoleDefinition; - coder: RoleDefinition; - reviewer: RoleDefinition; - committer: RoleDefinition; + [K in keyof SolveIssueMeta]: RoleDefinition; }; -export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles { - const extract = resolveExtract(config); - const reviewerConfig = { - cwd: config.workdir, - }; - const committerConfig = { - cwd: config.workdir, - }; - const coderConfig = { - cwd: config.workdir, - }; - - const plannerAgent = resolveRoleAgent(config, "planner"); - const coderAgent = resolveRoleAgent(config, "coder"); - const reviewerAgent = resolveRoleAgent(config, "reviewer"); - const committerAgent = resolveRoleAgent(config, "committer"); - - const plannerRun = createPlannerRole(plannerAgent, { - provider: extract.provider, - dryRun: extract.dryRun, - dryRunMeta: PLANNER_DRY_RUN_META, - }); - - const coderRun = createCoderRole( - coderAgent, - { - provider: extract.provider, - dryRun: extract.dryRun, - dryRunMeta: CODER_DRY_RUN_META, - }, - coderConfig, - ); - - const reviewerRun = createReviewerRole( - reviewerAgent, - { - provider: extract.provider, - dryRun: extract.dryRun, - dryRunMeta: REVIEWER_DRY_RUN_META, - }, - reviewerConfig, - ); - - const committerRun = createCommitterRole( - committerAgent, - { - provider: extract.provider, - dryRun: extract.dryRun, - dryRunMeta: COMMITTER_DRY_RUN_META, - }, - committerConfig, - ); - - return { - planner: { - description: "Analyzes the issue and emits ordered implementation phases.", - run: plannerRun, - schema: plannerMetaSchema, - }, - coder: { - description: "Implements the next incomplete phase and reports completedPhase.", - run: coderRun, - schema: coderMetaSchema, - }, - reviewer: { - description: "Runs git diff checks and sets approved when the change is ready.", - run: reviewerRun, - schema: reviewerMetaSchema, - }, - committer: { - description: "Creates branch, commits, and pushes when review passes.", - run: committerRun, - schema: committerMetaSchema, - }, - }; -} +export const solveIssueRoles: SolveIssueRoles = { + planner: plannerRole, + coder: coderRole, + reviewer: reviewerRole, + committer: committerRole, +}; diff --git a/packages/workflow-template-solve-issue/tsconfig.json b/packages/workflow-template-solve-issue/tsconfig.json index 8cf3619..5963394 100644 --- a/packages/workflow-template-solve-issue/tsconfig.json +++ b/packages/workflow-template-solve-issue/tsconfig.json @@ -11,7 +11,6 @@ { "path": "../workflow-role-coder" }, { "path": "../workflow-role-committer" }, { "path": "../workflow-role-planner" }, - { "path": "../workflow-role-reviewer" }, - { "path": "../workflow-util-role" } + { "path": "../workflow-role-reviewer" } ] } 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 e274b4a..920cb61 100644 --- a/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts +++ b/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts @@ -18,6 +18,7 @@ describe("buildAgentPrompt", () => { start: startTask("fix the bug"), steps: [], threadId: "01TEST000000000000000000TR", + currentRole: { name: START, systemPrompt: "" }, }; const text = buildAgentPrompt("You are an agent.", ctx); expect(text).toContain("You are an agent."); @@ -30,6 +31,7 @@ describe("buildAgentPrompt", () => { const ctx: ThreadContext = { start: startTask("user task"), threadId: "01TEST000000000000000000TR", + currentRole: { name: "coder", systemPrompt: "" }, steps: [ { role: "coder", @@ -53,6 +55,7 @@ describe("buildAgentPrompt", () => { const ctx: ThreadContext = { start: startTask("first message full: task content here"), threadId: "01TEST000000000000000000TR", + currentRole: { name: "coder", systemPrompt: "" }, steps: [ { role: "planner", @@ -85,6 +88,7 @@ describe("buildAgentPrompt", () => { const ctx: ThreadContext = { start: startTask("start"), threadId: "01TEST000000000000000000TR", + currentRole: { name: "c", systemPrompt: "" }, steps: [ { role: "a", diff --git a/packages/workflow-util-role/__tests__/create-role.test.ts b/packages/workflow-util-role/__tests__/create-role.test.ts deleted file mode 100644 index ef6a7e2..0000000 --- a/packages/workflow-util-role/__tests__/create-role.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; -import type { AgentFn, ThreadContext } from "@uncaged/workflow"; -import { START } from "@uncaged/workflow"; -import * as extractMetaModule from "@uncaged/workflow-util-role"; -import * as z from "zod/v4"; - -import { createRole } from "../src/create-role.js"; - -const provider = { - baseUrl: "https://example.com/v1", - apiKey: "k", - model: "m", -}; - -function toolCallResponse(argsJson: string): Response { - return new Response( - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { - function: { - name: "extract", - arguments: argsJson, - }, - }, - ], - }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); -} - -function makeCtx(): ThreadContext { - return { - start: { - role: START, - content: "", - meta: { maxRounds: 10 }, - timestamp: Date.now(), - }, - steps: [], - threadId: "01TEST000000000000000000TR", - }; -} - -describe("createRole", () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - mock.restore(); - }); - - test("runs AgentFn then structured extract", async () => { - globalThis.fetch = (() => - Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })))) as unknown as typeof fetch; - - const schema = z.object({ n: z.number() }); - const agent: AgentFn = async (_ctx, prompt) => prompt; - const role = createRole({ - name: "test", - schema, - systemPrompt: "hello", - agent, - extract: { provider, dryRun: null, dryRunMeta: { n: 0 } }, - }); - - const out = await role(makeCtx()); - expect(out.content).toBe("hello"); - expect(out.meta).toEqual({ n: 3 }); - }); - - test("passes ThreadContext to AgentFn", async () => { - globalThis.fetch = (() => - Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })))) as unknown as typeof fetch; - - const seen: ThreadContext[] = []; - const agent: AgentFn = async (ctx, _prompt) => { - seen.push(ctx); - return "x"; - }; - const role = createRole({ - name: "test", - schema: z.object({ n: z.number() }), - systemPrompt: "p", - agent, - extract: { provider, dryRun: null, dryRunMeta: { n: 0 } }, - }); - await role(makeCtx()); - - expect(seen).toHaveLength(1); - expect(seen[0].steps).toEqual([]); - }); - - test("extract dryRun null runs live extract path", async () => { - const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); - - const agent: AgentFn = async () => "raw"; - const role = createRole({ - name: "r1", - schema: z.object({ n: z.number() }), - systemPrompt: "p", - agent, - extract: { provider, dryRun: null, dryRunMeta: { n: 0 } }, - }); - await role(makeCtx()); - - expect(spy).toHaveBeenCalledWith( - "r1", - "raw", - expect.anything(), - expect.objectContaining({ provider, dryRun: false, dryRunMeta: { n: 0 } }), - ); - }); - - test("extract.dryRun true uses structured extract dry-run", async () => { - const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); - - const agent: AgentFn = async () => "raw"; - const role = createRole({ - name: "r2", - schema: z.object({ n: z.number() }), - systemPrompt: "p", - agent, - extract: { provider, dryRun: true, dryRunMeta: { n: 0 } }, - }); - await role(makeCtx()); - - expect(spy).toHaveBeenCalledWith( - "r2", - "raw", - expect.anything(), - expect.objectContaining({ dryRun: true, dryRunMeta: { n: 0 } }), - ); - }); -}); diff --git a/packages/workflow-util-role/__tests__/decorators.test.ts b/packages/workflow-util-role/__tests__/decorators.test.ts deleted file mode 100644 index 270c962..0000000 --- a/packages/workflow-util-role/__tests__/decorators.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Role, ThreadContext } from "@uncaged/workflow"; -import { START } from "@uncaged/workflow"; - -import { decorateRole, onFail, withDryRun } from "../src/decorators.js"; - -type TestMeta = Record & { ok: boolean }; - -function fakeCtx(): ThreadContext { - return { - start: { - role: START, - content: "", - meta: { - maxRounds: 10, - }, - timestamp: Date.now(), - }, - steps: [], - threadId: "01TEST000000000000000000TR", - }; -} - -const successRole: Role = async () => ({ - content: "done", - meta: { ok: true }, -}); - -const failRole: Role = async () => { - throw new Error("boom"); -}; - -const failNonErrorRole: Role = async () => { - throw "string error"; -}; - -describe("withDryRun", () => { - test("short-circuits on dry-run", async () => { - const dec = withDryRun({ label: "test", meta: { ok: true }, dryRun: true }); - const role = dec(successRole); - const result = await role(fakeCtx()); - expect(result.content).toBe("[dry-run] test skipped"); - expect(result.meta).toEqual({ ok: true }); - }); - - test("delegates when not dry-run", async () => { - const innerDec = withDryRun({ label: "test", meta: { ok: true }, dryRun: false }); - const role = innerDec(successRole); - const result = await role(fakeCtx()); - expect(result.content).toBe("done"); - expect(result.meta).toEqual({ ok: true }); - }); -}); - -describe("onFail", () => { - test("passes through on success", async () => { - const dec = onFail({ label: "test", meta: { ok: false } }); - const role = dec(successRole); - const result = await role(fakeCtx()); - expect(result.content).toBe("done"); - expect(result.meta).toEqual({ ok: true }); - }); - - test("catches Error and returns structured failure", async () => { - const dec = onFail({ label: "test", meta: { ok: false } }); - const role = dec(failRole); - const result = await role(fakeCtx()); - expect(result.content).toBe("test failed: boom"); - expect(result.meta).toEqual({ ok: false }); - }); - - test("catches non-Error throws", async () => { - const dec = onFail({ label: "test", meta: { ok: false } }); - const role = dec(failNonErrorRole); - const result = await role(fakeCtx()); - expect(result.content).toBe("test failed: string error"); - expect(result.meta).toEqual({ ok: false }); - }); -}); - -describe("decorateRole", () => { - test("applies decorators left-to-right", async () => { - const role = decorateRole(failRole, [ - withDryRun({ label: "x", meta: { ok: true }, dryRun: false }), - onFail({ label: "x", meta: { ok: false } }), - ]); - const result = await role(fakeCtx()); - expect(result.content).toBe("x failed: boom"); - expect(result.meta).toEqual({ ok: false }); - }); - - test("dry-run short-circuits before onFail", async () => { - const role = decorateRole(failRole, [ - withDryRun({ label: "x", meta: { ok: true }, dryRun: true }), - onFail({ label: "x", meta: { ok: false } }), - ]); - const result = await role(fakeCtx()); - expect(result.content).toBe("[dry-run] x skipped"); - expect(result.meta).toEqual({ ok: true }); - }); -}); diff --git a/packages/workflow-util-role/__tests__/extract-meta.test.ts b/packages/workflow-util-role/__tests__/extract-meta.test.ts deleted file mode 100644 index 6e3de1a..0000000 --- a/packages/workflow-util-role/__tests__/extract-meta.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import * as z from "zod/v4"; - -import { extractMetaOrThrow } from "../src/extract-meta.js"; - -const provider = { - baseUrl: "https://example.com/v1", - apiKey: "k", - model: "m", -}; - -describe("extractMetaOrThrow", () => { - const originalFetch = globalThis.fetch; - - test("dryRun returns dryRunMeta without calling fetch", async () => { - let calls = 0; - globalThis.fetch = (() => { - calls += 1; - return Promise.resolve(new Response("{}", { status: 200 })); - }) as unknown as typeof fetch; - - const schema = z.object({ n: z.number() }); - const out = await extractMetaOrThrow("r", "raw", schema, { - provider, - dryRun: true, - dryRunMeta: { n: 7 }, - }); - - globalThis.fetch = originalFetch; - - expect(calls).toBe(0); - expect(out).toEqual({ n: 7 }); - }); - - test("throws when extraction fails after retry", async () => { - globalThis.fetch = (() => - Promise.resolve( - new Response( - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { function: { name: "extract", arguments: JSON.stringify({ n: "bad" }) } }, - ], - }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - )) as unknown as typeof fetch; - - const schema = z.object({ n: z.number() }); - - await expect( - extractMetaOrThrow("plan", "text", schema, { provider, dryRun: false, dryRunMeta: { n: 0 } }), - ).rejects.toThrow(/structured extraction failed after retry/); - - globalThis.fetch = originalFetch; - }); - - test("returns validated meta on successful tool call", async () => { - globalThis.fetch = (() => - Promise.resolve( - new Response( - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { - function: { - name: "extract", - arguments: JSON.stringify({ branch: "feat/x", message: "feat: y" }), - }, - }, - ], - }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - )) as unknown as typeof fetch; - - const schema = z.object({ - branch: z.string(), - message: z.string(), - }); - - const out = await extractMetaOrThrow("committer-plan", "plan text", schema, { - provider, - dryRun: false, - dryRunMeta: { branch: "", message: "" }, - }); - - globalThis.fetch = originalFetch; - - expect(out).toEqual({ branch: "feat/x", message: "feat: y" }); - }); -}); diff --git a/packages/workflow-util-role/__tests__/llm-extract.test.ts b/packages/workflow-util-role/__tests__/llm-extract.test.ts deleted file mode 100644 index 836ebab..0000000 --- a/packages/workflow-util-role/__tests__/llm-extract.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import * as z from "zod/v4"; - -import { llmExtract } from "../src/llm-extract.js"; - -describe("llmExtract", () => { - const originalFetch = globalThis.fetch; - - test("parses tool call arguments and validates with the zod schema", async () => { - const schema = z - .object({ - name: z.string(), - description: z.string(), - }) - .describe("Extract sense metadata from plan"); - - let capturedUrl: string | null = null; - let capturedInit: RequestInit | null = null; - - globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => { - capturedUrl = typeof input === "string" ? input : input.toString(); - capturedInit = init ?? null; - return Promise.resolve( - new Response( - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { - function: { - name: "extract", - arguments: JSON.stringify({ - name: "cpu-usage", - description: "CPU load", - }), - }, - }, - ], - }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - }) as unknown as typeof fetch; - - const result = await llmExtract({ - text: "some plan", - schema, - provider: { - baseUrl: "https://example.com/v1", - apiKey: "k", - model: "m", - }, - dryRun: false, - dryRunMeta: { name: "", description: "" }, - }); - - globalThis.fetch = originalFetch; - - expect(result.ok).toBe(true); - if (!result.ok) { - return; - } - expect(result.value).toEqual({ name: "cpu-usage", description: "CPU load" }); - - expect(capturedUrl!).toBe("https://example.com/v1/chat/completions"); - expect(capturedInit!.method).toBe("POST"); - expect(capturedInit!.headers).toMatchObject({ - Authorization: "Bearer k", - "Content-Type": "application/json", - }); - const body = JSON.parse(capturedInit!.body as string) as { - model: string; - tool_choice: { function: { name: string } }; - }; - expect(body.model).toBe("m"); - expect(body.tool_choice.function.name).toBeDefined(); - }); - - test("returns schema_validation_failed when arguments do not match the schema", async () => { - const schema = z.object({ n: z.number() }); - - globalThis.fetch = (() => - Promise.resolve( - new Response( - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { function: { name: "extract", arguments: JSON.stringify({ n: "oops" }) } }, - ], - }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - )) as unknown as typeof fetch; - - const result = await llmExtract({ - text: "x", - schema, - provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" }, - dryRun: false, - dryRunMeta: { n: 0 }, - }); - - globalThis.fetch = originalFetch; - - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error.kind).toBe("schema_validation_failed"); - }); - - test("dryRun skips fetch and returns dryRunMeta", async () => { - let calls = 0; - globalThis.fetch = (() => { - calls += 1; - return Promise.resolve(new Response("{}", { status: 200 })); - }) as unknown as typeof fetch; - - const schema = z.object({ n: z.number() }); - const result = await llmExtract({ - text: "ignored", - schema, - provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" }, - dryRun: true, - dryRunMeta: { n: 42 }, - }); - - globalThis.fetch = originalFetch; - - expect(calls).toBe(0); - expect(result.ok).toBe(true); - if (!result.ok) { - return; - } - expect(result.value).toEqual({ n: 42 }); - }); -}); diff --git a/packages/workflow-util-role/package.json b/packages/workflow-util-role/package.json deleted file mode 100644 index e4fc41a..0000000 --- a/packages/workflow-util-role/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@uncaged/workflow-util-role", - "version": "0.1.0", - "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "build": "echo 'TODO'", - "test": "bun test" - }, - "dependencies": { - "@uncaged/workflow": "workspace:*", - "zod": "^4.0.0" - } -} diff --git a/packages/workflow-util-role/src/create-role.ts b/packages/workflow-util-role/src/create-role.ts deleted file mode 100644 index 6199be6..0000000 --- a/packages/workflow-util-role/src/create-role.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow"; -import type * as z from "zod/v4"; -import { extractMetaOrThrow } from "./extract-meta.js"; -import type { LlmProvider } from "./types.js"; - -export type CreateRoleArgs> = { - name: string; - schema: z.ZodType; - systemPrompt: string; - agent: AgentFn; - extract: { - provider: LlmProvider; - /** When `true`, structured extract returns `dryRunMeta`. When `null`, live API extract. */ - dryRun: boolean | null; - dryRunMeta: M; - }; -}; - -function resolveExtractDryRun(extractDryRun: boolean | null): boolean { - return extractDryRun === true; -} - -/** Builds a {@link Role} from an {@link AgentFn}, system prompt, Zod meta schema, and extract wiring. */ -export function createRole>(args: CreateRoleArgs): Role { - return async (ctx: ThreadContext) => { - const raw = await args.agent(ctx, args.systemPrompt); - const meta = await extractMetaOrThrow(args.name, raw, args.schema, { - provider: args.extract.provider, - dryRun: resolveExtractDryRun(args.extract.dryRun), - dryRunMeta: args.extract.dryRunMeta, - }); - return { content: raw, meta }; - }; -} diff --git a/packages/workflow-util-role/src/decorators.ts b/packages/workflow-util-role/src/decorators.ts deleted file mode 100644 index dd2864c..0000000 --- a/packages/workflow-util-role/src/decorators.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Role, ThreadContext } from "@uncaged/workflow"; - -/** A role decorator: takes a role, returns an enhanced role. */ -export type RoleDecorator> = (role: Role) => Role; - -/** - * Apply an ordered list of decorators to a role. - * Decorators are applied left-to-right (first in list wraps innermost). - */ -export function decorateRole>( - role: Role, - decorators: RoleDecorator[], -): Role { - return decorators.reduce((r, dec) => dec(r), role); -} - -export type WithDryRunOptions> = { - /** Used in skip message (e.g. "committer", "publish"). */ - label: string; - /** Meta returned when dry-run skips execution. */ - meta: M; - /** Adapter-level dry-run flag (e.g. from extract / wiring config). */ - dryRun: boolean; -}; - -/** Short-circuits with a stable result when `dryRun` is true. */ -export function withDryRun>( - opts: WithDryRunOptions, -): RoleDecorator { - return (role) => async (ctx: ThreadContext) => { - if (opts.dryRun) { - return { - content: `[dry-run] ${opts.label} skipped`, - meta: opts.meta, - }; - } - return role(ctx); - }; -} - -export type OnFailOptions> = { - /** Used in failure message (e.g. "committer", "publish"). */ - label: string; - /** Meta returned when the inner role throws. */ - meta: M; -}; - -/** Catches thrown errors and converts them into a structured {@link Role} result instead of propagating. */ -export function onFail>( - opts: OnFailOptions, -): RoleDecorator { - return (role) => async (ctx: ThreadContext) => { - try { - return await role(ctx); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return { - content: `${opts.label} failed: ${msg}`, - meta: opts.meta, - }; - } - }; -} diff --git a/packages/workflow-util-role/src/index.ts b/packages/workflow-util-role/src/index.ts deleted file mode 100644 index c7b735a..0000000 --- a/packages/workflow-util-role/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { type CreateRoleArgs, createRole } from "./create-role.js"; -export { - decorateRole, - type OnFailOptions, - onFail, - type RoleDecorator, - type WithDryRunOptions, - withDryRun, -} from "./decorators.js"; -export { extractMetaOrThrow } from "./extract-meta.js"; -export { - type LlmError, - type LlmExtractArgs, - llmErrorToCause, - llmExtract, - llmExtractWithRetry, -} from "./llm-extract.js"; -export type { LlmMessage, LlmProvider, MetaExtractConfig } from "./types.js"; diff --git a/packages/workflow-util-role/src/types.ts b/packages/workflow-util-role/src/types.ts deleted file mode 100644 index 57f5ff0..0000000 --- a/packages/workflow-util-role/src/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type * as z from "zod/v4"; - -export type LlmProvider = { - baseUrl: string; - apiKey: string; - model: string; -}; - -export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; - -/** Pairs an OpenAI-compatible provider with the Zod meta schema used for structured extraction. */ -export type MetaExtractConfig = { - provider: LlmProvider; - schema: z.ZodType; -}; diff --git a/packages/workflow-util-role/tsconfig.json b/packages/workflow-util-role/tsconfig.json deleted file mode 100644 index 2816fef..0000000 --- a/packages/workflow-util-role/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "composite": true - }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }] -} diff --git a/packages/workflow/__tests__/build-descriptor.test.ts b/packages/workflow/__tests__/build-descriptor.test.ts index 3ff2619..a6e5c55 100644 --- a/packages/workflow/__tests__/build-descriptor.test.ts +++ b/packages/workflow/__tests__/build-descriptor.test.ts @@ -19,8 +19,9 @@ describe("buildDescriptor", () => { roles: { analyst: { description: "Analyzes input", + systemPrompt: "You are an analyst.", schema, - run: async () => ({ content: "", meta: { title: "", count: 0 } }), + dryRunMeta: { title: "", count: 0 }, }, }, moderator: () => END, diff --git a/packages/workflow/__tests__/engine.test.ts b/packages/workflow/__tests__/engine.test.ts index c0fe96c..fdfc03c 100644 --- a/packages/workflow/__tests__/engine.test.ts +++ b/packages/workflow/__tests__/engine.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import * as z from "zod/v4"; -import { createRoleModerator } from "../src/create-role-moderator.js"; +import { createWorkflow } from "../src/create-workflow.js"; import { executeThread } from "../src/engine.js"; import { createLogger } from "../src/logger.js"; import { END } from "../src/types.js"; @@ -23,35 +23,46 @@ type DemoMeta = { coder: z.infer; }; -const demoWorkflow = createRoleModerator({ - roles: { - planner: { - description: "Demo planner", - schema: plannerMetaSchema, - run: async () => ({ - content: "plan-body", - meta: { plan: "do-it", files: ["a.ts"] }, - }), +const demoExtract = { + provider: { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" }, + dryRun: true, +} as const; + +const demoWorkflow = createWorkflow( + { + roles: { + planner: { + description: "Demo planner", + systemPrompt: "You are a planner.", + schema: plannerMetaSchema, + dryRunMeta: { plan: "do-it", files: ["a.ts"] }, + }, + coder: { + description: "Demo coder", + systemPrompt: "You are a coder.", + schema: coderMetaSchema, + dryRunMeta: { diff: "+ok" }, + }, }, - coder: { - description: "Demo coder", - schema: coderMetaSchema, - run: async () => ({ - content: "code-body", - meta: { diff: "+ok" }, - }), + moderator: (ctx) => { + if (ctx.steps.length === 0) { + return "planner"; + } + if (ctx.steps.length === 1) { + return "coder"; + } + return END; }, }, - moderator: (ctx) => { - if (ctx.steps.length === 0) { - return "planner"; - } - if (ctx.steps.length === 1) { - return "coder"; - } - return END; + { + agent: async () => "unused", + overrides: { + planner: async () => "plan-body", + coder: async () => "code-body", + }, }, -}); + demoExtract, +); describe("executeThread", () => { test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => { diff --git a/packages/workflow/src/create-role-moderator.ts b/packages/workflow/src/create-role-moderator.ts deleted file mode 100644 index 5d24bf5..0000000 --- a/packages/workflow/src/create-role-moderator.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - END, - type RoleMeta, - type RoleOutput, - type RoleStep, - START, - type ThreadContext, - type ThreadInput, - type WorkflowDefinition, - type WorkflowFn, - type WorkflowFnOptions, - type WorkflowResult, -} from "./types.js"; - -function isRoleNext( - next: (keyof M & string) | typeof END, -): next is keyof M & string { - return next !== END; -} - -/** - * Role + Moderator pattern as an optional helper: returns a {@link WorkflowFn} that runs the - * moderator loop and yields each {@link RoleOutput}. Assign with `export const run = createRoleModerator(...)`. - */ -export function createRoleModerator( - def: Pick, "roles" | "moderator">, -): WorkflowFn { - return async function* roleModeratorWorkflow( - input: ThreadInput, - options: WorkflowFnOptions, - ): AsyncGenerator { - const nowMs = Date.now(); - const start: ThreadContext["start"] = { - role: START, - content: input.prompt, - meta: { maxRounds: options.maxRounds }, - timestamp: nowMs, - }; - - const baseTs = Date.now(); - let steps: RoleStep[] = input.steps.map((out, i) => ({ - role: out.role, - content: out.content, - meta: out.meta, - timestamp: baseTs + i, - })) as RoleStep[]; - - while (true) { - if (steps.length >= options.maxRounds) { - return { - returnCode: 0, - summary: `completed: reached maxRounds (${options.maxRounds})`, - }; - } - - const ctx: ThreadContext = { - threadId: options.threadId, - start, - steps, - }; - - const next = def.moderator(ctx); - - if (!isRoleNext(next)) { - return { returnCode: 0, summary: "completed: moderator returned END" }; - } - - const roleDef = def.roles[next]; - if (roleDef === undefined) { - return { returnCode: 1, summary: `unknown role: ${next}` }; - } - - const result = await roleDef.run(ctx as unknown as ThreadContext); - const ts = Date.now(); - const step = { - role: next, - content: result.content, - meta: result.meta, - timestamp: ts, - } as RoleStep; - - yield { - role: step.role, - content: step.content, - meta: step.meta, - }; - - steps = [...steps, step]; - } - }; -} diff --git a/packages/workflow/src/create-workflow.ts b/packages/workflow/src/create-workflow.ts new file mode 100644 index 0000000..8632bfc --- /dev/null +++ b/packages/workflow/src/create-workflow.ts @@ -0,0 +1,136 @@ +import { extractMetaOrThrow } from "./extract-meta.js"; +import { + type AgentBinding, + END, + type ExtractConfig, + type RoleMeta, + type RoleOutput, + type RoleStep, + START, + type ThreadContext, + type ThreadInput, + type WorkflowDefinition, + type WorkflowFn, + type WorkflowFnOptions, + type WorkflowResult, +} from "./types.js"; + +function isRoleNext( + next: (keyof M & string) | typeof END, +): next is keyof M & string { + 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)`. + */ +export function createWorkflow( + def: Pick, "roles" | "moderator">, + binding: AgentBinding, + extract: ExtractConfig, +): WorkflowFn { + return async function* workflowLoop( + input: ThreadInput, + options: WorkflowFnOptions, + ): AsyncGenerator { + const nowMs = Date.now(); + const start: ThreadContext["start"] = { + role: START, + content: input.prompt, + meta: { maxRounds: options.maxRounds }, + timestamp: nowMs, + }; + + const baseTs = Date.now(); + let steps: RoleStep[] = input.steps.map((out, i) => ({ + role: out.role, + content: out.content, + meta: out.meta, + timestamp: baseTs + i, + })) as RoleStep[]; + + while (true) { + if (steps.length >= options.maxRounds) { + return { + returnCode: 0, + summary: `completed: reached maxRounds (${options.maxRounds})`, + }; + } + + const modCtx = moderatorThreadContext({ + threadId: options.threadId, + start, + steps, + roles: def.roles, + }); + + const next = def.moderator(modCtx); + + if (!isRoleNext(next)) { + return { returnCode: 0, summary: "completed: moderator returned END" }; + } + + const roleDef = def.roles[next]; + if (roleDef === undefined) { + return { returnCode: 1, summary: `unknown role: ${next}` }; + } + + const ctx: ThreadContext = { + threadId: options.threadId, + 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 meta = await extractMetaOrThrow(next, raw, roleDef.schema, { + provider: extract.provider, + dryRun: extract.dryRun, + dryRunMeta: roleDef.dryRunMeta, + }); + + const ts = Date.now(); + const step = { + role: next, + content: raw, + meta, + timestamp: ts, + } as RoleStep; + + yield { role: step.role, content: step.content, meta: step.meta }; + + steps = [...steps, step]; + } + }; +} diff --git a/packages/workflow-util-role/src/extract-meta.ts b/packages/workflow/src/extract-meta.ts similarity index 99% rename from packages/workflow-util-role/src/extract-meta.ts rename to packages/workflow/src/extract-meta.ts index f532311..62db1a8 100644 --- a/packages/workflow-util-role/src/extract-meta.ts +++ b/packages/workflow/src/extract-meta.ts @@ -1,4 +1,5 @@ import type * as z from "zod/v4"; + import { llmExtractWithRetry } from "./llm-extract.js"; import type { LlmProvider } from "./types.js"; diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index c0638d6..b366b7a 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -7,7 +7,7 @@ export { } from "./base32.js"; export { buildDescriptor } from "./build-descriptor.js"; export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js"; -export { createRoleModerator } from "./create-role-moderator.js"; +export { createWorkflow } from "./create-workflow.js"; export { type ExecuteThreadIo, type ExecuteThreadOptions, @@ -15,6 +15,7 @@ export { type PrefilledDiskStep, } from "./engine.js"; export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js"; +export { extractMetaOrThrow } from "./extract-meta.js"; export { buildForkPlan, type ForkHistoricalStep, @@ -25,6 +26,12 @@ export { } from "./fork-thread.js"; export { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; export { hashWorkflowBundleBytes } from "./hash.js"; +export { + type LlmError, + llmErrorToCause, + llmExtract, + llmExtractWithRetry, +} from "./llm-extract.js"; export { type CreateLoggerOptions, createLogger, @@ -50,14 +57,15 @@ export { err, ok, type Result } from "./result.js"; export { getDefaultWorkflowStorageRoot } from "./storage-root.js"; export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js"; export { + type AgentBinding, type AgentFn, END, + type ExtractConfig, + type LlmProvider, type Moderator, - type Role, type RoleDefinition, type RoleMeta, type RoleOutput, - type RoleResult, type RoleStep, START, type StartStep, diff --git a/packages/workflow-util-role/src/llm-extract.ts b/packages/workflow/src/llm-extract.ts similarity index 99% rename from packages/workflow-util-role/src/llm-extract.ts rename to packages/workflow/src/llm-extract.ts index e405cd1..b884ed0 100644 --- a/packages/workflow-util-role/src/llm-extract.ts +++ b/packages/workflow/src/llm-extract.ts @@ -1,5 +1,6 @@ -import { err, ok, type Result } from "@uncaged/workflow"; import * as z from "zod/v4"; + +import { err, ok, type Result } from "./result.js"; import type { LlmProvider } from "./types.js"; export type LlmExtractArgs = { diff --git a/packages/workflow/src/types.ts b/packages/workflow/src/types.ts index a43aa5b..bb3f5d9 100644 --- a/packages/workflow/src/types.ts +++ b/packages/workflow/src/types.ts @@ -7,6 +7,13 @@ export const END = "__end__" as const; /** Maps role names → their meta types. Single generic drives all inference. */ export type RoleMeta = Record>; +/** OpenAI-compatible LLM endpoint used for structured meta extraction. */ +export type LlmProvider = { + baseUrl: string; + apiKey: string; + model: string; +}; + /** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */ export type RoleOutput = { role: string; @@ -39,12 +46,6 @@ export type WorkflowFn = ( options: WorkflowFnOptions, ) => AsyncGenerator; -/** Typed output of a Role execution. */ -export type RoleResult> = { - content: string; - meta: Meta; -}; - /** Engine start frame: initial prompt + thread identity. */ export type StartStep = { role: typeof START; @@ -58,33 +59,39 @@ 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 roles and moderator. */ +/** Thread-scoped context passed to agents and moderator. */ export type ThreadContext = { threadId: string; + currentRole: { + name: string; + systemPrompt: string; + }; start: StartStep; steps: RoleStep[]; }; -/** - * A Role — receives full thread context, returns typed content + meta. - * Implementation can be an agent, LLM call, script, HTTP request, etc. - */ -export type Role> = ( - ctx: ThreadContext, -) => Promise>; +/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */ +export type AgentFn = (ctx: ThreadContext) => Promise; -/** Role wiring: runtime {@link Role}, JSON Schema for `meta`, and human-readable description. */ -export type RoleDefinition> = { - description: string; - run: Role; - schema: z.ZodType; +/** Runtime agent assignment (optional per-role overrides). */ +export type AgentBinding = { + agent: AgentFn; + overrides?: Partial>; }; -/** - * An Agent — raw string output interface for LLM/CLI adapters. - * Structured meta is extracted by the role's extract layer. - */ -export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise; +/** Structured extraction settings for the workflow engine. */ +export type ExtractConfig = { + provider: LlmProvider; + dryRun: boolean; +}; + +/** Role wiring: prompts, schema, dry-run meta, and human-readable description. */ +export type RoleDefinition> = { + description: string; + systemPrompt: string; + schema: z.ZodType; + dryRunMeta: Meta; +}; /** * The Moderator — a pure routing function. diff --git a/tsconfig.json b/tsconfig.json index 6d30bf1..1f3a873 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,6 @@ }, "references": [ { "path": "packages/workflow" }, - { "path": "packages/workflow-util-role" }, { "path": "packages/workflow-agent-llm" }, { "path": "packages/workflow-role-committer" }, { "path": "packages/workflow-role-coder" },