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:
@@ -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,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;
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
decorateRole,
|
||||
type OnFailOptions,
|
||||
onFail,
|
||||
type RoleDecorator,
|
||||
type WithDryRunOptions,
|
||||
withDryRun,
|
||||
} from "./decorators.js";
|
||||
export { schemaDefaults } from "./schema-defaults.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
Reference in New Issue
Block a user