refactor: rename workflow-role-llm → workflow-agent-llm
The package only contains createLlmAdapter (OpenAI chat → AgentFn), which is an agent adapter, not a role. Aligns with workflow-agent-cursor and workflow-agent-hermes naming.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { START, type ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||
|
||||
function makeCtx(userContent: string): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
content: userContent,
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: 1,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("createLlmAdapter", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test("posts system + user (start.content) and returns assistant text", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const out = await adapter(makeCtx("trigger text"), "system instructions");
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(out).toBe("model reply");
|
||||
});
|
||||
|
||||
test("throws on non-ok fetch response", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response("Internal Server Error", {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:");
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("throws on fetch network failure", async () => {
|
||||
globalThis.fetch = (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch;
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"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:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { type AgentFn, err, ok, type Result, type ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
import type { LlmMessage, LlmProvider } from "@uncaged/workflow-util-role";
|
||||
|
||||
export type LlmChatError =
|
||||
| { kind: "http_error"; status: number; body: string }
|
||||
| { kind: "invalid_response_json"; message: string }
|
||||
| { kind: "network_error"; message: string }
|
||||
| { kind: "empty_choices" }
|
||||
| { kind: "no_assistant_text" };
|
||||
|
||||
function chatUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatLlmChatError(e: LlmChatError): string {
|
||||
return JSON.stringify(e);
|
||||
}
|
||||
|
||||
async function fetchChatJson(
|
||||
provider: LlmProvider,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Result<unknown, LlmChatError>> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatUrl(provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "network_error", message });
|
||||
}
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return err({ kind: "http_error", status: response.status, body: responseText.slice(0, 4000) });
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "invalid_response_json", message });
|
||||
}
|
||||
return ok(parsed);
|
||||
}
|
||||
|
||||
function parseAssistantText(parsed: unknown): Result<string, LlmChatError> {
|
||||
if (!isRecord(parsed)) {
|
||||
return err({ kind: "invalid_response_json", message: "Not an object" });
|
||||
}
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return err({ kind: "empty_choices" });
|
||||
}
|
||||
const c0 = choices[0];
|
||||
if (!isRecord(c0)) {
|
||||
return err({ kind: "empty_choices" });
|
||||
}
|
||||
const messageObj = c0.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
return err({ kind: "no_assistant_text" });
|
||||
}
|
||||
const content = messageObj.content;
|
||||
if (typeof content === "string") {
|
||||
return ok(content);
|
||||
}
|
||||
return err({ kind: "no_assistant_text" });
|
||||
}
|
||||
|
||||
export async function chatCompletionText(options: {
|
||||
provider: LlmProvider;
|
||||
messages: LlmMessage[];
|
||||
}): Promise<Result<string, LlmChatError>> {
|
||||
const body = { model: options.provider.model, messages: options.messages };
|
||||
const res = await fetchChatJson(options.provider, body);
|
||||
if (!res.ok) {
|
||||
return res;
|
||||
}
|
||||
return parseAssistantText(res.value);
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system comes from `createRole` prompt; user is the thread start frame. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
||||
return async (ctx: ThreadContext, systemPrompt: string) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: ctx.start.content },
|
||||
],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
buildDescriptorFromRoles,
|
||||
type CreateRoleArgs,
|
||||
createRole,
|
||||
decorateRole,
|
||||
extractMetaOrThrow,
|
||||
type LlmError,
|
||||
type LlmExtractArgs,
|
||||
type LlmMessage,
|
||||
type LlmProvider,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
llmExtractWithRetry,
|
||||
type MetaExtractConfig,
|
||||
type OnFailOptions,
|
||||
onFail,
|
||||
type RoleDecorator,
|
||||
type RoleDescriptorInput,
|
||||
type WithDryRunOptions,
|
||||
withDryRun,
|
||||
} from "@uncaged/workflow-util-role";
|
||||
export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
|
||||
}
|
||||
Reference in New Issue
Block a user