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 type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||||
import { START } 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 { createCommitterRole } from "../src/committer.js";
|
||||||
import { gitExec } from "../src/git-exec.js";
|
import { gitExec } from "../src/git-exec.js";
|
||||||
@@ -79,7 +79,7 @@ describe("createCommitterRole", () => {
|
|||||||
const { repo } = await setupRepoWithRemote();
|
const { repo } = await setupRepoWithRemote();
|
||||||
await appendFile(join(repo, "README.md"), "\nmore\n", "utf8");
|
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",
|
branch: "feat/test-commit",
|
||||||
message: "feat: add more",
|
message: "feat: add more",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-role-llm": "workspace:*",
|
"@uncaged/workflow-role-llm": "workspace:*",
|
||||||
|
"@uncaged/workflow-util-role": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/workflow";
|
import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/workflow";
|
||||||
import {
|
import type { LlmProvider } from "@uncaged/workflow-role-llm";
|
||||||
decorateRole,
|
import { extractMetaOrThrow } from "@uncaged/workflow-role-llm";
|
||||||
extractMetaOrThrow,
|
import { decorateRole, onFail, withDryRun } from "@uncaged/workflow-util-role";
|
||||||
type LlmProvider,
|
|
||||||
onFail,
|
|
||||||
withDryRun,
|
|
||||||
} from "@uncaged/workflow-role-llm";
|
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { gitExec } from "./git-exec.js";
|
import { gitExec } from "./git-exec.js";
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"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 { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||||
import { START } from "@uncaged/workflow";
|
import { START } from "@uncaged/workflow";
|
||||||
|
import * as extractMetaModule from "@uncaged/workflow-role-llm";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createRole } from "../src/create-role.js";
|
import { createRole } from "../src/create-role.js";
|
||||||
import * as llmExtract from "../src/llm-extract.js";
|
|
||||||
|
|
||||||
const provider = {
|
const provider = {
|
||||||
baseUrl: "https://example.com/v1",
|
baseUrl: "https://example.com/v1",
|
||||||
@@ -113,7 +113,7 @@ describe("createRole", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("extract dryRun null runs live extract path", async () => {
|
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 agent: AgentFn = async () => "raw";
|
||||||
const role = createRole({
|
const role = createRole({
|
||||||
@@ -134,7 +134,7 @@ describe("createRole", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("extract.dryRun true uses structured extract dry-run", async () => {
|
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 agent: AgentFn = async () => "raw";
|
||||||
const role = createRole({
|
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",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
"build": "echo 'TODO'",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-util-role": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
|
import { extractMetaOrThrow } from "./extract-meta.js";
|
||||||
import { extractMetaOrThrow } from "./llm-extract.js";
|
|
||||||
import type { LlmProvider } from "./types.js";
|
import type { LlmProvider } from "./types.js";
|
||||||
|
|
||||||
export type CreateRoleArgs<M extends Record<string, unknown>> = {
|
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 {
|
export {
|
||||||
decorateRole,
|
decorateRole,
|
||||||
type OnFailOptions,
|
type OnFailOptions,
|
||||||
onFail,
|
onFail,
|
||||||
type RoleDecorator,
|
type RoleDecorator,
|
||||||
|
schemaDefaults,
|
||||||
type WithDryRunOptions,
|
type WithDryRunOptions,
|
||||||
withDryRun,
|
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 {
|
export {
|
||||||
extractMetaOrThrow,
|
|
||||||
type LlmError,
|
type LlmError,
|
||||||
type LlmExtractArgs,
|
type LlmExtractArgs,
|
||||||
type LlmProvider,
|
type LlmProvider,
|
||||||
@@ -18,5 +19,4 @@ export {
|
|||||||
llmExtract,
|
llmExtract,
|
||||||
llmExtractWithRetry,
|
llmExtractWithRetry,
|
||||||
} from "./llm-extract.js";
|
} from "./llm-extract.js";
|
||||||
export { schemaDefaults } from "./schema-defaults.js";
|
|
||||||
export type { LlmMessage, MetaExtractConfig } from "./types.js";
|
export type { LlmMessage, MetaExtractConfig } from "./types.js";
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
import { schemaDefaults } from "@uncaged/workflow-util-role";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { schemaDefaults } from "./schema-defaults.js";
|
|
||||||
import type { LlmProvider } from "./types.js";
|
import type { LlmProvider } from "./types.js";
|
||||||
|
|
||||||
export type { LlmProvider } from "./types.js";
|
export type { LlmProvider } from "./types.js";
|
||||||
@@ -252,23 +251,3 @@ ${correction}`;
|
|||||||
userContent: secondContent,
|
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
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"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" }]
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "packages/workflow" },
|
{ "path": "packages/workflow" },
|
||||||
|
{ "path": "packages/workflow-util-role" },
|
||||||
{ "path": "packages/workflow-role-llm" },
|
{ "path": "packages/workflow-role-llm" },
|
||||||
{ "path": "packages/workflow-role-committer" },
|
{ "path": "packages/workflow-role-committer" },
|
||||||
{ "path": "packages/workflow-role-reviewer" },
|
{ "path": "packages/workflow-role-reviewer" },
|
||||||
|
|||||||
Reference in New Issue
Block a user