From fbe1cc8ebadd2e45ec35feeedd30ff06f75552f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 30 Apr 2026 12:38:00 +0000 Subject: [PATCH] feat(workflow-utils): add createLlmAdapter AgentFn factory Single-turn chat via chatCompletionText: system from createRole prompt, user from ctx.start.content. Fixes #277 Made-with: Cursor --- .../src/__tests__/create-llm-adapter.test.ts | 54 +++++++++++++++++++ .../workflow-utils/src/create-llm-adapter.ts | 22 ++++++++ packages/workflow-utils/src/index.ts | 1 + 3 files changed, 77 insertions(+) create mode 100644 packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts create mode 100644 packages/workflow-utils/src/create-llm-adapter.ts diff --git a/packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts b/packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts new file mode 100644 index 0000000..5504b1a --- /dev/null +++ b/packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { START, type ThreadContext } from "@uncaged/nerve-core"; + +import { createLlmAdapter } from "../create-llm-adapter.js"; + +function makeCtx(threadId: string, userContent: string): ThreadContext { + return { + threadId, + start: { + role: START, + content: userContent, + meta: { maxRounds: 10, threadId }, + timestamp: 1, + }, + steps: [], + }; +} + +describe("createLlmAdapter", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("posts system + user (start.content) and returns assistant text", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + choices: [{ message: { content: "model reply" } }], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; + const adapter = createLlmAdapter(provider); + const out = await adapter(makeCtx("t1", "trigger text"), "system instructions"); + + expect(out).toBe("model reply"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string) as { + model: string; + messages: Array<{ role: string; content: string }>; + }; + expect(body.model).toBe("m"); + expect(body.messages).toEqual([ + { role: "system", content: "system instructions" }, + { role: "user", content: "trigger text" }, + ]); + }); +}); diff --git a/packages/workflow-utils/src/create-llm-adapter.ts b/packages/workflow-utils/src/create-llm-adapter.ts new file mode 100644 index 0000000..bcab534 --- /dev/null +++ b/packages/workflow-utils/src/create-llm-adapter.ts @@ -0,0 +1,22 @@ +import type { AgentFn, ThreadContext } from "@uncaged/nerve-core"; + +import { formatLlmError } from "./shared/format-error.js"; +import { chatCompletionText } from "./shared/llm-chat.js"; +import type { LlmProvider } from "./shared/llm-extract.js"; + +/** 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: ${formatLlmError(result.error)}`); + } + return result.value; + }; +} diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 418bfef..c5c73bb 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -1,4 +1,5 @@ // Primary API — role factory templates +export { createLlmAdapter } from "./create-llm-adapter.js"; export { createRole, type LlmExtractorConfig } from "./create-role.js"; export { createCursorRole } from "./role-cursor.js"; export { createHermesRole } from "./role-hermes.js";