@flowget/ai 0.1.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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The `propose_workflow` tool — the ONE emit channel.
3
+ *
4
+ * The model authors a workflow by calling this tool; its arguments ARE
5
+ * the workflow graph (position-optional authoring schema) plus a short
6
+ * summary. A plain-text reply (no tool call) is a `message` instead —
7
+ * a plain answer or a clarifying question.
8
+ *
9
+ * The authoring schema uses internal `$ref: "#/$defs/…"` references.
10
+ * When embedded as a tool-parameter property those refs would resolve
11
+ * against the WRONG root, so we hoist the schema's `$defs` to the
12
+ * tool-parameters root and reference the graph as `#/$defs/WorkflowGraph`.
13
+ */
14
+ import type { AiToolDefinition } from "./adapter.js";
15
+ export declare const EMIT_TOOL_NAME = "propose_workflow";
16
+ export declare function buildEmitTool(): AiToolDefinition;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The `propose_workflow` tool — the ONE emit channel.
3
+ *
4
+ * The model authors a workflow by calling this tool; its arguments ARE
5
+ * the workflow graph (position-optional authoring schema) plus a short
6
+ * summary. A plain-text reply (no tool call) is a `message` instead —
7
+ * a plain answer or a clarifying question.
8
+ *
9
+ * The authoring schema uses internal `$ref: "#/$defs/…"` references.
10
+ * When embedded as a tool-parameter property those refs would resolve
11
+ * against the WRONG root, so we hoist the schema's `$defs` to the
12
+ * tool-parameters root and reference the graph as `#/$defs/WorkflowGraph`.
13
+ */
14
+ import { buildAuthoringSchema } from "./authoring-schema.js";
15
+ import { isRecord } from "./guards.js";
16
+ export const EMIT_TOOL_NAME = "propose_workflow";
17
+ export function buildEmitTool() {
18
+ const schema = buildAuthoringSchema();
19
+ const schemaDefs = isRecord(schema["$defs"]) ? schema["$defs"] : {};
20
+ // The graph root, minus the JSON-Schema meta keys — its `$defs` are
21
+ // hoisted so nested `$ref: "#/$defs/WorkflowNode"` resolves.
22
+ const graphRoot = {};
23
+ for (const [key, value] of Object.entries(schema)) {
24
+ if (key === "$schema" || key === "$id" || key === "$defs")
25
+ continue;
26
+ graphRoot[key] = value;
27
+ }
28
+ return {
29
+ name: EMIT_TOOL_NAME,
30
+ description: "Emit the final workflow graph proposal. Call this exactly once, when you have a complete workflow that satisfies the user's command. The graph MUST use only node types present in the provided catalog and MUST validate against the workflow schema. Node positions are optional — omit them; the builder lays the graph out.",
31
+ parameters: {
32
+ type: "object",
33
+ additionalProperties: false,
34
+ required: ["graph", "summary"],
35
+ properties: {
36
+ graph: { $ref: "#/$defs/WorkflowGraph" },
37
+ summary: {
38
+ type: "string",
39
+ description: "One or two sentences describing what the workflow does (or what this change did to the existing graph).",
40
+ },
41
+ },
42
+ $defs: {
43
+ WorkflowGraph: graphRoot,
44
+ ...schemaDefs,
45
+ },
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The guardrail — a pre-LLM policy gate.
3
+ *
4
+ * Runs BEFORE any model call (and before toolsets). It returns
5
+ * `allow` or `block(message)`; a block short-circuits the request into
6
+ * a `message` response with NO adapter call and NO token spend.
7
+ *
8
+ * There is **no default guardrail** — policy is the customer's. The
9
+ * port + call site ship; the customer supplies the rule (rate limits,
10
+ * banned intents, tenant policy, a moderation call, …).
11
+ */
12
+ import type { Actor } from "@flowget/types";
13
+ import type { AuthorRequest } from "./contract.js";
14
+ export type GuardrailDecision = {
15
+ action: "allow";
16
+ } | {
17
+ action: "block";
18
+ message: string;
19
+ };
20
+ export type GuardrailContext = {
21
+ actor?: Actor;
22
+ };
23
+ export type Guardrail = (request: AuthorRequest, context: GuardrailContext) => GuardrailDecision | Promise<GuardrailDecision>;
24
+ /** Convenience: an `allow` decision. */
25
+ export declare const allow: () => GuardrailDecision;
26
+ /** Convenience: a `block` decision carrying the user-facing message. */
27
+ export declare const block: (message: string) => GuardrailDecision;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The guardrail — a pre-LLM policy gate.
3
+ *
4
+ * Runs BEFORE any model call (and before toolsets). It returns
5
+ * `allow` or `block(message)`; a block short-circuits the request into
6
+ * a `message` response with NO adapter call and NO token spend.
7
+ *
8
+ * There is **no default guardrail** — policy is the customer's. The
9
+ * port + call site ship; the customer supplies the rule (rate limits,
10
+ * banned intents, tenant policy, a moderation call, …).
11
+ */
12
+ /** Convenience: an `allow` decision. */
13
+ export const allow = () => ({ action: "allow" });
14
+ /** Convenience: a `block` decision carrying the user-facing message. */
15
+ export const block = (message) => ({
16
+ action: "block",
17
+ message,
18
+ });
@@ -0,0 +1,3 @@
1
+ /** Internal shared type guards. Not part of the public barrel. */
2
+ /** True for a plain object (not null, not an array). */
3
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
package/dist/guards.js ADDED
@@ -0,0 +1,5 @@
1
+ /** Internal shared type guards. Not part of the public barrel. */
2
+ /** True for a plain object (not null, not an array). */
3
+ export function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Audit hooks — the observability seam.
3
+ *
4
+ * `onProposal` fires when a valid workflow proposal is produced;
5
+ * `onBlocked` fires when the guardrail blocks a request. Route them to
6
+ * the customer's permanent audit sink (their format / SIEM). The
7
+ * hooks are best-effort side channels — they run after the decision
8
+ * and never alter the response.
9
+ *
10
+ * A batteries-included append-only sink ({@link inMemoryAuditSink}) is
11
+ * provided for local dev and tests.
12
+ */
13
+ import type { Actor } from "@flowget/types";
14
+ import type { AuthoredGraph } from "./contract.js";
15
+ export type ProposalAuditEvent = {
16
+ type: "proposal";
17
+ command: string;
18
+ actor?: Actor;
19
+ summary: string;
20
+ graph: AuthoredGraph;
21
+ };
22
+ export type BlockedAuditEvent = {
23
+ type: "blocked";
24
+ command: string;
25
+ actor?: Actor;
26
+ /** The guardrail's user-facing block message. */
27
+ reason: string;
28
+ };
29
+ export type AuditEvent = ProposalAuditEvent | BlockedAuditEvent;
30
+ export type AuditHooks = {
31
+ onProposal?: (event: ProposalAuditEvent) => void | Promise<void>;
32
+ onBlocked?: (event: BlockedAuditEvent) => void | Promise<void>;
33
+ };
34
+ export type InMemoryAuditSink = Required<AuditHooks> & {
35
+ /** Append-only log of every audit event, in order. */
36
+ readonly entries: readonly AuditEvent[];
37
+ };
38
+ /**
39
+ * A default append-only, in-memory audit sink. Every proposal / block
40
+ * is pushed to `entries`. Swap for a durable sink in production.
41
+ */
42
+ export declare function inMemoryAuditSink(): InMemoryAuditSink;
package/dist/hooks.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Audit hooks — the observability seam.
3
+ *
4
+ * `onProposal` fires when a valid workflow proposal is produced;
5
+ * `onBlocked` fires when the guardrail blocks a request. Route them to
6
+ * the customer's permanent audit sink (their format / SIEM). The
7
+ * hooks are best-effort side channels — they run after the decision
8
+ * and never alter the response.
9
+ *
10
+ * A batteries-included append-only sink ({@link inMemoryAuditSink}) is
11
+ * provided for local dev and tests.
12
+ */
13
+ /**
14
+ * A default append-only, in-memory audit sink. Every proposal / block
15
+ * is pushed to `entries`. Swap for a durable sink in production.
16
+ */
17
+ export function inMemoryAuditSink() {
18
+ const entries = [];
19
+ return {
20
+ entries,
21
+ onProposal: (event) => {
22
+ entries.push(event);
23
+ },
24
+ onBlocked: (event) => {
25
+ entries.push(event);
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `@flowget/ai` — request-first, stateless AI workflow-authoring PRIMITIVES.
3
+ *
4
+ * `author(config, request)` turns a natural-language command + the
5
+ * customer's node catalog into a schema-valid Flowget workflow graph.
6
+ * This package ships **primitives, not an HTTP server**: build a config
7
+ * with `resolveConfig` and call `author`, then wrap it in whatever
8
+ * transport you need (a customer who wants an API writes it themselves).
9
+ * Every piece — the LLM adapter, catalog source, guardrail, authorizers,
10
+ * toolsets, audit hooks, validator — is a named, typed, swappable hook
11
+ * with a batteries-included default.
12
+ *
13
+ * Quickstart:
14
+ *
15
+ * ```ts
16
+ * import { resolveConfig, author, openaiAdapter } from "@flowget/ai";
17
+ *
18
+ * const config = await resolveConfig({
19
+ * adapter: openaiAdapter({ apiKey: process.env.OPENROUTER_API_KEY! }),
20
+ * catalog: { registryDir: process.env.REGISTRY_DIR! },
21
+ * });
22
+ * const event = await author(config, { command: "email ops on new orders" });
23
+ * // event.kind is "proposal" (a workflow graph) or "message" (a text reply).
24
+ * ```
25
+ *
26
+ * The public surface is an EXPLICIT allow-list (not `export *`): only the
27
+ * names below are semver-stable API. Prompt-assembly, schema-loading, and
28
+ * emit-tool internals (`buildSystemPrompt`, `loadWorkflowSchema`,
29
+ * `buildEmitTool`, `runAuthorizers`, `ToolRegistry`, …) are deliberately
30
+ * kept private so they can evolve without a breaking change.
31
+ */
32
+ export { author, authorStream } from "./author.js";
33
+ export { AuthorizationError } from "./authorizer.js";
34
+ export { buildAuthoringSchema } from "./authoring-schema.js";
35
+ export { resolveCatalog } from "./catalog.js";
36
+ export { finalizeConfig, resolveConfig } from "./config.js";
37
+ export { EMIT_TOOL_NAME } from "./emit-tool.js";
38
+ export { allow, block } from "./guardrail.js";
39
+ export { inMemoryAuditSink } from "./hooks.js";
40
+ export { mockAdapter, mockMessage, mockProposeWorkflow, mockToolCall, } from "./mock-adapter.js";
41
+ export { DEFAULT_MODEL, OPENROUTER_BASE_URL, openaiAdapter, } from "./openai-adapter.js";
42
+ export { DEFAULT_PERSONA, getDefaultSystemPrompt } from "./prompt.js";
43
+ export { assertSafeUrl, UnsafeUrlError } from "./ssrf.js";
44
+ export { createWorkflowValidator } from "./validator.js";
45
+ export type { AiAdapter, AiGenerateRequest, AiGenerateResult, AiMessage, AiStreamPart, AiToolCall, AiToolDefinition, } from "./adapter.js";
46
+ export type { AuthorOptions } from "./author.js";
47
+ export type { Authorizer } from "./authorizer.js";
48
+ export type { CatalogNode, CatalogSource } from "./catalog.js";
49
+ export type { AiConfig, ResolvedConfig } from "./config.js";
50
+ export type { AuthoredGraph, AuthoredNode, AuthorEvent, AuthorMessage, AuthorProposal, AuthorRequest, AuthorStreamEvent, } from "./contract.js";
51
+ export type { Guardrail, GuardrailContext, GuardrailDecision, } from "./guardrail.js";
52
+ export type { AuditEvent, AuditHooks, BlockedAuditEvent, InMemoryAuditSink, ProposalAuditEvent, } from "./hooks.js";
53
+ export type { MockAdapter, MockScript, MockTurn } from "./mock-adapter.js";
54
+ export type { OpenAiAdapterOptions, OpenAiChatClient, } from "./openai-adapter.js";
55
+ export type { AssertSafeUrlOptions } from "./ssrf.js";
56
+ export type { Tool, ToolContext, Toolset } from "./toolset.js";
57
+ export type { ValidationResult, WorkflowValidator, WorkflowValidatorFactory, } from "./validator.js";
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `@flowget/ai` — request-first, stateless AI workflow-authoring PRIMITIVES.
3
+ *
4
+ * `author(config, request)` turns a natural-language command + the
5
+ * customer's node catalog into a schema-valid Flowget workflow graph.
6
+ * This package ships **primitives, not an HTTP server**: build a config
7
+ * with `resolveConfig` and call `author`, then wrap it in whatever
8
+ * transport you need (a customer who wants an API writes it themselves).
9
+ * Every piece — the LLM adapter, catalog source, guardrail, authorizers,
10
+ * toolsets, audit hooks, validator — is a named, typed, swappable hook
11
+ * with a batteries-included default.
12
+ *
13
+ * Quickstart:
14
+ *
15
+ * ```ts
16
+ * import { resolveConfig, author, openaiAdapter } from "@flowget/ai";
17
+ *
18
+ * const config = await resolveConfig({
19
+ * adapter: openaiAdapter({ apiKey: process.env.OPENROUTER_API_KEY! }),
20
+ * catalog: { registryDir: process.env.REGISTRY_DIR! },
21
+ * });
22
+ * const event = await author(config, { command: "email ops on new orders" });
23
+ * // event.kind is "proposal" (a workflow graph) or "message" (a text reply).
24
+ * ```
25
+ *
26
+ * The public surface is an EXPLICIT allow-list (not `export *`): only the
27
+ * names below are semver-stable API. Prompt-assembly, schema-loading, and
28
+ * emit-tool internals (`buildSystemPrompt`, `loadWorkflowSchema`,
29
+ * `buildEmitTool`, `runAuthorizers`, `ToolRegistry`, …) are deliberately
30
+ * kept private so they can evolve without a breaking change.
31
+ */
32
+ // --- Runtime API (values) ---
33
+ export { author, authorStream } from "./author.js";
34
+ export { AuthorizationError } from "./authorizer.js";
35
+ export { buildAuthoringSchema } from "./authoring-schema.js";
36
+ export { resolveCatalog } from "./catalog.js";
37
+ export { finalizeConfig, resolveConfig } from "./config.js";
38
+ export { EMIT_TOOL_NAME } from "./emit-tool.js";
39
+ export { allow, block } from "./guardrail.js";
40
+ export { inMemoryAuditSink } from "./hooks.js";
41
+ export { mockAdapter, mockMessage, mockProposeWorkflow, mockToolCall, } from "./mock-adapter.js";
42
+ export { DEFAULT_MODEL, OPENROUTER_BASE_URL, openaiAdapter, } from "./openai-adapter.js";
43
+ export { DEFAULT_PERSONA, getDefaultSystemPrompt } from "./prompt.js";
44
+ export { assertSafeUrl, UnsafeUrlError } from "./ssrf.js";
45
+ export { createWorkflowValidator } from "./validator.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A deterministic mock `AiAdapter` — the E2E testing seam.
3
+ *
4
+ * Drive `author` / `authorStream` with a scripted mock instead of a
5
+ * live LLM: pass an array of turns (returned in order) or a function of
6
+ * the request, then assert the resulting `message | proposal`. No
7
+ * network, no API key, fully deterministic. This is exactly how a
8
+ * customer writes an acceptance test against their own config.
9
+ */
10
+ import type { AiAdapter, AiGenerateRequest, AiGenerateResult } from "./adapter.js";
11
+ export type MockTurn = AiGenerateResult;
12
+ export type MockScript = MockTurn[] | ((request: AiGenerateRequest, callIndex: number) => MockTurn | Promise<MockTurn>);
13
+ export type MockAdapter = AiAdapter & {
14
+ /** Every request the adapter received, in order — assert against these. */
15
+ readonly calls: readonly AiGenerateRequest[];
16
+ };
17
+ export declare function mockAdapter(script: MockScript): MockAdapter;
18
+ /** A plain-text turn — becomes a `message` (reply or clarifying question). */
19
+ export declare function mockMessage(text: string): MockTurn;
20
+ /** A turn that calls a named (toolset) tool with the given arguments. */
21
+ export declare function mockToolCall(name: string, args: Record<string, unknown>, id?: string): MockTurn;
22
+ /** A turn that emits a workflow proposal via the `propose_workflow` tool. */
23
+ export declare function mockProposeWorkflow(graph: unknown, summary: string, id?: string): MockTurn;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A deterministic mock `AiAdapter` — the E2E testing seam.
3
+ *
4
+ * Drive `author` / `authorStream` with a scripted mock instead of a
5
+ * live LLM: pass an array of turns (returned in order) or a function of
6
+ * the request, then assert the resulting `message | proposal`. No
7
+ * network, no API key, fully deterministic. This is exactly how a
8
+ * customer writes an acceptance test against their own config.
9
+ */
10
+ import { EMIT_TOOL_NAME } from "./emit-tool.js";
11
+ export function mockAdapter(script) {
12
+ const calls = [];
13
+ let index = 0;
14
+ return {
15
+ calls,
16
+ async generate(request) {
17
+ calls.push(request);
18
+ const callIndex = index++;
19
+ if (typeof script === "function")
20
+ return script(request, callIndex);
21
+ const turn = script[callIndex];
22
+ if (turn === undefined) {
23
+ throw new Error(`mockAdapter: no scripted turn for adapter call #${callIndex} (script has ${script.length} turn(s)).`);
24
+ }
25
+ return turn;
26
+ },
27
+ };
28
+ }
29
+ /** A plain-text turn — becomes a `message` (reply or clarifying question). */
30
+ export function mockMessage(text) {
31
+ return { text, toolCalls: [] };
32
+ }
33
+ // Monotonic suffix so a default id is unique even when the same tool is
34
+ // scripted twice (two `mockToolCall("x", …)` turns must not share `call_x`).
35
+ let nextCallId = 0;
36
+ /** A turn that calls a named (toolset) tool with the given arguments. */
37
+ export function mockToolCall(name, args, id = `call_${name}_${nextCallId++}`) {
38
+ return { text: null, toolCalls: [{ id, name, arguments: args }] };
39
+ }
40
+ /** A turn that emits a workflow proposal via the `propose_workflow` tool. */
41
+ export function mockProposeWorkflow(graph, summary, id = `call_propose_${nextCallId++}`) {
42
+ return {
43
+ text: null,
44
+ toolCalls: [
45
+ { id, name: EMIT_TOOL_NAME, arguments: { graph, summary } },
46
+ ],
47
+ };
48
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The default `AiAdapter`: the `openai` SDK pointed at OpenRouter's
3
+ * OpenAI-compatible endpoint, using native tool-calling.
4
+ *
5
+ * Zero-config path: `openaiAdapter({ apiKey })`. Swap the model or
6
+ * point at another OpenAI-compatible gateway via `model` / `baseURL`.
7
+ * For a non-OpenAI LLM, implement {@link AiAdapter} directly instead.
8
+ *
9
+ * **Credential handling.** `apiKey` is captured in closure at
10
+ * construct and never appears on a public method signature. `baseURL`
11
+ * is SSRF-validated once at construct (`assertSafeUrl`); runtime calls
12
+ * do not re-validate.
13
+ */
14
+ import OpenAI from "openai";
15
+ import type { AiAdapter } from "./adapter.js";
16
+ /** OpenRouter's OpenAI-compatible base URL — the default upstream. */
17
+ export declare const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
18
+ /**
19
+ * A default OpenRouter model slug. Tool-calling capable and cheap;
20
+ * override per deployment via `model` (or per call via the request).
21
+ */
22
+ export declare const DEFAULT_MODEL = "openai/gpt-4o-mini";
23
+ /**
24
+ * The slice of the `openai` client this adapter uses. Declared
25
+ * structurally so a test can inject a fake client without a network
26
+ * round-trip, and so the public type never widens to the full SDK.
27
+ */
28
+ export type OpenAiChatClient = {
29
+ chat: {
30
+ completions: {
31
+ create(params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, options?: {
32
+ signal?: AbortSignal;
33
+ }): Promise<{
34
+ choices: Array<{
35
+ message: OpenAI.Chat.Completions.ChatCompletionMessage;
36
+ }>;
37
+ }>;
38
+ create(params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, options?: {
39
+ signal?: AbortSignal;
40
+ }): Promise<AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>>;
41
+ };
42
+ };
43
+ };
44
+ export type OpenAiAdapterOptions = {
45
+ /** Credential, captured in closure. Never travels on any method. */
46
+ apiKey: string;
47
+ /** OpenAI-compatible base URL. Defaults to OpenRouter. SSRF-validated at construct. */
48
+ baseURL?: string;
49
+ /** Default model slug used when a request does not override it. */
50
+ model?: string;
51
+ /** SSRF-guard escape hatch for a knowingly-private mirror. */
52
+ allowHosts?: readonly string[];
53
+ /** Extra headers to send on every request (e.g. OpenRouter attribution). */
54
+ defaultHeaders?: Record<string, string>;
55
+ /**
56
+ * Inject a pre-built client. Primarily a testing seam — pass a fake
57
+ * to exercise request/response mapping without hitting the network.
58
+ * When omitted, a real `openai` client is constructed.
59
+ */
60
+ client?: OpenAiChatClient;
61
+ };
62
+ export declare function openaiAdapter(options: OpenAiAdapterOptions): AiAdapter;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The default `AiAdapter`: the `openai` SDK pointed at OpenRouter's
3
+ * OpenAI-compatible endpoint, using native tool-calling.
4
+ *
5
+ * Zero-config path: `openaiAdapter({ apiKey })`. Swap the model or
6
+ * point at another OpenAI-compatible gateway via `model` / `baseURL`.
7
+ * For a non-OpenAI LLM, implement {@link AiAdapter} directly instead.
8
+ *
9
+ * **Credential handling.** `apiKey` is captured in closure at
10
+ * construct and never appears on a public method signature. `baseURL`
11
+ * is SSRF-validated once at construct (`assertSafeUrl`); runtime calls
12
+ * do not re-validate.
13
+ */
14
+ import OpenAI from "openai";
15
+ import { assertSafeUrl } from "./ssrf.js";
16
+ /** OpenRouter's OpenAI-compatible base URL — the default upstream. */
17
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
18
+ /**
19
+ * A default OpenRouter model slug. Tool-calling capable and cheap;
20
+ * override per deployment via `model` (or per call via the request).
21
+ */
22
+ export const DEFAULT_MODEL = "openai/gpt-4o-mini";
23
+ export function openaiAdapter(options) {
24
+ if (typeof options.apiKey !== "string" || options.apiKey.length === 0) {
25
+ throw new Error("openaiAdapter: `apiKey` is required and must be a non-empty string.");
26
+ }
27
+ const baseURL = options.baseURL ?? OPENROUTER_BASE_URL;
28
+ assertSafeUrl(baseURL, options.allowHosts ? { allowHosts: options.allowHosts } : {});
29
+ const defaultModel = options.model ?? DEFAULT_MODEL;
30
+ const client = options.client ??
31
+ new OpenAI({
32
+ apiKey: options.apiKey,
33
+ baseURL,
34
+ ...(options.defaultHeaders
35
+ ? { defaultHeaders: options.defaultHeaders }
36
+ : {}),
37
+ });
38
+ return {
39
+ async generate(request) {
40
+ const params = {
41
+ model: request.model ?? defaultModel,
42
+ messages: request.messages.map(toOpenAiMessage),
43
+ ...(request.tools.length > 0
44
+ ? {
45
+ tools: request.tools.map(toOpenAiTool),
46
+ tool_choice: "auto",
47
+ }
48
+ : {}),
49
+ };
50
+ const response = await client.chat.completions.create(params, request.signal ? { signal: request.signal } : undefined);
51
+ const message = response.choices[0]?.message;
52
+ return {
53
+ text: message?.content ?? null,
54
+ toolCalls: extractToolCalls(message),
55
+ };
56
+ },
57
+ async *generateStream(request) {
58
+ const params = {
59
+ model: request.model ?? defaultModel,
60
+ messages: request.messages.map(toOpenAiMessage),
61
+ stream: true,
62
+ ...(request.tools.length > 0
63
+ ? {
64
+ tools: request.tools.map(toOpenAiTool),
65
+ tool_choice: "auto",
66
+ }
67
+ : {}),
68
+ };
69
+ const stream = await client.chat.completions.create(params, request.signal ? { signal: request.signal } : undefined);
70
+ let text = "";
71
+ // Tool-call args stream in fragments keyed by their choice index; we
72
+ // accumulate id/name/arguments across chunks and parse once at the end.
73
+ const toolCalls = new Map();
74
+ for await (const chunk of stream) {
75
+ const delta = chunk.choices[0]?.delta;
76
+ if (delta?.content) {
77
+ text += delta.content;
78
+ yield { type: "text-delta", delta: delta.content };
79
+ }
80
+ for (const call of delta?.tool_calls ?? []) {
81
+ const acc = toolCalls.get(call.index) ?? { id: "", name: "", args: "" };
82
+ if (call.id)
83
+ acc.id = call.id;
84
+ if (call.function?.name)
85
+ acc.name = call.function.name;
86
+ if (call.function?.arguments)
87
+ acc.args += call.function.arguments;
88
+ toolCalls.set(call.index, acc);
89
+ }
90
+ }
91
+ const resolved = [...toolCalls.values()]
92
+ .filter((call) => call.name.length > 0)
93
+ .map((call) => ({
94
+ id: call.id,
95
+ name: call.name,
96
+ arguments: parseArguments(call.args),
97
+ }));
98
+ yield {
99
+ type: "result",
100
+ result: { text: text.length > 0 ? text : null, toolCalls: resolved },
101
+ };
102
+ },
103
+ };
104
+ }
105
+ function toOpenAiMessage(message) {
106
+ switch (message.role) {
107
+ case "system":
108
+ return { role: "system", content: message.content };
109
+ case "user":
110
+ return { role: "user", content: message.content };
111
+ case "tool":
112
+ return {
113
+ role: "tool",
114
+ tool_call_id: message.toolCallId,
115
+ content: message.content,
116
+ };
117
+ case "assistant":
118
+ return {
119
+ role: "assistant",
120
+ content: message.content,
121
+ ...(message.toolCalls && message.toolCalls.length > 0
122
+ ? {
123
+ tool_calls: message.toolCalls.map((call) => ({
124
+ id: call.id,
125
+ type: "function",
126
+ function: {
127
+ name: call.name,
128
+ arguments: JSON.stringify(call.arguments),
129
+ },
130
+ })),
131
+ }
132
+ : {}),
133
+ };
134
+ }
135
+ }
136
+ function toOpenAiTool(tool) {
137
+ return {
138
+ type: "function",
139
+ function: {
140
+ name: tool.name,
141
+ description: tool.description,
142
+ parameters: tool.parameters,
143
+ },
144
+ };
145
+ }
146
+ function extractToolCalls(message) {
147
+ if (!message?.tool_calls)
148
+ return [];
149
+ const out = [];
150
+ for (const call of message.tool_calls) {
151
+ if (call.type !== "function")
152
+ continue;
153
+ out.push({
154
+ id: call.id,
155
+ name: call.function.name,
156
+ arguments: parseArguments(call.function.arguments),
157
+ });
158
+ }
159
+ return out;
160
+ }
161
+ function parseArguments(raw) {
162
+ if (raw.trim().length === 0)
163
+ return {};
164
+ try {
165
+ const parsed = JSON.parse(raw);
166
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
167
+ return parsed;
168
+ }
169
+ return {};
170
+ }
171
+ catch {
172
+ // Malformed JSON from the model — surface an empty object so the
173
+ // validate/repair loop rejects and re-prompts rather than crashing.
174
+ return {};
175
+ }
176
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Prompt assembly — the grounding + persona + request.
3
+ *
4
+ * The **system prompt** is a stable prefix: persona, the customer's
5
+ * node catalog, and a compact reference to the workflow-graph shape.
6
+ * Being stable across a request's repair turns (and across requests
7
+ * with the same catalog) makes it the natural **prompt-caching
8
+ * prefix** — providers that cache by leading-token prefix amortize it.
9
+ *
10
+ * The **user prompt** carries the variable part: the command, the
11
+ * optional `currentGraph` being edited, and the optional client-carried
12
+ * `context`.
13
+ *
14
+ * Grounding note: the AUTHORITATIVE emission schema rides on the
15
+ * `propose_workflow` tool's parameters (the model fills it directly).
16
+ * The schema echoed here is trimmed (verbose `markdownDescription`
17
+ * copy stripped) so the model understands the graph model without
18
+ * paying for the full annotated schema twice.
19
+ */
20
+ import type { AiMessage } from "./adapter.js";
21
+ import type { CatalogNode } from "./catalog.js";
22
+ import type { AuthorRequest } from "./contract.js";
23
+ export declare const DEFAULT_PERSONA = "You are Flowget's workflow-authoring assistant. You turn a user's natural-language request into a valid Flowget workflow graph, using ONLY the nodes in the provided catalog.\n\nRules:\n- Author the smallest correct workflow that satisfies the request \u2014 do not add nodes the request did not ask for.\n- A runnable workflow needs at least one node whose category is \"trigger\".\n- Use only node \"type\" values that appear in the catalog (a node's \"type\" equals a catalog entry's \"id\"). Never invent a node type.\n- Wire nodes with edges. An edge's \"source\"/\"target\" are node ids. For branch routing, set an edge \"sourceHandle\" to one of the source node's declared handle ids.\n- To use an upstream node's output in a downstream node's config, write a template: {{ steps.<nodeId>.output.<key> }} for a node's output or {{ trigger.<key> }} for the trigger payload. Only reference node ids that exist in the graph.\n- Node \"position\" is optional \u2014 omit it; the builder lays the graph out.\n- Most config fields hold a scalar, but some are COMPOUND objects. A field of type \"schedule\" takes: { \"spec\": { \"cron\": \"0 8 * * *\", \"timezone\": \"UTC\" }, \"overlap\": \"skip\" }. The cron (or \"intervalSeconds\": <number> instead of cron) and \"timezone\" nest UNDER \"spec\"; \"spec\" is required with exactly one of cron or intervalSeconds; \"overlap\" is optional (defaults \"skip\"). Do NOT emit a flat { \"cron\", \"timezone\" }.\n- When the request is ambiguous or under-specified, DO NOT guess. Reply in plain text with ONE concise clarifying question instead of calling the tool.\n- If the request is NOT an authorable runnable workflow \u2014 e.g. \"clear/discard/empty the canvas\", \"delete everything\", \"start over\", or anything that would require an empty or non-runnable graph \u2014 do NOT call propose_workflow. Reply in plain text explaining that you author runnable workflows and that clearing the canvas is a manual action (delete the nodes directly in the builder). NEVER echo the current graph unchanged as a no-op proposal.\n- If a current graph is provided and it ALREADY satisfies the request, reply in plain text saying the workflow already does that, instead of proposing an unchanged graph.\n- When (and only when) you have a complete, valid workflow to author or change, call the \"propose_workflow\" tool exactly once.";
24
+ export type SystemPromptInput = {
25
+ catalog: readonly CatalogNode[];
26
+ /** Persona override; falls back to {@link DEFAULT_PERSONA}. */
27
+ persona?: string;
28
+ };
29
+ export declare function buildSystemPrompt(input: SystemPromptInput): string;
30
+ /**
31
+ * The EXACT default system prompt `author` sends for a given catalog
32
+ * — {@link DEFAULT_PERSONA} + the catalog + the workflow-graph schema
33
+ * (loaded from `@flowget/types`, the single source of truth). This is
34
+ * the un-overridden `buildSystemPrompt({ catalog })` the author loop
35
+ * uses when `config.persona` is unset (see `runToolLoop` in
36
+ * `author.ts`).
37
+ *
38
+ * A thin, intent-revealing public entry point for consumers that need
39
+ * the real prompt without reaching into the author loop or replicating
40
+ * the persona — e.g. an out-of-process evaluation harness that measures
41
+ * the raw model output the library's own prompt elicits. The schema is
42
+ * loaded internally (never injected) so the prompt can never drift from
43
+ * the shipped `@flowget/types` schema.
44
+ */
45
+ export declare function getDefaultSystemPrompt(catalog: readonly CatalogNode[]): string;
46
+ export declare function buildUserPrompt(request: AuthorRequest): string;
47
+ export declare function buildInitialMessages(request: AuthorRequest, system: SystemPromptInput): AiMessage[];