From 82d34788956584855c12244c995a29ac68e796a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 6 May 2026 07:27:11 +0000 Subject: [PATCH] refactor: extract @uncaged/workflow-util-role from role-llm (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move pure role utilities (decorateRole, withDryRun, onFail, schemaDefaults) into @uncaged/workflow-util-role. extractMetaOrThrow stays in role-llm since it depends on LLM capabilities. Dependency graph (no cycles): util-role → workflow role-llm → workflow, util-role committer → workflow, util-role, role-llm Closes #15 --- .../__tests__/committer.test.ts | 4 +- packages/workflow-role-committer/package.json | 1 + .../workflow-role-committer/src/committer.ts | 10 +- .../workflow-role-committer/tsconfig.json | 6 +- .../__tests__/create-role.test.ts | 6 +- .../__tests__/extract-meta.test.ts | 100 ++++++++++++++++++ packages/workflow-role-llm/package.json | 4 + packages/workflow-role-llm/src/create-role.ts | 3 +- .../workflow-role-llm/src/extract-meta.ts | 23 ++++ packages/workflow-role-llm/src/index.ts | 12 +-- packages/workflow-role-llm/src/llm-extract.ts | 23 +--- packages/workflow-role-llm/tsconfig.json | 2 +- .../__tests__/decorators.test.ts | 0 packages/workflow-util-role/package.json | 18 ++++ .../src/decorators.ts | 0 packages/workflow-util-role/src/index.ts | 9 ++ .../src/schema-defaults.ts | 0 packages/workflow-util-role/tsconfig.json | 10 ++ tsconfig.json | 1 + 19 files changed, 188 insertions(+), 44 deletions(-) create mode 100644 packages/workflow-role-llm/__tests__/extract-meta.test.ts create mode 100644 packages/workflow-role-llm/src/extract-meta.ts rename packages/{workflow-role-llm => workflow-util-role}/__tests__/decorators.test.ts (100%) create mode 100644 packages/workflow-util-role/package.json rename packages/{workflow-role-llm => workflow-util-role}/src/decorators.ts (100%) create mode 100644 packages/workflow-util-role/src/index.ts rename packages/{workflow-role-llm => workflow-util-role}/src/schema-defaults.ts (100%) create mode 100644 packages/workflow-util-role/tsconfig.json diff --git a/packages/workflow-role-committer/__tests__/committer.test.ts b/packages/workflow-role-committer/__tests__/committer.test.ts index 89f296b..4ea0901 100644 --- a/packages/workflow-role-committer/__tests__/committer.test.ts +++ b/packages/workflow-role-committer/__tests__/committer.test.ts @@ -7,7 +7,7 @@ import { promisify } from "node:util"; import type { AgentFn, ThreadContext } from "@uncaged/workflow"; import { START } from "@uncaged/workflow"; -import * as workflowRoleLlm from "@uncaged/workflow-role-llm"; +import * as roleLlm from "@uncaged/workflow-role-llm"; import { createCommitterRole } from "../src/committer.js"; import { gitExec } from "../src/git-exec.js"; @@ -79,7 +79,7 @@ describe("createCommitterRole", () => { const { repo } = await setupRepoWithRemote(); await appendFile(join(repo, "README.md"), "\nmore\n", "utf8"); - const spy = spyOn(workflowRoleLlm, "extractMetaOrThrow").mockResolvedValue({ + const spy = spyOn(roleLlm, "extractMetaOrThrow").mockResolvedValue({ branch: "feat/test-commit", message: "feat: add more", }); diff --git a/packages/workflow-role-committer/package.json b/packages/workflow-role-committer/package.json index aa685a9..2096c42 100644 --- a/packages/workflow-role-committer/package.json +++ b/packages/workflow-role-committer/package.json @@ -11,6 +11,7 @@ "dependencies": { "@uncaged/workflow": "workspace:*", "@uncaged/workflow-role-llm": "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 4d95118..e851eb4 100644 --- a/packages/workflow-role-committer/src/committer.ts +++ b/packages/workflow-role-committer/src/committer.ts @@ -1,11 +1,7 @@ import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/workflow"; -import { - decorateRole, - extractMetaOrThrow, - type LlmProvider, - onFail, - withDryRun, -} from "@uncaged/workflow-role-llm"; +import type { LlmProvider } from "@uncaged/workflow-role-llm"; +import { extractMetaOrThrow } from "@uncaged/workflow-role-llm"; +import { decorateRole, onFail, withDryRun } from "@uncaged/workflow-util-role"; import * as z from "zod/v4"; import { gitExec } from "./git-exec.js"; diff --git a/packages/workflow-role-committer/tsconfig.json b/packages/workflow-role-committer/tsconfig.json index b270640..0241dfc 100644 --- a/packages/workflow-role-committer/tsconfig.json +++ b/packages/workflow-role-committer/tsconfig.json @@ -6,5 +6,9 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }, { "path": "../workflow-role-llm" }] + "references": [ + { "path": "../workflow" }, + { "path": "../workflow-role-llm" }, + { "path": "../workflow-util-role" } + ] } diff --git a/packages/workflow-role-llm/__tests__/create-role.test.ts b/packages/workflow-role-llm/__tests__/create-role.test.ts index 2b42f38..176063e 100644 --- a/packages/workflow-role-llm/__tests__/create-role.test.ts +++ b/packages/workflow-role-llm/__tests__/create-role.test.ts @@ -1,10 +1,10 @@ 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-role-llm"; import * as z from "zod/v4"; import { createRole } from "../src/create-role.js"; -import * as llmExtract from "../src/llm-extract.js"; const provider = { baseUrl: "https://example.com/v1", @@ -113,7 +113,7 @@ describe("createRole", () => { }); test("extract dryRun null runs live extract path", async () => { - const spy = spyOn(llmExtract, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); + const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); const agent: AgentFn = async () => "raw"; const role = createRole({ @@ -134,7 +134,7 @@ describe("createRole", () => { }); test("extract.dryRun true uses structured extract dry-run", async () => { - const spy = spyOn(llmExtract, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); + const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); const agent: AgentFn = async () => "raw"; const role = createRole({ diff --git a/packages/workflow-role-llm/__tests__/extract-meta.test.ts b/packages/workflow-role-llm/__tests__/extract-meta.test.ts new file mode 100644 index 0000000..f5c80d2 --- /dev/null +++ b/packages/workflow-role-llm/__tests__/extract-meta.test.ts @@ -0,0 +1,100 @@ +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 schema-shaped defaults without calling fetch", async () => { + let calls = 0; + globalThis.fetch = () => { + calls += 1; + return Promise.resolve(new Response("{}", { status: 200 })); + }; + + const schema = z.object({ n: z.number() }); + const out = await extractMetaOrThrow("r", "raw", schema, { + provider, + dryRun: true, + }); + + globalThis.fetch = originalFetch; + + expect(calls).toBe(0); + expect(out).toEqual({ n: 0 }); + }); + + 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" } }, + ), + ); + + const schema = z.object({ n: z.number() }); + + await expect( + extractMetaOrThrow("plan", "text", schema, { provider, dryRun: false }), + ).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" } }, + ), + ); + + const schema = z.object({ + branch: z.string(), + message: z.string(), + }); + + const out = await extractMetaOrThrow("committer-plan", "plan text", schema, { + provider, + dryRun: false, + }); + + globalThis.fetch = originalFetch; + + expect(out).toEqual({ branch: "feat/x", message: "feat: y" }); + }); +}); diff --git a/packages/workflow-role-llm/package.json b/packages/workflow-role-llm/package.json index 8e98a15..8437af9 100644 --- a/packages/workflow-role-llm/package.json +++ b/packages/workflow-role-llm/package.json @@ -4,12 +4,16 @@ "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" } } diff --git a/packages/workflow-role-llm/src/create-role.ts b/packages/workflow-role-llm/src/create-role.ts index 2b55228..162c991 100644 --- a/packages/workflow-role-llm/src/create-role.ts +++ b/packages/workflow-role-llm/src/create-role.ts @@ -1,7 +1,6 @@ import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow"; import type * as z from "zod/v4"; - -import { extractMetaOrThrow } from "./llm-extract.js"; +import { extractMetaOrThrow } from "./extract-meta.js"; import type { LlmProvider } from "./types.js"; export type CreateRoleArgs> = { diff --git a/packages/workflow-role-llm/src/extract-meta.ts b/packages/workflow-role-llm/src/extract-meta.ts new file mode 100644 index 0000000..08045c9 --- /dev/null +++ b/packages/workflow-role-llm/src/extract-meta.ts @@ -0,0 +1,23 @@ +import type * as z from "zod/v4"; +import { llmExtractWithRetry } from "./llm-extract.js"; +import type { LlmProvider } from "./types.js"; + +export async function extractMetaOrThrow>( + roleName: string, + raw: string, + schema: z.ZodType, + options: { provider: LlmProvider; dryRun: boolean }, +): Promise { + const result = await llmExtractWithRetry({ + text: raw, + schema, + provider: options.provider, + dryRun: options.dryRun, + }); + if (!result.ok) { + throw new Error( + `Role "${roleName}": structured extraction failed after retry: ${JSON.stringify(result.error)}`, + ); + } + return result.value; +} diff --git a/packages/workflow-role-llm/src/index.ts b/packages/workflow-role-llm/src/index.ts index 61421d9..653fc29 100644 --- a/packages/workflow-role-llm/src/index.ts +++ b/packages/workflow-role-llm/src/index.ts @@ -1,16 +1,17 @@ -export { buildDescriptorFromRoles, type RoleDescriptorInput } from "./build-descriptor.js"; -export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js"; -export { type CreateRoleArgs, createRole } from "./create-role.js"; export { decorateRole, type OnFailOptions, onFail, type RoleDecorator, + schemaDefaults, type WithDryRunOptions, withDryRun, -} from "./decorators.js"; +} from "@uncaged/workflow-util-role"; +export { buildDescriptorFromRoles, type RoleDescriptorInput } from "./build-descriptor.js"; +export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js"; +export { type CreateRoleArgs, createRole } from "./create-role.js"; +export { extractMetaOrThrow } from "./extract-meta.js"; export { - extractMetaOrThrow, type LlmError, type LlmExtractArgs, type LlmProvider, @@ -18,5 +19,4 @@ export { llmExtract, llmExtractWithRetry, } from "./llm-extract.js"; -export { schemaDefaults } from "./schema-defaults.js"; export type { LlmMessage, MetaExtractConfig } from "./types.js"; diff --git a/packages/workflow-role-llm/src/llm-extract.ts b/packages/workflow-role-llm/src/llm-extract.ts index 0657ccb..085fa22 100644 --- a/packages/workflow-role-llm/src/llm-extract.ts +++ b/packages/workflow-role-llm/src/llm-extract.ts @@ -1,7 +1,6 @@ import { err, ok, type Result } from "@uncaged/workflow"; +import { schemaDefaults } from "@uncaged/workflow-util-role"; import * as z from "zod/v4"; - -import { schemaDefaults } from "./schema-defaults.js"; import type { LlmProvider } from "./types.js"; export type { LlmProvider } from "./types.js"; @@ -252,23 +251,3 @@ ${correction}`; userContent: secondContent, }); } - -export async function extractMetaOrThrow>( - roleName: string, - raw: string, - schema: z.ZodType, - options: { provider: LlmProvider; dryRun: boolean }, -): Promise { - const result = await llmExtractWithRetry({ - text: raw, - schema, - provider: options.provider, - dryRun: options.dryRun, - }); - if (!result.ok) { - throw new Error( - `Role "${roleName}": structured extraction failed after retry: ${JSON.stringify(result.error)}`, - ); - } - return result.value; -} diff --git a/packages/workflow-role-llm/tsconfig.json b/packages/workflow-role-llm/tsconfig.json index 2816fef..9227ea6 100644 --- a/packages/workflow-role-llm/tsconfig.json +++ b/packages/workflow-role-llm/tsconfig.json @@ -6,5 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }] + "references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }] } diff --git a/packages/workflow-role-llm/__tests__/decorators.test.ts b/packages/workflow-util-role/__tests__/decorators.test.ts similarity index 100% rename from packages/workflow-role-llm/__tests__/decorators.test.ts rename to packages/workflow-util-role/__tests__/decorators.test.ts diff --git a/packages/workflow-util-role/package.json b/packages/workflow-util-role/package.json new file mode 100644 index 0000000..e4fc41a --- /dev/null +++ b/packages/workflow-util-role/package.json @@ -0,0 +1,18 @@ +{ + "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-role-llm/src/decorators.ts b/packages/workflow-util-role/src/decorators.ts similarity index 100% rename from packages/workflow-role-llm/src/decorators.ts rename to packages/workflow-util-role/src/decorators.ts diff --git a/packages/workflow-util-role/src/index.ts b/packages/workflow-util-role/src/index.ts new file mode 100644 index 0000000..1ad39da --- /dev/null +++ b/packages/workflow-util-role/src/index.ts @@ -0,0 +1,9 @@ +export { + decorateRole, + type OnFailOptions, + onFail, + type RoleDecorator, + type WithDryRunOptions, + withDryRun, +} from "./decorators.js"; +export { schemaDefaults } from "./schema-defaults.js"; diff --git a/packages/workflow-role-llm/src/schema-defaults.ts b/packages/workflow-util-role/src/schema-defaults.ts similarity index 100% rename from packages/workflow-role-llm/src/schema-defaults.ts rename to packages/workflow-util-role/src/schema-defaults.ts diff --git a/packages/workflow-util-role/tsconfig.json b/packages/workflow-util-role/tsconfig.json new file mode 100644 index 0000000..2816fef --- /dev/null +++ b/packages/workflow-util-role/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../workflow" }] +} diff --git a/tsconfig.json b/tsconfig.json index 9cad802..4d2fd56 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ }, "references": [ { "path": "packages/workflow" }, + { "path": "packages/workflow-util-role" }, { "path": "packages/workflow-role-llm" }, { "path": "packages/workflow-role-committer" }, { "path": "packages/workflow-role-reviewer" },