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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user