refactor: extract @uncaged/workflow-util-role from role-llm (#15)

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
This commit is contained in:
2026-05-06 07:27:11 +00:00
parent 2a71454c10
commit 82d3478895
19 changed files with 188 additions and 44 deletions
@@ -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",
});
@@ -11,6 +11,7 @@
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-role-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -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";
@@ -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" }
]
}
@@ -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({
@@ -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" });
});
});
+4
View File
@@ -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"
}
}
@@ -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<M extends Record<string, unknown>> = {
@@ -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<T extends Record<string, unknown>>(
roleName: string,
raw: string,
schema: z.ZodType<T>,
options: { provider: LlmProvider; dryRun: boolean },
): Promise<T> {
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;
}
+6 -6
View File
@@ -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";
+1 -22
View File
@@ -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<T extends Record<string, unknown>>(
roleName: string,
raw: string,
schema: z.ZodType<T>,
options: { provider: LlmProvider; dryRun: boolean },
): Promise<T> {
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;
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }]
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
}
+18
View File
@@ -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"
}
}
+9
View File
@@ -0,0 +1,9 @@
export {
decorateRole,
type OnFailOptions,
onFail,
type RoleDecorator,
type WithDryRunOptions,
withDryRun,
} from "./decorators.js";
export { schemaDefaults } from "./schema-defaults.js";
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }]
}
+1
View File
@@ -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" },