@nanobpm/urban 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export * from "./runtime/index.ts";
2
2
  export { createCamundaClient } from "@nanobpm/nano-sdk";
3
3
  export * from "./toolkit/index.ts";
4
4
  export { defineFlow, declarativeToBpmn, declarativeToLayoutedBpmn, externalJobTypes, envelope, layoutBpmn, toBpmn, toDeployableBpmn, Worker, WorkflowClient, WorkflowError, } from "@nanobpm/workflow";
5
- export type { FlowBuilder, Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, WorkerOptions, ActivityEvent, } from "@nanobpm/workflow";
5
+ export type { FlowBuilder, TimerStart, Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, WorkerOptions, ActivityEvent, } from "@nanobpm/workflow";
@@ -22,6 +22,25 @@ export interface WorkerDecl {
22
22
  /** Name of an `llm[]` binding used as the worker (LLM-as-worker). Mutually exclusive with `handler`. */
23
23
  llm?: string;
24
24
  }
25
+ /**
26
+ * One named LLM binding (`llm.<id>` in the manifest). A binding is usable as a worker
27
+ * (LLM-as-worker, ADR 0022 §E role 1) or as a chat surface agent. `provider` selects how
28
+ * the endpoint is resolved (`"env"` reads it from the environment); `model` is typically
29
+ * an env template. When `output.decision` is set, the model's JSON reply is fed through
30
+ * that DMN decision (the "rails") and the decision's output is returned instead.
31
+ */
32
+ export interface LlmBinding {
33
+ /** Provider selector. `"env"` resolves an OpenAI-compatible endpoint from the environment. */
34
+ provider: string;
35
+ /** Model id, typically an env template (e.g. `${NANO_APP_LLM_MODEL}`). */
36
+ model: string;
37
+ /** Constrains the model's structured output — currently a DMN decision id (the rails). */
38
+ output?: {
39
+ decision?: string;
40
+ };
41
+ /** Action-API tools the agent may call (chat-agent role; unused by the worker role). */
42
+ tools?: string[];
43
+ }
25
44
  export interface TriggerDecl {
26
45
  id: string;
27
46
  type: string;
@@ -89,7 +108,7 @@ export interface AppManifest {
89
108
  surfaces?: Record<string, SurfaceDecl>;
90
109
  actions?: ActionDecl[];
91
110
  workers?: WorkerDecl[];
92
- llm?: Record<string, unknown>;
111
+ llm?: Record<string, LlmBinding>;
93
112
  security?: Record<string, unknown>;
94
113
  [k: string]: unknown;
95
114
  }
@@ -0,0 +1,65 @@
1
+ import type { LlmBinding } from "../manifest.ts";
2
+ /** Job variables/headers default to untyped JSON. */
3
+ export type LlmVars = Record<string, unknown>;
4
+ /** Reads an environment variable (routed through the host seam). */
5
+ export type EnvLookup = (name: string) => string | undefined;
6
+ /** One chat message on the wire. */
7
+ export interface ChatMessage {
8
+ role: "system" | "user" | "assistant";
9
+ content: string;
10
+ }
11
+ /** A resolved provider endpoint (OpenAI-compatible chat completions). */
12
+ export interface ProviderConfig {
13
+ baseUrl: string;
14
+ apiKey?: string;
15
+ model: string;
16
+ }
17
+ /**
18
+ * Evaluate a DMN decision (the output "rails"), returning its already-parsed output.
19
+ * Injected by the caller so this module stays free of any engine/SDK coupling.
20
+ */
21
+ export type DecisionEvaluator = (decisionId: string, variables: Record<string, unknown>) => Promise<unknown>;
22
+ /** Everything running one LLM job needs, injected for host-agnosticism + testability. */
23
+ export interface LlmRuntime {
24
+ env: EnvLookup;
25
+ /** Fetch implementation. Defaults to the global `fetch`. */
26
+ fetch?: typeof fetch;
27
+ /** Decision-rails evaluator; required only when a binding sets `output.decision`. */
28
+ evaluateDecision?: DecisionEvaluator;
29
+ }
30
+ /**
31
+ * Resolve an OpenAI-compatible endpoint for a binding. `provider: "env"` (the only
32
+ * built-in today) reads:
33
+ * - NANO_APP_LLM_BASE_URL (default http://localhost:11434/v1 — Ollama's
34
+ * OpenAI-compatible endpoint; override for any host)
35
+ * - NANO_APP_LLM_API_KEY (optional; sent as `Authorization: Bearer` if set)
36
+ * - NANO_APP_LLM_MODEL (fallback when the binding's `model` resolves empty)
37
+ * The binding's `model` is env-templated (`${VAR}` / `${VAR:-default}`).
38
+ */
39
+ export declare function resolveProvider(binding: LlmBinding, env: EnvLookup): ProviderConfig;
40
+ /**
41
+ * Build the chat messages for a job's variables. Input contract:
42
+ * - `messages`: a ready `{role,content}[]` (used verbatim), or
43
+ * - `prompt`: a user-message string.
44
+ * An optional `system` string is prepended.
45
+ */
46
+ export declare function buildMessages(vars: LlmVars): ChatMessage[];
47
+ /**
48
+ * Call the model's chat-completions endpoint. When `json` is set, requests a JSON object
49
+ * response (`response_format`) so structured output can be parsed. Returns the assistant
50
+ * message text.
51
+ */
52
+ export declare function callLlm(provider: ProviderConfig, messages: ChatMessage[], opts?: {
53
+ json?: boolean;
54
+ fetch?: typeof fetch;
55
+ }): Promise<string>;
56
+ /**
57
+ * Run one job through the LLM (and optional decision rails). Returns the output variables
58
+ * the job is completed with.
59
+ *
60
+ * - No `output` binding → returns `{ text }` (the raw completion).
61
+ * - `output` set → requests JSON, parses it; the parsed object is the output.
62
+ * - `output.decision` set → the parsed JSON is fed to that DMN decision and the decision's
63
+ * output is returned (object as-is, scalar wrapped as `{ result }`).
64
+ */
65
+ export declare function runLlmJob(vars: LlmVars, binding: LlmBinding, rt: LlmRuntime): Promise<Record<string, unknown>>;
@@ -0,0 +1,146 @@
1
+ // LLM-as-worker runtime (ADR 0022 §E role 1) — the batteries-included implementation of
2
+ // the manifest's `llm` seam. For every `workers[]` entry that carries an `llm` binding
3
+ // (instead of a `handler` file), `workers.ts` registers a job worker on that task type
4
+ // whose handler is an LLM call — no external connector runtime required.
5
+ //
6
+ // A worker's contract:
7
+ // { "taskType": "classify", "llm": "classifier" } // workers[]
8
+ // "llm": { "classifier": { "provider": "env", // llm registry
9
+ // "model": "${NANO_APP_LLM_MODEL}",
10
+ // "output": { "decision": "risk" } } }
11
+ //
12
+ // The job's input variables drive the prompt (`prompt` string and/or `messages` array,
13
+ // optional `system`); the model's reply becomes the job's output variables. When the
14
+ // binding declares `output.decision`, the model's JSON is fed through that DMN decision
15
+ // (the rails) and the decision's output is returned instead — the LLM proposes, the
16
+ // decision disposes.
17
+ //
18
+ // Provider is configuration, not code: `provider: "env"` resolves an OpenAI-compatible
19
+ // endpoint from the environment, so a fully-local model (Ollama, LM Studio, llama.cpp,
20
+ // vLLM, …) or a hosted API both work unchanged. This module is host-agnostic: env and
21
+ // fetch are injected (env always via the host seam), never read from a concrete runtime.
22
+ import { expandEnvString } from "../manifest.js";
23
+ /**
24
+ * Resolve an OpenAI-compatible endpoint for a binding. `provider: "env"` (the only
25
+ * built-in today) reads:
26
+ * - NANO_APP_LLM_BASE_URL (default http://localhost:11434/v1 — Ollama's
27
+ * OpenAI-compatible endpoint; override for any host)
28
+ * - NANO_APP_LLM_API_KEY (optional; sent as `Authorization: Bearer` if set)
29
+ * - NANO_APP_LLM_MODEL (fallback when the binding's `model` resolves empty)
30
+ * The binding's `model` is env-templated (`${VAR}` / `${VAR:-default}`).
31
+ */
32
+ export function resolveProvider(binding, env) {
33
+ if (binding.provider !== "env") {
34
+ throw new Error(`llm worker: unsupported provider "${binding.provider}" (only "env" is built in — ` +
35
+ `it resolves an OpenAI-compatible endpoint from NANO_APP_LLM_* env)`);
36
+ }
37
+ const baseUrl = (env("NANO_APP_LLM_BASE_URL") ?? "http://localhost:11434/v1").replace(/\/+$/, "");
38
+ const apiKey = env("NANO_APP_LLM_API_KEY") || undefined;
39
+ const model = expandEnvString(binding.model ?? "", env) || env("NANO_APP_LLM_MODEL") || "";
40
+ if (!model) {
41
+ throw new Error(`llm worker: no model resolved for binding (model="${binding.model}"); ` +
42
+ `set NANO_APP_LLM_MODEL or a literal model`);
43
+ }
44
+ return { baseUrl, apiKey, model };
45
+ }
46
+ /**
47
+ * Build the chat messages for a job's variables. Input contract:
48
+ * - `messages`: a ready `{role,content}[]` (used verbatim), or
49
+ * - `prompt`: a user-message string.
50
+ * An optional `system` string is prepended.
51
+ */
52
+ export function buildMessages(vars) {
53
+ const system = typeof vars.system === "string" ? vars.system : undefined;
54
+ let msgs;
55
+ if (Array.isArray(vars.messages)) {
56
+ if (vars.messages.length === 0) {
57
+ throw new Error("llm worker: 'messages' must not be an empty array");
58
+ }
59
+ msgs = vars.messages.map((m, i) => {
60
+ const o = m;
61
+ if (typeof o.content !== "string" ||
62
+ (o.role !== "user" && o.role !== "assistant" && o.role !== "system")) {
63
+ throw new Error(`llm worker: messages[${i}] must be { role: user|assistant|system, content: string }`);
64
+ }
65
+ return { role: o.role, content: o.content };
66
+ });
67
+ }
68
+ else if (typeof vars.prompt === "string") {
69
+ msgs = [{ role: "user", content: vars.prompt }];
70
+ }
71
+ else {
72
+ throw new Error("llm worker: job needs a 'prompt' string or a 'messages' array in its variables");
73
+ }
74
+ return system ? [{ role: "system", content: system }, ...msgs] : msgs;
75
+ }
76
+ /**
77
+ * Call the model's chat-completions endpoint. When `json` is set, requests a JSON object
78
+ * response (`response_format`) so structured output can be parsed. Returns the assistant
79
+ * message text.
80
+ */
81
+ export async function callLlm(provider, messages, opts = {}) {
82
+ const doFetch = opts.fetch ?? fetch;
83
+ const headers = { "content-type": "application/json" };
84
+ if (provider.apiKey)
85
+ headers.authorization = `Bearer ${provider.apiKey}`;
86
+ const body = { model: provider.model, messages };
87
+ if (opts.json)
88
+ body.response_format = { type: "json_object" };
89
+ const res = await doFetch(`${provider.baseUrl}/chat/completions`, {
90
+ method: "POST",
91
+ headers,
92
+ body: JSON.stringify(body),
93
+ });
94
+ if (!res.ok) {
95
+ const detail = await res.text().catch(() => "");
96
+ throw new Error(`llm provider returned ${res.status}: ${detail.slice(0, 500)}`);
97
+ }
98
+ const data = (await res.json());
99
+ const content = data.choices?.[0]?.message?.content;
100
+ if (typeof content !== "string") {
101
+ throw new Error("llm provider returned no message content");
102
+ }
103
+ return content;
104
+ }
105
+ /**
106
+ * Run one job through the LLM (and optional decision rails). Returns the output variables
107
+ * the job is completed with.
108
+ *
109
+ * - No `output` binding → returns `{ text }` (the raw completion).
110
+ * - `output` set → requests JSON, parses it; the parsed object is the output.
111
+ * - `output.decision` set → the parsed JSON is fed to that DMN decision and the decision's
112
+ * output is returned (object as-is, scalar wrapped as `{ result }`).
113
+ */
114
+ export async function runLlmJob(vars, binding, rt) {
115
+ const wantJson = !!binding.output;
116
+ const provider = resolveProvider(binding, rt.env);
117
+ const messages = buildMessages(vars);
118
+ const text = await callLlm(provider, messages, { json: wantJson, fetch: rt.fetch });
119
+ if (!wantJson)
120
+ return { text };
121
+ let parsed;
122
+ try {
123
+ parsed = JSON.parse(text);
124
+ }
125
+ catch {
126
+ throw new Error(`llm worker: expected JSON output but got: ${text.slice(0, 300)}`);
127
+ }
128
+ // response_format asks for a JSON object, but a provider can still return a scalar, array,
129
+ // or null — none of which is a valid variable map (nor valid decision-rails input).
130
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
131
+ throw new Error(`llm worker: expected a JSON object output but got: ${text.slice(0, 300)}`);
132
+ }
133
+ const obj = parsed;
134
+ const decisionId = binding.output?.decision;
135
+ if (decisionId) {
136
+ if (!rt.evaluateDecision) {
137
+ throw new Error(`llm worker: binding declares output.decision "${decisionId}" but no decision ` +
138
+ `evaluator is available (the engine SDK surface is required for decision rails)`);
139
+ }
140
+ const out = await rt.evaluateDecision(decisionId, obj);
141
+ return out !== null && typeof out === "object" && !Array.isArray(out)
142
+ ? out
143
+ : { result: out };
144
+ }
145
+ return obj;
146
+ }
@@ -1,5 +1,11 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
2
  import type { EngineJob } from "../host.ts";
3
+ import { type DecisionEvaluator } from "./llm.ts";
4
+ /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
5
+ * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
6
+ * SDK shape and adds decision context to a parse failure rather than surfacing a raw
7
+ * "is not a function" / `JSON.parse` error. */
8
+ export declare function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator;
3
9
  /** A handler as authored by an app: the job plus the injected app API. */
4
10
  export type AppJobHandler = (job: EngineJob, app: AppApi) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
5
11
  /**
@@ -2,6 +2,30 @@
2
2
  // on the engine. Handlers are resolved by a small, documented contract and are injected
3
3
  // with the app's runtime API (datasource + engine + host utils).
4
4
  import { workerJobType } from "../manifest.js";
5
+ import { runLlmJob } from "./llm.js";
6
+ /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
7
+ * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
8
+ * SDK shape and adds decision context to a parse failure rather than surfacing a raw
9
+ * "is not a function" / `JSON.parse` error. */
10
+ export function sdkDecisionEvaluator(sdk) {
11
+ const client = sdk;
12
+ return async (decisionId, variables) => {
13
+ if (typeof client.evaluateDecision !== "function") {
14
+ throw new Error(`llm worker: the engine SDK does not support evaluateDecision ` +
15
+ `(needed for decision rails "${decisionId}")`);
16
+ }
17
+ const res = await client.evaluateDecision({ decisionDefinitionId: decisionId, variables });
18
+ const out = res?.output;
19
+ if (typeof out !== "string")
20
+ return out;
21
+ try {
22
+ return JSON.parse(out);
23
+ }
24
+ catch {
25
+ throw new Error(`llm worker: decision "${decisionId}" returned unparseable JSON output: ${out.slice(0, 300)}`);
26
+ }
27
+ };
28
+ }
5
29
  /**
6
30
  * Resolve a handler for `jobType` from a loaded module, in priority order:
7
31
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -45,12 +69,31 @@ export async function mountWorkers(ctx, app) {
45
69
  if (!jobType)
46
70
  continue;
47
71
  if (!decl.handler) {
48
- // llm-backed workers (schema `oneOf` handler|llm) are validated but not yet
49
- // executed by the runtime. Skip with a clear warning rather than crashing.
50
- ctx.host.log("warn", "skipping worker: llm-backed workers are not yet implemented", {
51
- jobType,
52
- llm: decl.llm,
72
+ // LLM-backed worker (schema `oneOf` handler|llm): synthesise a handler that runs
73
+ // the job through the bound LLM (and optional decision rails) instead of loading a
74
+ // handler module. A declared-but-unknown binding is a manifest error → fail loudly.
75
+ if (!decl.llm) {
76
+ ctx.host.log("warn", "skipping worker: neither a handler nor an llm binding declared", {
77
+ jobType,
78
+ });
79
+ continue;
80
+ }
81
+ const binding = (ctx.manifest.llm ?? {})[decl.llm];
82
+ if (!binding) {
83
+ throw new Error(`worker "${jobType}" references unknown llm binding "${decl.llm}" ` +
84
+ `(no llm["${decl.llm}"] in the manifest)`);
85
+ }
86
+ const rt = {
87
+ env: (n) => app.env(n),
88
+ evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
89
+ };
90
+ const wrapped = (job) => runLlmJob(job.variables, binding, rt);
91
+ const sub = await ctx.engine.registerWorker(jobType, wrapped, {
92
+ workerName: `${ctx.manifest.id}:${jobType}`,
53
93
  });
94
+ subs.push(sub);
95
+ jobTypes.push(jobType);
96
+ ctx.host.log("info", "llm worker registered", { jobType, llm: decl.llm });
54
97
  continue;
55
98
  }
56
99
  const mod = await loadModule(decl.handler);
@@ -1,7 +1,7 @@
1
1
  export { createUrbanApp } from "./core/runtime.ts";
2
2
  export type { CreateUrbanAppOptions, MountFlags, UrbanApp, } from "./core/runtime.ts";
3
3
  export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.ts";
4
- export type { ActionDecl, AppManifest, DataSource, DomainType, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
4
+ export type { ActionDecl, AppManifest, DataSource, DomainType, LlmBinding, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
5
5
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
7
  export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WorkerSubscription, } from "./core/host.ts";
@@ -15,7 +15,9 @@ export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./co
15
15
  export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
16
16
  export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
17
17
  export type { AppJobHandler } from "./core/modules/workers.ts";
18
- export { resolveHandler } from "./core/modules/workers.ts";
18
+ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts";
19
+ export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
20
+ export type { ChatMessage, DecisionEvaluator, EnvLookup, LlmRuntime, LlmVars, ProviderConfig, } from "./core/modules/llm.ts";
19
21
  export { evalCorrelation } from "./core/modules/triggers.ts";
20
22
  export { createNodeHost } from "./adapters/node.ts";
21
23
  export { createDenoHost } from "./adapters/deno.ts";
@@ -10,7 +10,8 @@ export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
10
10
  export { makeGateway, Table } from "./core/modules/gateway.js";
11
11
  export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
12
12
  export { mountActions, resolveActionHandler } from "./core/modules/actions.js";
13
- export { resolveHandler } from "./core/modules/workers.js";
13
+ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
14
+ export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
14
15
  export { evalCorrelation } from "./core/modules/triggers.js";
15
16
  // Adapters + engine + run entrypoint
16
17
  export { createNodeHost } from "./adapters/node.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/nano-sdk": "^1.2.0",
62
- "@nanobpm/workflow": "^0.6.0",
62
+ "@nanobpm/workflow": "^0.7.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2",
64
64
  "create-urban-app": "^0.2.0"
65
65
  },
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  } from "@nanobpm/workflow";
42
42
  export type {
43
43
  FlowBuilder,
44
+ TimerStart,
44
45
  Envelope,
45
46
  EnvelopeType,
46
47
  EnvelopeField,
@@ -32,6 +32,24 @@ export interface WorkerDecl {
32
32
  llm?: string;
33
33
  }
34
34
 
35
+ /**
36
+ * One named LLM binding (`llm.<id>` in the manifest). A binding is usable as a worker
37
+ * (LLM-as-worker, ADR 0022 §E role 1) or as a chat surface agent. `provider` selects how
38
+ * the endpoint is resolved (`"env"` reads it from the environment); `model` is typically
39
+ * an env template. When `output.decision` is set, the model's JSON reply is fed through
40
+ * that DMN decision (the "rails") and the decision's output is returned instead.
41
+ */
42
+ export interface LlmBinding {
43
+ /** Provider selector. `"env"` resolves an OpenAI-compatible endpoint from the environment. */
44
+ provider: string;
45
+ /** Model id, typically an env template (e.g. `${NANO_APP_LLM_MODEL}`). */
46
+ model: string;
47
+ /** Constrains the model's structured output — currently a DMN decision id (the rails). */
48
+ output?: { decision?: string };
49
+ /** Action-API tools the agent may call (chat-agent role; unused by the worker role). */
50
+ tools?: string[];
51
+ }
52
+
35
53
  export interface TriggerDecl {
36
54
  id: string;
37
55
  type: string;
@@ -86,7 +104,7 @@ export interface AppManifest {
86
104
  surfaces?: Record<string, SurfaceDecl>;
87
105
  actions?: ActionDecl[];
88
106
  workers?: WorkerDecl[];
89
- llm?: Record<string, unknown>;
107
+ llm?: Record<string, LlmBinding>;
90
108
  security?: Record<string, unknown>;
91
109
  [k: string]: unknown;
92
110
  }
@@ -0,0 +1,237 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { LlmBinding } from "../manifest.ts";
4
+ import {
5
+ buildMessages,
6
+ callLlm,
7
+ type ChatMessage,
8
+ type EnvLookup,
9
+ resolveProvider,
10
+ runLlmJob,
11
+ } from "./llm.ts";
12
+
13
+ const noEnv: EnvLookup = () => undefined;
14
+ const envOf =
15
+ (m: Record<string, string>): EnvLookup =>
16
+ (k) =>
17
+ m[k];
18
+
19
+ /** A minimal fetch double returning a fixed chat-completion. */
20
+ function fakeFetch(
21
+ content: string,
22
+ opts: { ok?: boolean; status?: number; capture?: (url: string, init: RequestInit) => void } = {},
23
+ ): typeof fetch {
24
+ return (async (url: string, init: RequestInit) => {
25
+ opts.capture?.(String(url), init);
26
+ return {
27
+ ok: opts.ok ?? true,
28
+ status: opts.status ?? 200,
29
+ json: async () => ({ choices: [{ message: { content } }] }),
30
+ text: async () => content,
31
+ };
32
+ }) as unknown as typeof fetch;
33
+ }
34
+
35
+ // --- resolveProvider -------------------------------------------------------
36
+
37
+ test("resolveProvider defaults to the local Ollama endpoint and env-templates the model", () => {
38
+ const binding: LlmBinding = { provider: "env", model: "${NANO_APP_LLM_MODEL}" };
39
+ const p = resolveProvider(binding, envOf({ NANO_APP_LLM_MODEL: "llama3" }));
40
+ assert.equal(p.baseUrl, "http://localhost:11434/v1");
41
+ assert.equal(p.model, "llama3");
42
+ assert.equal(p.apiKey, undefined);
43
+ });
44
+
45
+ test("resolveProvider honours NANO_APP_LLM_BASE_URL (trailing slash trimmed) + api key", () => {
46
+ const p = resolveProvider(
47
+ { provider: "env", model: "gpt-4o-mini" },
48
+ envOf({ NANO_APP_LLM_BASE_URL: "https://api.example.com/v1/", NANO_APP_LLM_API_KEY: "sk-x" }),
49
+ );
50
+ assert.equal(p.baseUrl, "https://api.example.com/v1");
51
+ assert.equal(p.model, "gpt-4o-mini");
52
+ assert.equal(p.apiKey, "sk-x");
53
+ });
54
+
55
+ test("resolveProvider falls back to NANO_APP_LLM_MODEL when the binding model is empty", () => {
56
+ const p = resolveProvider({ provider: "env", model: "" }, envOf({ NANO_APP_LLM_MODEL: "phi3" }));
57
+ assert.equal(p.model, "phi3");
58
+ });
59
+
60
+ test("resolveProvider throws when no model resolves", () => {
61
+ assert.throws(() => resolveProvider({ provider: "env", model: "" }, noEnv), /no model resolved/);
62
+ });
63
+
64
+ test("resolveProvider throws on an unsupported provider", () => {
65
+ assert.throws(
66
+ () => resolveProvider({ provider: "openai", model: "gpt" }, noEnv),
67
+ /unsupported provider "openai"/,
68
+ );
69
+ });
70
+
71
+ // --- buildMessages ---------------------------------------------------------
72
+
73
+ test("buildMessages wraps a prompt string as a single user message", () => {
74
+ assert.deepEqual(buildMessages({ prompt: "hi" }), [{ role: "user", content: "hi" }]);
75
+ });
76
+
77
+ test("buildMessages prepends an optional system message", () => {
78
+ assert.deepEqual(buildMessages({ prompt: "hi", system: "be terse" }), [
79
+ { role: "system", content: "be terse" },
80
+ { role: "user", content: "hi" },
81
+ ]);
82
+ });
83
+
84
+ test("buildMessages uses a messages array verbatim", () => {
85
+ const messages: ChatMessage[] = [
86
+ { role: "user", content: "a" },
87
+ { role: "assistant", content: "b" },
88
+ ];
89
+ assert.deepEqual(buildMessages({ messages }), messages);
90
+ });
91
+
92
+ test("buildMessages rejects a malformed messages entry", () => {
93
+ assert.throws(() => buildMessages({ messages: [{ role: "user" }] }), /messages\[0\]/);
94
+ assert.throws(() => buildMessages({ messages: [{ role: "bogus", content: "x" }] }), /messages\[0\]/);
95
+ });
96
+
97
+ test("buildMessages requires a prompt or messages", () => {
98
+ assert.throws(() => buildMessages({}), /needs a 'prompt' string or a 'messages' array/);
99
+ });
100
+
101
+ test("buildMessages rejects an empty messages array", () => {
102
+ assert.throws(() => buildMessages({ messages: [] }), /must not be an empty array/);
103
+ });
104
+
105
+ // --- callLlm ---------------------------------------------------------------
106
+
107
+ test("callLlm posts to /chat/completions and returns the message content", async () => {
108
+ let seen: { url: string; init: RequestInit } | undefined;
109
+ const f = fakeFetch("hello world", { capture: (url, init) => (seen = { url, init }) });
110
+ const out = await callLlm({ baseUrl: "http://h/v1", model: "m", apiKey: "k" }, [
111
+ { role: "user", content: "hi" },
112
+ ], { fetch: f });
113
+ assert.equal(out, "hello world");
114
+ assert.equal(seen?.url, "http://h/v1/chat/completions");
115
+ assert.equal(seen?.init.method, "POST");
116
+ const headers = seen?.init.headers as Record<string, string>;
117
+ assert.equal(headers.authorization, "Bearer k");
118
+ const body = JSON.parse(String(seen?.init.body));
119
+ assert.equal(body.model, "m");
120
+ assert.equal(body.response_format, undefined);
121
+ });
122
+
123
+ test("callLlm requests a JSON object response when json:true", async () => {
124
+ let body: Record<string, unknown> = {};
125
+ const f = fakeFetch("{}", { capture: (_u, init) => (body = JSON.parse(String(init.body))) });
126
+ await callLlm({ baseUrl: "http://h/v1", model: "m" }, [{ role: "user", content: "hi" }], {
127
+ json: true,
128
+ fetch: f,
129
+ });
130
+ assert.deepEqual(body.response_format, { type: "json_object" });
131
+ });
132
+
133
+ test("callLlm throws on a non-ok provider response", async () => {
134
+ const f = fakeFetch("boom", { ok: false, status: 500 });
135
+ await assert.rejects(
136
+ callLlm({ baseUrl: "http://h/v1", model: "m" }, [{ role: "user", content: "hi" }], { fetch: f }),
137
+ /llm provider returned 500/,
138
+ );
139
+ });
140
+
141
+ test("callLlm throws when the provider returns no content", async () => {
142
+ const f = (async () => ({ ok: true, status: 200, json: async () => ({ choices: [] }) })) as unknown as typeof fetch;
143
+ await assert.rejects(
144
+ callLlm({ baseUrl: "http://h/v1", model: "m" }, [{ role: "user", content: "hi" }], { fetch: f }),
145
+ /no message content/,
146
+ );
147
+ });
148
+
149
+ // --- runLlmJob -------------------------------------------------------------
150
+
151
+ const model = envOf({ NANO_APP_LLM_MODEL: "m" });
152
+
153
+ test("runLlmJob returns raw text when the binding has no output schema", async () => {
154
+ const out = await runLlmJob({ prompt: "hi" }, { provider: "env", model: "" }, {
155
+ env: model,
156
+ fetch: fakeFetch("plain answer"),
157
+ });
158
+ assert.deepEqual(out, { text: "plain answer" });
159
+ });
160
+
161
+ test("runLlmJob parses JSON output when the binding sets output", async () => {
162
+ const out = await runLlmJob(
163
+ { prompt: "classify" },
164
+ { provider: "env", model: "", output: {} },
165
+ { env: model, fetch: fakeFetch('{"decision":"risk","score":0.9}') },
166
+ );
167
+ assert.deepEqual(out, { decision: "risk", score: 0.9 });
168
+ });
169
+
170
+ test("runLlmJob throws when JSON output is expected but not returned", async () => {
171
+ await assert.rejects(
172
+ runLlmJob({ prompt: "x" }, { provider: "env", model: "", output: {} }, {
173
+ env: model,
174
+ fetch: fakeFetch("not json"),
175
+ }),
176
+ /expected JSON output/,
177
+ );
178
+ });
179
+
180
+ test("runLlmJob rejects a non-object JSON output (scalar/array/null)", async () => {
181
+ for (const bad of ["42", "[1,2]", "null", '"just a string"']) {
182
+ await assert.rejects(
183
+ runLlmJob({ prompt: "x" }, { provider: "env", model: "", output: {} }, {
184
+ env: model,
185
+ fetch: fakeFetch(bad),
186
+ }),
187
+ /expected a JSON object output/,
188
+ `should reject ${bad}`,
189
+ );
190
+ }
191
+ });
192
+
193
+ test("runLlmJob feeds parsed JSON through the decision rails and returns its output", async () => {
194
+ const calls: Array<{ id: string; vars: Record<string, unknown> }> = [];
195
+ const out = await runLlmJob(
196
+ { prompt: "x" },
197
+ { provider: "env", model: "", output: { decision: "risk" } },
198
+ {
199
+ env: model,
200
+ fetch: fakeFetch('{"amount":100}'),
201
+ evaluateDecision: async (id, vars) => {
202
+ calls.push({ id, vars });
203
+ return { approved: true };
204
+ },
205
+ },
206
+ );
207
+ assert.deepEqual(out, { approved: true });
208
+ assert.deepEqual(calls, [{ id: "risk", vars: { amount: 100 } }]);
209
+ });
210
+
211
+ test("runLlmJob wraps a scalar decision output as { result }", async () => {
212
+ const out = await runLlmJob(
213
+ { prompt: "x" },
214
+ { provider: "env", model: "", output: { decision: "risk" } },
215
+ { env: model, fetch: fakeFetch("{}"), evaluateDecision: async () => "APPROVE" },
216
+ );
217
+ assert.deepEqual(out, { result: "APPROVE" });
218
+ });
219
+
220
+ test("runLlmJob wraps an array decision output as { result } (not a var map)", async () => {
221
+ const out = await runLlmJob(
222
+ { prompt: "x" },
223
+ { provider: "env", model: "", output: { decision: "risk" } },
224
+ { env: model, fetch: fakeFetch("{}"), evaluateDecision: async () => [1, 2] },
225
+ );
226
+ assert.deepEqual(out, { result: [1, 2] });
227
+ });
228
+
229
+ test("runLlmJob throws when decision rails are declared but no evaluator is available", async () => {
230
+ await assert.rejects(
231
+ runLlmJob({ prompt: "x" }, { provider: "env", model: "", output: { decision: "risk" } }, {
232
+ env: model,
233
+ fetch: fakeFetch("{}"),
234
+ }),
235
+ /no decision evaluator is available/,
236
+ );
237
+ });
@@ -0,0 +1,209 @@
1
+ // LLM-as-worker runtime (ADR 0022 §E role 1) — the batteries-included implementation of
2
+ // the manifest's `llm` seam. For every `workers[]` entry that carries an `llm` binding
3
+ // (instead of a `handler` file), `workers.ts` registers a job worker on that task type
4
+ // whose handler is an LLM call — no external connector runtime required.
5
+ //
6
+ // A worker's contract:
7
+ // { "taskType": "classify", "llm": "classifier" } // workers[]
8
+ // "llm": { "classifier": { "provider": "env", // llm registry
9
+ // "model": "${NANO_APP_LLM_MODEL}",
10
+ // "output": { "decision": "risk" } } }
11
+ //
12
+ // The job's input variables drive the prompt (`prompt` string and/or `messages` array,
13
+ // optional `system`); the model's reply becomes the job's output variables. When the
14
+ // binding declares `output.decision`, the model's JSON is fed through that DMN decision
15
+ // (the rails) and the decision's output is returned instead — the LLM proposes, the
16
+ // decision disposes.
17
+ //
18
+ // Provider is configuration, not code: `provider: "env"` resolves an OpenAI-compatible
19
+ // endpoint from the environment, so a fully-local model (Ollama, LM Studio, llama.cpp,
20
+ // vLLM, …) or a hosted API both work unchanged. This module is host-agnostic: env and
21
+ // fetch are injected (env always via the host seam), never read from a concrete runtime.
22
+
23
+ import type { LlmBinding } from "../manifest.ts";
24
+ import { expandEnvString } from "../manifest.ts";
25
+
26
+ /** Job variables/headers default to untyped JSON. */
27
+ export type LlmVars = Record<string, unknown>;
28
+
29
+ /** Reads an environment variable (routed through the host seam). */
30
+ export type EnvLookup = (name: string) => string | undefined;
31
+
32
+ /** One chat message on the wire. */
33
+ export interface ChatMessage {
34
+ role: "system" | "user" | "assistant";
35
+ content: string;
36
+ }
37
+
38
+ /** A resolved provider endpoint (OpenAI-compatible chat completions). */
39
+ export interface ProviderConfig {
40
+ baseUrl: string;
41
+ apiKey?: string;
42
+ model: string;
43
+ }
44
+
45
+ /**
46
+ * Evaluate a DMN decision (the output "rails"), returning its already-parsed output.
47
+ * Injected by the caller so this module stays free of any engine/SDK coupling.
48
+ */
49
+ export type DecisionEvaluator = (
50
+ decisionId: string,
51
+ variables: Record<string, unknown>,
52
+ ) => Promise<unknown>;
53
+
54
+ /** Everything running one LLM job needs, injected for host-agnosticism + testability. */
55
+ export interface LlmRuntime {
56
+ env: EnvLookup;
57
+ /** Fetch implementation. Defaults to the global `fetch`. */
58
+ fetch?: typeof fetch;
59
+ /** Decision-rails evaluator; required only when a binding sets `output.decision`. */
60
+ evaluateDecision?: DecisionEvaluator;
61
+ }
62
+
63
+ /**
64
+ * Resolve an OpenAI-compatible endpoint for a binding. `provider: "env"` (the only
65
+ * built-in today) reads:
66
+ * - NANO_APP_LLM_BASE_URL (default http://localhost:11434/v1 — Ollama's
67
+ * OpenAI-compatible endpoint; override for any host)
68
+ * - NANO_APP_LLM_API_KEY (optional; sent as `Authorization: Bearer` if set)
69
+ * - NANO_APP_LLM_MODEL (fallback when the binding's `model` resolves empty)
70
+ * The binding's `model` is env-templated (`${VAR}` / `${VAR:-default}`).
71
+ */
72
+ export function resolveProvider(binding: LlmBinding, env: EnvLookup): ProviderConfig {
73
+ if (binding.provider !== "env") {
74
+ throw new Error(
75
+ `llm worker: unsupported provider "${binding.provider}" (only "env" is built in — ` +
76
+ `it resolves an OpenAI-compatible endpoint from NANO_APP_LLM_* env)`,
77
+ );
78
+ }
79
+ const baseUrl = (env("NANO_APP_LLM_BASE_URL") ?? "http://localhost:11434/v1").replace(/\/+$/, "");
80
+ const apiKey = env("NANO_APP_LLM_API_KEY") || undefined;
81
+ const model = expandEnvString(binding.model ?? "", env) || env("NANO_APP_LLM_MODEL") || "";
82
+ if (!model) {
83
+ throw new Error(
84
+ `llm worker: no model resolved for binding (model="${binding.model}"); ` +
85
+ `set NANO_APP_LLM_MODEL or a literal model`,
86
+ );
87
+ }
88
+ return { baseUrl, apiKey, model };
89
+ }
90
+
91
+ /**
92
+ * Build the chat messages for a job's variables. Input contract:
93
+ * - `messages`: a ready `{role,content}[]` (used verbatim), or
94
+ * - `prompt`: a user-message string.
95
+ * An optional `system` string is prepended.
96
+ */
97
+ export function buildMessages(vars: LlmVars): ChatMessage[] {
98
+ const system = typeof vars.system === "string" ? vars.system : undefined;
99
+
100
+ let msgs: ChatMessage[];
101
+ if (Array.isArray(vars.messages)) {
102
+ if (vars.messages.length === 0) {
103
+ throw new Error("llm worker: 'messages' must not be an empty array");
104
+ }
105
+ msgs = (vars.messages as unknown[]).map((m, i) => {
106
+ const o = m as { role?: unknown; content?: unknown };
107
+ if (
108
+ typeof o.content !== "string" ||
109
+ (o.role !== "user" && o.role !== "assistant" && o.role !== "system")
110
+ ) {
111
+ throw new Error(
112
+ `llm worker: messages[${i}] must be { role: user|assistant|system, content: string }`,
113
+ );
114
+ }
115
+ return { role: o.role, content: o.content };
116
+ });
117
+ } else if (typeof vars.prompt === "string") {
118
+ msgs = [{ role: "user", content: vars.prompt }];
119
+ } else {
120
+ throw new Error(
121
+ "llm worker: job needs a 'prompt' string or a 'messages' array in its variables",
122
+ );
123
+ }
124
+
125
+ return system ? [{ role: "system", content: system }, ...msgs] : msgs;
126
+ }
127
+
128
+ /**
129
+ * Call the model's chat-completions endpoint. When `json` is set, requests a JSON object
130
+ * response (`response_format`) so structured output can be parsed. Returns the assistant
131
+ * message text.
132
+ */
133
+ export async function callLlm(
134
+ provider: ProviderConfig,
135
+ messages: ChatMessage[],
136
+ opts: { json?: boolean; fetch?: typeof fetch } = {},
137
+ ): Promise<string> {
138
+ const doFetch = opts.fetch ?? fetch;
139
+ const headers: Record<string, string> = { "content-type": "application/json" };
140
+ if (provider.apiKey) headers.authorization = `Bearer ${provider.apiKey}`;
141
+ const body: Record<string, unknown> = { model: provider.model, messages };
142
+ if (opts.json) body.response_format = { type: "json_object" };
143
+
144
+ const res = await doFetch(`${provider.baseUrl}/chat/completions`, {
145
+ method: "POST",
146
+ headers,
147
+ body: JSON.stringify(body),
148
+ });
149
+ if (!res.ok) {
150
+ const detail = await res.text().catch(() => "");
151
+ throw new Error(`llm provider returned ${res.status}: ${detail.slice(0, 500)}`);
152
+ }
153
+ const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
154
+ const content = data.choices?.[0]?.message?.content;
155
+ if (typeof content !== "string") {
156
+ throw new Error("llm provider returned no message content");
157
+ }
158
+ return content;
159
+ }
160
+
161
+ /**
162
+ * Run one job through the LLM (and optional decision rails). Returns the output variables
163
+ * the job is completed with.
164
+ *
165
+ * - No `output` binding → returns `{ text }` (the raw completion).
166
+ * - `output` set → requests JSON, parses it; the parsed object is the output.
167
+ * - `output.decision` set → the parsed JSON is fed to that DMN decision and the decision's
168
+ * output is returned (object as-is, scalar wrapped as `{ result }`).
169
+ */
170
+ export async function runLlmJob(
171
+ vars: LlmVars,
172
+ binding: LlmBinding,
173
+ rt: LlmRuntime,
174
+ ): Promise<Record<string, unknown>> {
175
+ const wantJson = !!binding.output;
176
+ const provider = resolveProvider(binding, rt.env);
177
+ const messages = buildMessages(vars);
178
+ const text = await callLlm(provider, messages, { json: wantJson, fetch: rt.fetch });
179
+
180
+ if (!wantJson) return { text };
181
+
182
+ let parsed: unknown;
183
+ try {
184
+ parsed = JSON.parse(text);
185
+ } catch {
186
+ throw new Error(`llm worker: expected JSON output but got: ${text.slice(0, 300)}`);
187
+ }
188
+ // response_format asks for a JSON object, but a provider can still return a scalar, array,
189
+ // or null — none of which is a valid variable map (nor valid decision-rails input).
190
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
191
+ throw new Error(`llm worker: expected a JSON object output but got: ${text.slice(0, 300)}`);
192
+ }
193
+ const obj = parsed as Record<string, unknown>;
194
+
195
+ const decisionId = binding.output?.decision;
196
+ if (decisionId) {
197
+ if (!rt.evaluateDecision) {
198
+ throw new Error(
199
+ `llm worker: binding declares output.decision "${decisionId}" but no decision ` +
200
+ `evaluator is available (the engine SDK surface is required for decision rails)`,
201
+ );
202
+ }
203
+ const out = await rt.evaluateDecision(decisionId, obj);
204
+ return out !== null && typeof out === "object" && !Array.isArray(out)
205
+ ? (out as Record<string, unknown>)
206
+ : { result: out };
207
+ }
208
+ return obj;
209
+ }
@@ -0,0 +1,158 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { AppApi, RuntimeContext } from "../context.ts";
4
+ import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
5
+ import type { AppManifest } from "../manifest.ts";
6
+ import { mountWorkers, sdkDecisionEvaluator } from "./workers.ts";
7
+
8
+ /** A tiny engine that records registrations and can deliver a job to a handler. */
9
+ class MiniEngine {
10
+ workers = new Map<string, JobHandler>();
11
+ async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
12
+ this.workers.set(jobType, handler);
13
+ return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
14
+ }
15
+ deliver(jobType: string, job: EngineJob) {
16
+ const h = this.workers.get(jobType);
17
+ if (!h) throw new Error(`no worker for ${jobType}`);
18
+ return h(job);
19
+ }
20
+ }
21
+
22
+ function makeCtx(
23
+ manifest: Partial<AppManifest>,
24
+ engine: MiniEngine,
25
+ ): { ctx: RuntimeContext; logs: Array<{ level: string; msg: string }> } {
26
+ const logs: Array<{ level: string; msg: string }> = [];
27
+ const ctx = {
28
+ root: "/app",
29
+ manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest } as AppManifest,
30
+ engine: engine as unknown as RuntimeContext["engine"],
31
+ host: {
32
+ log: (level: string, msg: string) => logs.push({ level, msg }),
33
+ importModule: () => Promise.reject(new Error("no modules in this test")),
34
+ },
35
+ } as unknown as RuntimeContext;
36
+ return { ctx, logs };
37
+ }
38
+
39
+ function makeApp(over: Partial<AppApi> = {}): AppApi {
40
+ return {
41
+ env: (n) => ({ NANO_APP_LLM_MODEL: "m" } as Record<string, string>)[n],
42
+ log: () => {},
43
+ ...over,
44
+ } as AppApi;
45
+ }
46
+
47
+ function fakeFetch(content: string): typeof fetch {
48
+ return (async () => ({
49
+ ok: true,
50
+ status: 200,
51
+ json: async () => ({ choices: [{ message: { content } }] }),
52
+ text: async () => content,
53
+ })) as unknown as typeof fetch;
54
+ }
55
+
56
+ const orig = globalThis.fetch;
57
+ function withFetch(content: string, fn: () => Promise<void>): Promise<void> {
58
+ globalThis.fetch = fakeFetch(content);
59
+ return fn().finally(() => {
60
+ globalThis.fetch = orig;
61
+ });
62
+ }
63
+
64
+ test("mountWorkers registers an llm-bound worker and runs the job through the model", async () => {
65
+ const engine = new MiniEngine();
66
+ const { ctx, logs } = makeCtx(
67
+ {
68
+ workers: [{ taskType: "classify", llm: "classifier" }],
69
+ llm: { classifier: { provider: "env", model: "" } },
70
+ },
71
+ engine,
72
+ );
73
+ const handle = await mountWorkers(ctx, makeApp());
74
+ assert.deepEqual(handle.jobTypes, ["classify"]);
75
+ assert.ok(logs.some((l) => l.msg === "llm worker registered"));
76
+
77
+ await withFetch("hi there", async () => {
78
+ const out = await engine.deliver("classify", {
79
+ jobKey: "j1",
80
+ jobType: "classify",
81
+ variables: { prompt: "who are you" },
82
+ });
83
+ assert.deepEqual(out, { text: "hi there" });
84
+ });
85
+ await handle.stop();
86
+ });
87
+
88
+ test("mountWorkers throws when an llm worker references an unknown binding", async () => {
89
+ const engine = new MiniEngine();
90
+ const { ctx } = makeCtx(
91
+ { workers: [{ taskType: "classify", llm: "missing" }], llm: {} },
92
+ engine,
93
+ );
94
+ await assert.rejects(mountWorkers(ctx, makeApp()), /unknown llm binding "missing"/);
95
+ });
96
+
97
+ test("mountWorkers skips (with a warning) a worker with neither handler nor llm", async () => {
98
+ const engine = new MiniEngine();
99
+ const { ctx, logs } = makeCtx({ workers: [{ taskType: "orphan" }] }, engine);
100
+ const handle = await mountWorkers(ctx, makeApp());
101
+ assert.deepEqual(handle.jobTypes, []);
102
+ assert.ok(logs.some((l) => l.level === "warn" && /neither a handler nor an llm/.test(l.msg)));
103
+ });
104
+
105
+ test("an llm worker with decision rails uses app.sdk to evaluate the decision", async () => {
106
+ const engine = new MiniEngine();
107
+ const evaluated: Array<{ id: string; vars: Record<string, unknown> }> = [];
108
+ const sdk = {
109
+ evaluateDecision: async (input: { decisionDefinitionId: string; variables: Record<string, unknown> }) => {
110
+ evaluated.push({ id: input.decisionDefinitionId, vars: input.variables });
111
+ return { output: JSON.stringify({ approved: true }) };
112
+ },
113
+ };
114
+ const { ctx } = makeCtx(
115
+ {
116
+ workers: [{ taskType: "risk", llm: "risker" }],
117
+ llm: { risker: { provider: "env", model: "", output: { decision: "risk-table" } } },
118
+ },
119
+ engine,
120
+ );
121
+ await mountWorkers(ctx, makeApp({ sdk: sdk as unknown as AppApi["sdk"] }));
122
+
123
+ await withFetch('{"amount":100}', async () => {
124
+ const out = await engine.deliver("risk", {
125
+ jobKey: "j1",
126
+ jobType: "risk",
127
+ variables: { prompt: "score it" },
128
+ });
129
+ assert.deepEqual(out, { approved: true });
130
+ });
131
+ assert.deepEqual(evaluated, [{ id: "risk-table", vars: { amount: 100 } }]);
132
+ });
133
+
134
+ test("sdkDecisionEvaluator parses the decision's JSON-string output", async () => {
135
+ const evaluate = sdkDecisionEvaluator({
136
+ evaluateDecision: async () => ({ output: JSON.stringify({ ok: 1 }) }),
137
+ });
138
+ assert.deepEqual(await evaluate("d", {}), { ok: 1 });
139
+ });
140
+
141
+ test("sdkDecisionEvaluator passes through a non-string output", async () => {
142
+ const evaluate = sdkDecisionEvaluator({
143
+ evaluateDecision: async () => ({ output: { ok: 2 } }),
144
+ });
145
+ assert.deepEqual(await evaluate("d", {}), { ok: 2 });
146
+ });
147
+
148
+ test("sdkDecisionEvaluator throws with decision context on unparseable output", async () => {
149
+ const evaluate = sdkDecisionEvaluator({
150
+ evaluateDecision: async () => ({ output: "not json" }),
151
+ });
152
+ await assert.rejects(evaluate("risk", {}), /decision "risk" returned unparseable JSON/);
153
+ });
154
+
155
+ test("sdkDecisionEvaluator throws a clear error when the SDK lacks evaluateDecision", async () => {
156
+ const evaluate = sdkDecisionEvaluator({});
157
+ await assert.rejects(evaluate("risk", {}), /does not support evaluateDecision.*"risk"/);
158
+ });
@@ -5,6 +5,42 @@
5
5
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
6
6
  import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
7
7
  import { workerJobType, type WorkerDecl } from "../manifest.ts";
8
+ import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
9
+
10
+ /** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
11
+ * whose `output` comes back as a JSON string (the orchestration-cluster contract). */
12
+ interface DecisionCapableSdk {
13
+ evaluateDecision(input: {
14
+ decisionDefinitionId: string;
15
+ variables: Record<string, unknown>;
16
+ }): Promise<{ output?: unknown }>;
17
+ }
18
+
19
+ /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
20
+ * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
21
+ * SDK shape and adds decision context to a parse failure rather than surfacing a raw
22
+ * "is not a function" / `JSON.parse` error. */
23
+ export function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator {
24
+ const client = sdk as Partial<DecisionCapableSdk>;
25
+ return async (decisionId, variables) => {
26
+ if (typeof client.evaluateDecision !== "function") {
27
+ throw new Error(
28
+ `llm worker: the engine SDK does not support evaluateDecision ` +
29
+ `(needed for decision rails "${decisionId}")`,
30
+ );
31
+ }
32
+ const res = await client.evaluateDecision({ decisionDefinitionId: decisionId, variables });
33
+ const out = res?.output;
34
+ if (typeof out !== "string") return out;
35
+ try {
36
+ return JSON.parse(out);
37
+ } catch {
38
+ throw new Error(
39
+ `llm worker: decision "${decisionId}" returned unparseable JSON output: ${out.slice(0, 300)}`,
40
+ );
41
+ }
42
+ };
43
+ }
8
44
 
9
45
  /** A handler as authored by an app: the job plus the injected app API. */
10
46
  export type AppJobHandler = (
@@ -59,12 +95,33 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
59
95
  const jobType = workerJobType(decl);
60
96
  if (!jobType) continue;
61
97
  if (!decl.handler) {
62
- // llm-backed workers (schema `oneOf` handler|llm) are validated but not yet
63
- // executed by the runtime. Skip with a clear warning rather than crashing.
64
- ctx.host.log("warn", "skipping worker: llm-backed workers are not yet implemented", {
65
- jobType,
66
- llm: decl.llm,
98
+ // LLM-backed worker (schema `oneOf` handler|llm): synthesise a handler that runs
99
+ // the job through the bound LLM (and optional decision rails) instead of loading a
100
+ // handler module. A declared-but-unknown binding is a manifest error → fail loudly.
101
+ if (!decl.llm) {
102
+ ctx.host.log("warn", "skipping worker: neither a handler nor an llm binding declared", {
103
+ jobType,
104
+ });
105
+ continue;
106
+ }
107
+ const binding = (ctx.manifest.llm ?? {})[decl.llm];
108
+ if (!binding) {
109
+ throw new Error(
110
+ `worker "${jobType}" references unknown llm binding "${decl.llm}" ` +
111
+ `(no llm["${decl.llm}"] in the manifest)`,
112
+ );
113
+ }
114
+ const rt: LlmRuntime = {
115
+ env: (n) => app.env(n),
116
+ evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
117
+ };
118
+ const wrapped: JobHandler = (job) => runLlmJob(job.variables, binding, rt);
119
+ const sub = await ctx.engine.registerWorker(jobType, wrapped, {
120
+ workerName: `${ctx.manifest.id}:${jobType}`,
67
121
  });
122
+ subs.push(sub);
123
+ jobTypes.push(jobType);
124
+ ctx.host.log("info", "llm worker registered", { jobType, llm: decl.llm });
68
125
  continue;
69
126
  }
70
127
  const mod = await loadModule(decl.handler);
@@ -21,6 +21,7 @@ export type {
21
21
  AppManifest,
22
22
  DataSource,
23
23
  DomainType,
24
+ LlmBinding,
24
25
  SurfaceDecl,
25
26
  TriggerDecl,
26
27
  TypeField,
@@ -67,7 +68,21 @@ export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./co
67
68
  export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
68
69
  export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
69
70
  export type { AppJobHandler } from "./core/modules/workers.ts";
70
- export { resolveHandler } from "./core/modules/workers.ts";
71
+ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts";
72
+ export {
73
+ buildMessages,
74
+ callLlm,
75
+ resolveProvider,
76
+ runLlmJob,
77
+ } from "./core/modules/llm.ts";
78
+ export type {
79
+ ChatMessage,
80
+ DecisionEvaluator,
81
+ EnvLookup,
82
+ LlmRuntime,
83
+ LlmVars,
84
+ ProviderConfig,
85
+ } from "./core/modules/llm.ts";
71
86
  export { evalCorrelation } from "./core/modules/triggers.ts";
72
87
 
73
88
  // Adapters + engine + run entrypoint