refactor: move llmExtract, extractMeta, buildDescriptor, types to workflow-util-role

workflow-role-llm now only contains LLM-as-agent specifics:
  - createRole (wires agent + extract)
  - createLlmAdapter (OpenAI chat completions agent)

workflow-util-role now provides all role infrastructure:
  - decorators (decorateRole, withDryRun, onFail)
  - llmExtract / extractMetaOrThrow (structured extraction)
  - buildDescriptorFromRoles (zod → JSON Schema)
  - LlmProvider, LlmMessage types
This commit is contained in:
2026-05-06 08:13:27 +00:00
parent 6e62c7458d
commit c04e7c31af
23 changed files with 53 additions and 35 deletions
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import { validateWorkflowDescriptor } from "@uncaged/workflow";
import * as z from "zod/v4";
import { buildDescriptorFromRoles } from "../src/build-descriptor.js";
describe("buildDescriptorFromRoles", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => {
const schema = z.object({
title: z.string(),
count: z.number(),
});
const descriptor = buildDescriptorFromRoles({
description: "Demo workflow",
roles: {
analyst: {
name: "analyst",
schema,
description: "Analyzes input",
},
},
});
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
return;
}
expect(validated.value.description).toBe("Demo workflow");
const analyst = validated.value.roles.analyst;
expect(analyst.description).toBe("Analyzes input");
expect(analyst.schema.type).toBe("object");
const props = analyst.schema.properties as Record<string, unknown>;
expect(props.title).toMatchObject({ type: "string" });
expect(props.count).toMatchObject({ type: "number" });
});
test("uses empty description when spec.description is null", () => {
const descriptor = buildDescriptorFromRoles({
description: "W",
roles: {
x: {
name: "x",
schema: z.object({ n: z.number() }),
description: null,
},
},
});
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
return;
}
expect(validated.value.roles.x.description).toBe("");
});
test("throws when role key and spec.name diverge", () => {
expect(() =>
buildDescriptorFromRoles({
description: "W",
roles: {
a: {
name: "b",
schema: z.object({ n: z.number() }),
description: null,
},
},
}),
).toThrow(/must match spec.name/);
});
});
@@ -0,0 +1,102 @@
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 }));
};
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" } },
),
);
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" } },
),
);
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" });
});
});
@@ -0,0 +1,146 @@
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: RequestInfo | 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" } },
),
);
};
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" } },
),
);
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 }));
};
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 });
});
});