@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.
- package/LICENSE +105 -0
- package/README.md +238 -0
- package/dist/adapter.d.ts +99 -0
- package/dist/adapter.js +29 -0
- package/dist/author.d.ts +40 -0
- package/dist/author.js +244 -0
- package/dist/authoring-schema.d.ts +17 -0
- package/dist/authoring-schema.js +53 -0
- package/dist/authorizer.d.ts +25 -0
- package/dist/authorizer.js +29 -0
- package/dist/catalog.d.ts +30 -0
- package/dist/catalog.js +22 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.js +36 -0
- package/dist/contract.d.ts +126 -0
- package/dist/contract.js +14 -0
- package/dist/emit-tool.d.ts +16 -0
- package/dist/emit-tool.js +48 -0
- package/dist/guardrail.d.ts +27 -0
- package/dist/guardrail.js +18 -0
- package/dist/guards.d.ts +3 -0
- package/dist/guards.js +5 -0
- package/dist/hooks.d.ts +42 -0
- package/dist/hooks.js +28 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +45 -0
- package/dist/mock-adapter.d.ts +23 -0
- package/dist/mock-adapter.js +48 -0
- package/dist/openai-adapter.d.ts +62 -0
- package/dist/openai-adapter.js +176 -0
- package/dist/prompt.d.ts +47 -0
- package/dist/prompt.js +112 -0
- package/dist/ssrf.d.ts +33 -0
- package/dist/ssrf.js +116 -0
- package/dist/toolset.d.ts +57 -0
- package/dist/toolset.js +76 -0
- package/dist/validator.d.ts +54 -0
- package/dist/validator.js +196 -0
- package/package.json +66 -0
package/dist/author.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command → proposal core.
|
|
3
|
+
*
|
|
4
|
+
* Orchestration (stateless — nothing survives the call):
|
|
5
|
+
* 1. authorizers verify the trusted actor (throw ⇒ request rejected);
|
|
6
|
+
* 2. the guardrail runs pre-LLM (block ⇒ a `message`, no adapter call);
|
|
7
|
+
* 3. the tool-calling loop assembles the prompt, calls the adapter,
|
|
8
|
+
* dispatches actor-scoped toolset calls, and validates/repairs the
|
|
9
|
+
* emitted graph until it is valid — or the model asks a clarifying
|
|
10
|
+
* question, or the bounds are hit;
|
|
11
|
+
* 4. audit hooks fire on the outcome.
|
|
12
|
+
*
|
|
13
|
+
* A `proposal` is only ever returned once the graph passes the
|
|
14
|
+
* validator (schema + catalog + structural + trigger-presence), so only
|
|
15
|
+
* runnable-shaped graphs reach the caller.
|
|
16
|
+
*
|
|
17
|
+
* {@link author} and {@link authorStream} share ONE per-turn decision
|
|
18
|
+
* ({@link stepTurn}); the only difference is how each fetches a turn —
|
|
19
|
+
* `adapter.generate` (buffered) vs {@link streamOneTurn} (token deltas).
|
|
20
|
+
*/
|
|
21
|
+
import { runAuthorizers } from "./authorizer.js";
|
|
22
|
+
import { buildEmitTool, EMIT_TOOL_NAME } from "./emit-tool.js";
|
|
23
|
+
import { buildInitialMessages } from "./prompt.js";
|
|
24
|
+
/** Fallback text for a degenerate empty turn (no tool call, no text). */
|
|
25
|
+
const EMPTY_TURN_MESSAGE = "I didn't produce a response. Please rephrase or add detail.";
|
|
26
|
+
/** Given up after exhausting the per-request adapter-turn budget. */
|
|
27
|
+
const MAX_STEPS_MESSAGE = "I could not complete the request within the allowed number of steps. Please try a more specific command.";
|
|
28
|
+
export async function author(config, request, options = {}) {
|
|
29
|
+
const actor = request.actor;
|
|
30
|
+
// 1. Authorizers (throw ⇒ AuthorizationError bubbles to the handler).
|
|
31
|
+
await runAuthorizers(config.authorizers, actor, request);
|
|
32
|
+
// 2. Guardrail (pre-LLM). A block short-circuits with no token spend.
|
|
33
|
+
if (config.guardrail !== undefined) {
|
|
34
|
+
const decision = await config.guardrail(request, actor ? { actor } : {});
|
|
35
|
+
if (decision.action === "block") {
|
|
36
|
+
await config.hooks.onBlocked?.({
|
|
37
|
+
type: "blocked",
|
|
38
|
+
command: request.command,
|
|
39
|
+
...(actor ? { actor } : {}),
|
|
40
|
+
reason: decision.message,
|
|
41
|
+
});
|
|
42
|
+
return { kind: "message", text: decision.message };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 3. The tool-calling loop.
|
|
46
|
+
const event = await runToolLoop(config, request, options);
|
|
47
|
+
// 4. Audit the proposal.
|
|
48
|
+
if (event.kind === "proposal") {
|
|
49
|
+
await config.hooks.onProposal?.({
|
|
50
|
+
type: "proposal",
|
|
51
|
+
command: request.command,
|
|
52
|
+
...(actor ? { actor } : {}),
|
|
53
|
+
summary: event.summary,
|
|
54
|
+
graph: event.graph,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return event;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Streaming sibling of {@link author}: identical orchestration, but the
|
|
61
|
+
* assistant's text is forwarded as `text-delta`s as it is generated (the chat
|
|
62
|
+
* "typing" feel), ending in exactly one terminal `proposal`, `message`, or
|
|
63
|
+
* `error`.
|
|
64
|
+
*
|
|
65
|
+
* Adapters that don't implement `generateStream` degrade cleanly — no deltas,
|
|
66
|
+
* just the terminal event. A failure DURING the stream is yielded as a
|
|
67
|
+
* terminal `{ kind: "error" }` (per {@link AuthorStreamEvent}) rather than
|
|
68
|
+
* thrown, so a consumer's `case "error"` is reachable. Pre-stream rejections
|
|
69
|
+
* (a throwing authorizer / guardrail) still throw, mirroring {@link author}
|
|
70
|
+
* so a transport can reject before opening the stream.
|
|
71
|
+
*/
|
|
72
|
+
export async function* authorStream(config, request, options = {}) {
|
|
73
|
+
const actor = request.actor;
|
|
74
|
+
// 1. Authorizers (throw ⇒ bubbles to the handler — pre-stream).
|
|
75
|
+
await runAuthorizers(config.authorizers, actor, request);
|
|
76
|
+
// 2. Guardrail (pre-LLM). A block short-circuits with no token spend.
|
|
77
|
+
if (config.guardrail !== undefined) {
|
|
78
|
+
const decision = await config.guardrail(request, actor ? { actor } : {});
|
|
79
|
+
if (decision.action === "block") {
|
|
80
|
+
await config.hooks.onBlocked?.({
|
|
81
|
+
type: "blocked",
|
|
82
|
+
command: request.command,
|
|
83
|
+
...(actor ? { actor } : {}),
|
|
84
|
+
reason: decision.message,
|
|
85
|
+
});
|
|
86
|
+
yield { kind: "message", text: decision.message };
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// 3. The streaming tool loop — deltas out, terminal event returned.
|
|
91
|
+
// Wrapped so a mid-stream failure becomes a terminal `error` event.
|
|
92
|
+
try {
|
|
93
|
+
const event = yield* runToolLoopStream(config, request, options);
|
|
94
|
+
// 4. Audit + emit the terminal event.
|
|
95
|
+
if (event.kind === "proposal") {
|
|
96
|
+
await config.hooks.onProposal?.({
|
|
97
|
+
type: "proposal",
|
|
98
|
+
command: request.command,
|
|
99
|
+
...(actor ? { actor } : {}),
|
|
100
|
+
summary: event.summary,
|
|
101
|
+
graph: event.graph,
|
|
102
|
+
});
|
|
103
|
+
yield { kind: "proposal", graph: event.graph, summary: event.summary };
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
yield { kind: "message", text: event.text };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
yield {
|
|
111
|
+
kind: "error",
|
|
112
|
+
error: error instanceof Error ? error.message : String(error),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function createTurnState(config, request) {
|
|
117
|
+
return {
|
|
118
|
+
tools: [buildEmitTool(), ...config.toolRegistry.definitions()],
|
|
119
|
+
messages: buildInitialMessages(request, {
|
|
120
|
+
catalog: config.catalog,
|
|
121
|
+
...(config.persona !== undefined ? { persona: config.persona } : {}),
|
|
122
|
+
}),
|
|
123
|
+
toolContext: request.actor ? { actor: request.actor } : {},
|
|
124
|
+
repairs: 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function buildGenerateRequest(state, config, options) {
|
|
128
|
+
return {
|
|
129
|
+
messages: state.messages,
|
|
130
|
+
tools: state.tools,
|
|
131
|
+
...(config.model !== undefined ? { model: config.model } : {}),
|
|
132
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The one per-turn decision, shared by the buffered and streaming loops.
|
|
137
|
+
* Reads the adapter's result, mutates `state` (records the assistant
|
|
138
|
+
* turn + tool results, bumps the repair counter), and returns either a
|
|
139
|
+
* terminal {@link AuthorEvent} or the signal to run another turn.
|
|
140
|
+
*/
|
|
141
|
+
async function stepTurn(result, config, state) {
|
|
142
|
+
// No tool call ⇒ a plain-text message (a reply or a clarifying question).
|
|
143
|
+
if (result.toolCalls.length === 0) {
|
|
144
|
+
const text = result.text && result.text.length > 0 ? result.text : EMPTY_TURN_MESSAGE;
|
|
145
|
+
return { done: { kind: "message", text } };
|
|
146
|
+
}
|
|
147
|
+
// Record the assistant turn — the tool protocol requires a tool result
|
|
148
|
+
// for every tool call the assistant made.
|
|
149
|
+
state.messages.push({
|
|
150
|
+
role: "assistant",
|
|
151
|
+
content: result.text,
|
|
152
|
+
toolCalls: result.toolCalls,
|
|
153
|
+
});
|
|
154
|
+
for (const call of result.toolCalls) {
|
|
155
|
+
if (call.name === EMIT_TOOL_NAME) {
|
|
156
|
+
const graph = call.arguments["graph"];
|
|
157
|
+
const summary = typeof call.arguments["summary"] === "string"
|
|
158
|
+
? call.arguments["summary"]
|
|
159
|
+
: "";
|
|
160
|
+
const verdict = config.validator(graph);
|
|
161
|
+
if (verdict.ok) {
|
|
162
|
+
// Return immediately — a later tool call in the same turn (e.g. a
|
|
163
|
+
// throwing toolset dispatch) must not discard a valid proposal.
|
|
164
|
+
return {
|
|
165
|
+
done: { kind: "proposal", graph: graph, summary },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
state.repairs++;
|
|
169
|
+
state.messages.push({
|
|
170
|
+
role: "tool",
|
|
171
|
+
toolCallId: call.id,
|
|
172
|
+
content: "The workflow did not validate. Fix ALL of these and call propose_workflow again:\n- " +
|
|
173
|
+
verdict.errors.join("\n- "),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else if (config.toolRegistry.has(call.name)) {
|
|
177
|
+
const toolResult = await config.toolRegistry.dispatch(call, state.toolContext);
|
|
178
|
+
state.messages.push({
|
|
179
|
+
role: "tool",
|
|
180
|
+
toolCallId: call.id,
|
|
181
|
+
content: toolResult,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// The model hallucinated a tool — tell it what actually exists.
|
|
186
|
+
state.messages.push({
|
|
187
|
+
role: "tool",
|
|
188
|
+
toolCallId: call.id,
|
|
189
|
+
content: `Unknown tool "${call.name}". Available tools: ${state.tools
|
|
190
|
+
.map((tool) => tool.name)
|
|
191
|
+
.join(", ")}.`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (state.repairs > config.maxRepairAttempts) {
|
|
196
|
+
return {
|
|
197
|
+
done: {
|
|
198
|
+
kind: "message",
|
|
199
|
+
text: `I could not produce a valid workflow after ${config.maxRepairAttempts} repair attempt(s). Please refine your request.`,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return { continue: true };
|
|
204
|
+
}
|
|
205
|
+
/** Buffered tool loop: fetch each turn via `adapter.generate`. */
|
|
206
|
+
async function runToolLoop(config, request, options) {
|
|
207
|
+
const state = createTurnState(config, request);
|
|
208
|
+
for (let iteration = 0; iteration < config.maxToolIterations; iteration++) {
|
|
209
|
+
const result = await config.adapter.generate(buildGenerateRequest(state, config, options));
|
|
210
|
+
const step = await stepTurn(result, config, state);
|
|
211
|
+
if ("done" in step)
|
|
212
|
+
return step.done;
|
|
213
|
+
}
|
|
214
|
+
return { kind: "message", text: MAX_STEPS_MESSAGE };
|
|
215
|
+
}
|
|
216
|
+
/** One adapter turn, streamed: yields text deltas, returns the full result.
|
|
217
|
+
* Falls back to `generate` when the adapter has no `generateStream`. */
|
|
218
|
+
async function* streamOneTurn(adapter, request) {
|
|
219
|
+
if (adapter.generateStream === undefined) {
|
|
220
|
+
return await adapter.generate(request);
|
|
221
|
+
}
|
|
222
|
+
let result;
|
|
223
|
+
for await (const part of adapter.generateStream(request)) {
|
|
224
|
+
if (part.type === "text-delta") {
|
|
225
|
+
yield { kind: "text-delta", delta: part.delta };
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
result = part.result;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result ?? { text: null, toolCalls: [] };
|
|
232
|
+
}
|
|
233
|
+
/** Streaming tool loop: fetch each turn via {@link streamOneTurn}, forwarding
|
|
234
|
+
* its text deltas, then feed the full result to the shared {@link stepTurn}. */
|
|
235
|
+
async function* runToolLoopStream(config, request, options) {
|
|
236
|
+
const state = createTurnState(config, request);
|
|
237
|
+
for (let iteration = 0; iteration < config.maxToolIterations; iteration++) {
|
|
238
|
+
const result = yield* streamOneTurn(config.adapter, buildGenerateRequest(state, config, options));
|
|
239
|
+
const step = await stepTurn(result, config, state);
|
|
240
|
+
if ("done" in step)
|
|
241
|
+
return step.done;
|
|
242
|
+
}
|
|
243
|
+
return { kind: "message", text: MAX_STEPS_MESSAGE };
|
|
244
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **authoring schema** — the JSON Schema the AI emits against.
|
|
3
|
+
*
|
|
4
|
+
* It is the canonical `WorkflowGraph` schema that `@flowget/types`
|
|
5
|
+
* ships (`@flowget/types/schemas/workflow.schema.json`), with a single
|
|
6
|
+
* relaxation: `position` is no longer required on a node. The AI emits
|
|
7
|
+
* topology + config; the builder lays the graph out later. Deriving it
|
|
8
|
+
* from the shipped schema keeps a **single source of truth** — no
|
|
9
|
+
* hand-maintained copy to drift.
|
|
10
|
+
*/
|
|
11
|
+
/** Load the canonical workflow-graph JSON schema shipped by `@flowget/types`. */
|
|
12
|
+
export declare function loadWorkflowSchema(): Record<string, unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* The position-optional authoring schema, derived from the shipped
|
|
15
|
+
* workflow schema. Returns a fresh object each call (safe to mutate).
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildAuthoringSchema(): Record<string, unknown>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **authoring schema** — the JSON Schema the AI emits against.
|
|
3
|
+
*
|
|
4
|
+
* It is the canonical `WorkflowGraph` schema that `@flowget/types`
|
|
5
|
+
* ships (`@flowget/types/schemas/workflow.schema.json`), with a single
|
|
6
|
+
* relaxation: `position` is no longer required on a node. The AI emits
|
|
7
|
+
* topology + config; the builder lays the graph out later. Deriving it
|
|
8
|
+
* from the shipped schema keeps a **single source of truth** — no
|
|
9
|
+
* hand-maintained copy to drift.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { isRecord } from "./guards.js";
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
/**
|
|
16
|
+
* Memoized canonical schema. The file is stable for the process
|
|
17
|
+
* lifetime, and `loadWorkflowSchema` sits on the hot path
|
|
18
|
+
* (`buildSystemPrompt` + `buildEmitTool`, both per `author()` call), so
|
|
19
|
+
* the `readFileSync` + `JSON.parse` runs exactly once. Callers MUST
|
|
20
|
+
* treat the result as immutable — `buildAuthoringSchema` clones before
|
|
21
|
+
* mutating, and prompt assembly reads it non-destructively.
|
|
22
|
+
*/
|
|
23
|
+
let cachedSchema;
|
|
24
|
+
/** Load the canonical workflow-graph JSON schema shipped by `@flowget/types`. */
|
|
25
|
+
export function loadWorkflowSchema() {
|
|
26
|
+
if (cachedSchema !== undefined)
|
|
27
|
+
return cachedSchema;
|
|
28
|
+
const schemaPath = require.resolve("@flowget/types/schemas/workflow.schema.json");
|
|
29
|
+
const parsed = JSON.parse(readFileSync(schemaPath, "utf8"));
|
|
30
|
+
if (!isRecord(parsed)) {
|
|
31
|
+
throw new Error("workflow.schema.json did not parse to an object — @flowget/types may be corrupt.");
|
|
32
|
+
}
|
|
33
|
+
cachedSchema = parsed;
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The position-optional authoring schema, derived from the shipped
|
|
38
|
+
* workflow schema. Returns a fresh object each call (safe to mutate).
|
|
39
|
+
*/
|
|
40
|
+
export function buildAuthoringSchema() {
|
|
41
|
+
const schema = structuredClone(loadWorkflowSchema());
|
|
42
|
+
const defs = schema["$defs"];
|
|
43
|
+
if (isRecord(defs)) {
|
|
44
|
+
const node = defs["WorkflowNode"];
|
|
45
|
+
if (isRecord(node) && Array.isArray(node["required"])) {
|
|
46
|
+
node["required"] = node["required"].filter((key) => key !== "position");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Distinct $id so an AJV instance can hold both this and the
|
|
50
|
+
// canonical schema without a duplicate-id collision.
|
|
51
|
+
schema["$id"] = "https://schemas.flowget.com/workflow-authoring/v1.json";
|
|
52
|
+
return schema;
|
|
53
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorizers — verify the TRUSTED actor before the guardrail and
|
|
3
|
+
* toolsets run.
|
|
4
|
+
*
|
|
5
|
+
* The actor is stamped by the customer's BFF and handed to `author()`
|
|
6
|
+
* on the request; an authorizer is where you VERIFY it (signature
|
|
7
|
+
* check, tenant lookup, scope assertion). Authorizers run first, in
|
|
8
|
+
* order. Any authorizer that throws rejects the whole request — throw
|
|
9
|
+
* {@link AuthorizationError} to carry a status your transport can map
|
|
10
|
+
* to a response (401 by default).
|
|
11
|
+
*
|
|
12
|
+
* Auth-agnostic stance: the library never forces authentication. With
|
|
13
|
+
* no authorizers configured and no actor on the request, the request
|
|
14
|
+
* proceeds anonymously — identity enforcement is the customer's choice.
|
|
15
|
+
*/
|
|
16
|
+
import type { Actor } from "@flowget/types";
|
|
17
|
+
import type { AuthorRequest } from "./contract.js";
|
|
18
|
+
export declare class AuthorizationError extends Error {
|
|
19
|
+
name: string;
|
|
20
|
+
readonly status: number;
|
|
21
|
+
constructor(message: string, status?: number);
|
|
22
|
+
}
|
|
23
|
+
export type Authorizer = (actor: Actor | undefined, request: AuthorRequest) => void | Promise<void>;
|
|
24
|
+
/** Run every authorizer in order; the first throw rejects the request. */
|
|
25
|
+
export declare function runAuthorizers(authorizers: readonly Authorizer[], actor: Actor | undefined, request: AuthorRequest): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorizers — verify the TRUSTED actor before the guardrail and
|
|
3
|
+
* toolsets run.
|
|
4
|
+
*
|
|
5
|
+
* The actor is stamped by the customer's BFF and handed to `author()`
|
|
6
|
+
* on the request; an authorizer is where you VERIFY it (signature
|
|
7
|
+
* check, tenant lookup, scope assertion). Authorizers run first, in
|
|
8
|
+
* order. Any authorizer that throws rejects the whole request — throw
|
|
9
|
+
* {@link AuthorizationError} to carry a status your transport can map
|
|
10
|
+
* to a response (401 by default).
|
|
11
|
+
*
|
|
12
|
+
* Auth-agnostic stance: the library never forces authentication. With
|
|
13
|
+
* no authorizers configured and no actor on the request, the request
|
|
14
|
+
* proceeds anonymously — identity enforcement is the customer's choice.
|
|
15
|
+
*/
|
|
16
|
+
export class AuthorizationError extends Error {
|
|
17
|
+
name = "AuthorizationError";
|
|
18
|
+
status;
|
|
19
|
+
constructor(message, status = 401) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.status = status;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Run every authorizer in order; the first throw rejects the request. */
|
|
25
|
+
export async function runAuthorizers(authorizers, actor, request) {
|
|
26
|
+
for (const authorizer of authorizers) {
|
|
27
|
+
await authorizer(actor, request);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog loading — the ONLY substrate the AI needs.
|
|
3
|
+
*
|
|
4
|
+
* The catalog is the customer's node metadata: the `.json` files under
|
|
5
|
+
* their `REGISTRY_DIR`, loaded via `@flowget/registry-loader`. We load
|
|
6
|
+
* ONLY the node `.json` (never `import` the executor `.ts`, never boot
|
|
7
|
+
* a worker, never touch Temporal), exactly the loader's documented
|
|
8
|
+
* web-side pattern. The library's dependency footprint stays tiny.
|
|
9
|
+
*/
|
|
10
|
+
import type { BuilderHints, NodeMetadata } from "@flowget/types";
|
|
11
|
+
/** A single catalog entry — a node's projected metadata. */
|
|
12
|
+
export type CatalogNode = NodeMetadata<BuilderHints>;
|
|
13
|
+
/**
|
|
14
|
+
* Where the catalog comes from. Three accepted forms:
|
|
15
|
+
*
|
|
16
|
+
* - A pre-loaded `CatalogNode[]` — the customer already has metadata
|
|
17
|
+
* (e.g. from their own build step) and hands it in directly.
|
|
18
|
+
* - `{ registryDir }` — a `REGISTRY_DIR`-shaped specifier string
|
|
19
|
+
* (filesystem paths and/or npm subpaths, brace-expandable, comma-
|
|
20
|
+
* separated), resolved and walked for you.
|
|
21
|
+
* - `{ paths }` — already-resolved absolute base paths to walk.
|
|
22
|
+
*/
|
|
23
|
+
export type CatalogSource = CatalogNode[] | {
|
|
24
|
+
registryDir: string;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
} | {
|
|
27
|
+
paths: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
/** Resolve any {@link CatalogSource} to a flat `CatalogNode[]`. */
|
|
30
|
+
export declare function resolveCatalog(source: CatalogSource): Promise<CatalogNode[]>;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog loading — the ONLY substrate the AI needs.
|
|
3
|
+
*
|
|
4
|
+
* The catalog is the customer's node metadata: the `.json` files under
|
|
5
|
+
* their `REGISTRY_DIR`, loaded via `@flowget/registry-loader`. We load
|
|
6
|
+
* ONLY the node `.json` (never `import` the executor `.ts`, never boot
|
|
7
|
+
* a worker, never touch Temporal), exactly the loader's documented
|
|
8
|
+
* web-side pattern. The library's dependency footprint stays tiny.
|
|
9
|
+
*/
|
|
10
|
+
import { discoverRegistry, expandBraces, parseRegistryEnv, projectMetadata, resolveRegistrySpecifier, } from "@flowget/registry-loader";
|
|
11
|
+
/** Resolve any {@link CatalogSource} to a flat `CatalogNode[]`. */
|
|
12
|
+
export async function resolveCatalog(source) {
|
|
13
|
+
if (Array.isArray(source))
|
|
14
|
+
return [...source];
|
|
15
|
+
const paths = "registryDir" in source
|
|
16
|
+
? parseRegistryEnv(source.registryDir)
|
|
17
|
+
.flatMap(expandBraces)
|
|
18
|
+
.map((entry) => resolveRegistrySpecifier(entry, source.cwd ? { cwd: source.cwd } : {}))
|
|
19
|
+
: [...source.paths];
|
|
20
|
+
const records = await discoverRegistry({ paths });
|
|
21
|
+
return records.map((record) => projectMetadata(record.json, record.jsonPath));
|
|
22
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoring configuration for `author()` — every piece a named, typed,
|
|
3
|
+
* swappable hook with a batteries-included default. `adapter` and
|
|
4
|
+
* `catalog` are the only required fields.
|
|
5
|
+
*/
|
|
6
|
+
import type { AiAdapter } from "./adapter.js";
|
|
7
|
+
import type { Authorizer } from "./authorizer.js";
|
|
8
|
+
import type { CatalogNode, CatalogSource } from "./catalog.js";
|
|
9
|
+
import type { Guardrail } from "./guardrail.js";
|
|
10
|
+
import type { AuditHooks } from "./hooks.js";
|
|
11
|
+
import type { Toolset } from "./toolset.js";
|
|
12
|
+
import { ToolRegistry } from "./toolset.js";
|
|
13
|
+
import type { WorkflowValidator, WorkflowValidatorFactory } from "./validator.js";
|
|
14
|
+
export declare const DEFAULT_MAX_TOOL_ITERATIONS = 8;
|
|
15
|
+
export declare const DEFAULT_MAX_REPAIR_ATTEMPTS = 2;
|
|
16
|
+
export type AiConfig = {
|
|
17
|
+
/** The LLM. Default: `openaiAdapter({ apiKey })` → OpenRouter. */
|
|
18
|
+
adapter: AiAdapter;
|
|
19
|
+
/** The node catalog — the substrate the AI authors against. */
|
|
20
|
+
catalog: CatalogSource;
|
|
21
|
+
/** Pre-LLM policy gate. No default (policy is the customer's). */
|
|
22
|
+
guardrail?: Guardrail;
|
|
23
|
+
/** Verify the trusted actor before guardrail/toolsets. Default: none. */
|
|
24
|
+
authorizers?: Authorizer[];
|
|
25
|
+
/** Actor-scoped, model-invoked tools. Default: none. */
|
|
26
|
+
toolsets?: Toolset[];
|
|
27
|
+
/** Audit sinks for proposals / blocks. Default: no-op. */
|
|
28
|
+
hooks?: AuditHooks;
|
|
29
|
+
/** Persona override for the system prompt. Default: workflow-only persona. */
|
|
30
|
+
persona?: string;
|
|
31
|
+
/** Workflow-validator factory. Default: AJV + catalog + template-ref. */
|
|
32
|
+
validator?: WorkflowValidatorFactory;
|
|
33
|
+
/** Default model slug forwarded to the adapter. Adapter picks its own if absent. */
|
|
34
|
+
model?: string;
|
|
35
|
+
/** Max adapter turns per request. Default 8. */
|
|
36
|
+
maxToolIterations?: number;
|
|
37
|
+
/** Max emit re-validation (repair) attempts per request. Default 2. */
|
|
38
|
+
maxRepairAttempts?: number;
|
|
39
|
+
};
|
|
40
|
+
/** Fully-resolved runtime config — every default applied. */
|
|
41
|
+
export type ResolvedConfig = {
|
|
42
|
+
adapter: AiAdapter;
|
|
43
|
+
catalog: CatalogNode[];
|
|
44
|
+
validator: WorkflowValidator;
|
|
45
|
+
toolRegistry: ToolRegistry;
|
|
46
|
+
guardrail: Guardrail | undefined;
|
|
47
|
+
authorizers: Authorizer[];
|
|
48
|
+
hooks: AuditHooks;
|
|
49
|
+
persona: string | undefined;
|
|
50
|
+
model: string | undefined;
|
|
51
|
+
maxToolIterations: number;
|
|
52
|
+
maxRepairAttempts: number;
|
|
53
|
+
};
|
|
54
|
+
/** Build a {@link ResolvedConfig} from config + an already-loaded catalog. */
|
|
55
|
+
export declare function finalizeConfig(config: AiConfig, catalog: CatalogNode[]): ResolvedConfig;
|
|
56
|
+
/** Resolve config fully, loading the catalog from its source first. */
|
|
57
|
+
export declare function resolveConfig(config: AiConfig): Promise<ResolvedConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoring configuration for `author()` — every piece a named, typed,
|
|
3
|
+
* swappable hook with a batteries-included default. `adapter` and
|
|
4
|
+
* `catalog` are the only required fields.
|
|
5
|
+
*/
|
|
6
|
+
import { resolveCatalog } from "./catalog.js";
|
|
7
|
+
import { EMIT_TOOL_NAME } from "./emit-tool.js";
|
|
8
|
+
import { ToolRegistry } from "./toolset.js";
|
|
9
|
+
import { createWorkflowValidator } from "./validator.js";
|
|
10
|
+
export const DEFAULT_MAX_TOOL_ITERATIONS = 8;
|
|
11
|
+
export const DEFAULT_MAX_REPAIR_ATTEMPTS = 2;
|
|
12
|
+
/** Build a {@link ResolvedConfig} from config + an already-loaded catalog. */
|
|
13
|
+
export function finalizeConfig(config, catalog) {
|
|
14
|
+
if (catalog.length === 0) {
|
|
15
|
+
throw new Error("resolveConfig: the resolved catalog is empty. Point `catalog` at a REGISTRY_DIR containing node `.json` files, or pass a pre-loaded NodeMetadata[]. The AI cannot author a workflow with no node types.");
|
|
16
|
+
}
|
|
17
|
+
const validatorFactory = config.validator ?? createWorkflowValidator;
|
|
18
|
+
return {
|
|
19
|
+
adapter: config.adapter,
|
|
20
|
+
catalog,
|
|
21
|
+
validator: validatorFactory(catalog),
|
|
22
|
+
toolRegistry: new ToolRegistry(config.toolsets ?? [], [EMIT_TOOL_NAME]),
|
|
23
|
+
guardrail: config.guardrail,
|
|
24
|
+
authorizers: config.authorizers ?? [],
|
|
25
|
+
hooks: config.hooks ?? {},
|
|
26
|
+
persona: config.persona,
|
|
27
|
+
model: config.model,
|
|
28
|
+
maxToolIterations: config.maxToolIterations ?? DEFAULT_MAX_TOOL_ITERATIONS,
|
|
29
|
+
maxRepairAttempts: config.maxRepairAttempts ?? DEFAULT_MAX_REPAIR_ATTEMPTS,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Resolve config fully, loading the catalog from its source first. */
|
|
33
|
+
export async function resolveConfig(config) {
|
|
34
|
+
const catalog = await resolveCatalog(config.catalog);
|
|
35
|
+
return finalizeConfig(config, catalog);
|
|
36
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes `author()` / `authorStream()` return.
|
|
3
|
+
*
|
|
4
|
+
* Stateless: every request is self-contained. The workflow graph IS
|
|
5
|
+
* the state, so there is no session store. Optional continuity is
|
|
6
|
+
* carried by the CALLER via {@link AuthorRequest.context} — the library
|
|
7
|
+
* never persists anything across requests.
|
|
8
|
+
*
|
|
9
|
+
* These are plain data shapes, not a wire format. YOU choose the
|
|
10
|
+
* transport: return an {@link AuthorEvent} as a JSON response, push
|
|
11
|
+
* {@link AuthorStreamEvent}s over SSE / WebSocket, or consume them
|
|
12
|
+
* in-process. The library ships no HTTP server.
|
|
13
|
+
*/
|
|
14
|
+
import type { Actor, WorkflowEdge, WorkflowNode } from "@flowget/types";
|
|
15
|
+
/**
|
|
16
|
+
* A node in an *authored* graph. Identical to `@flowget/types`'
|
|
17
|
+
* `WorkflowNode` except `position` is **optional** — the AI emits
|
|
18
|
+
* topology + config, and the builder lays the graph out (iteration 2).
|
|
19
|
+
* A downstream consumer that needs coordinates fills them in.
|
|
20
|
+
*/
|
|
21
|
+
export type AuthoredNode = Omit<WorkflowNode, "position"> & {
|
|
22
|
+
position?: {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The graph `author()` proposes. Position-optional sibling of
|
|
29
|
+
* `WorkflowGraph`; still validates against the workflow JSON schema
|
|
30
|
+
* with the `position` requirement relaxed (see `authoring-schema.ts`).
|
|
31
|
+
*/
|
|
32
|
+
export type AuthoredGraph = {
|
|
33
|
+
nodes: AuthoredNode[];
|
|
34
|
+
edges: WorkflowEdge[];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The input to {@link author} / {@link authorStream}.
|
|
38
|
+
*
|
|
39
|
+
* - `command` — the natural-language instruction (required).
|
|
40
|
+
* - `currentGraph` — the graph being edited, for a targeted tweak.
|
|
41
|
+
* Absent for a from-scratch authoring.
|
|
42
|
+
* - `actor` — the caller identity, as **caller-supplied JSON**. It is
|
|
43
|
+
* threaded to `authorizers`, the guardrail, and actor-scoped
|
|
44
|
+
* toolsets. **It is NOT trusted or verified by default** — the
|
|
45
|
+
* library performs no signature or shape check. It becomes
|
|
46
|
+
* trustworthy ONLY once you configure an `authorizer` that verifies
|
|
47
|
+
* it (per the auth-agnostic stance). Any actor-scoped toolset that
|
|
48
|
+
* gates data access on `actor` (e.g. by `tenantId`) therefore
|
|
49
|
+
* REQUIRES an authorizer, or a caller can spoof the actor. Absent for
|
|
50
|
+
* an anonymous deployment.
|
|
51
|
+
* - `context` — opaque, caller-carried continuity payload. The library
|
|
52
|
+
* forwards it to the model as extra grounding but never inspects or
|
|
53
|
+
* persists it.
|
|
54
|
+
*
|
|
55
|
+
* **Bound the inputs (host responsibility).** `command`, `currentGraph`,
|
|
56
|
+
* and `context` are all serialized into the prompt, and `currentGraph` /
|
|
57
|
+
* `context` can be re-sent across repair turns (up to `maxToolIterations`).
|
|
58
|
+
* The host MUST cap the size of ALL THREE before calling — an unbounded
|
|
59
|
+
* `currentGraph` or `context` is as much a token-DoS as a huge `command`.
|
|
60
|
+
* See the README guardrail example.
|
|
61
|
+
*/
|
|
62
|
+
export type AuthorRequest = {
|
|
63
|
+
command: string;
|
|
64
|
+
currentGraph?: AuthoredGraph;
|
|
65
|
+
actor?: Actor;
|
|
66
|
+
context?: unknown;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* A workflow-graph proposal to preview / apply.
|
|
70
|
+
*
|
|
71
|
+
* **Model-authored, untrusted until approved.** The graph is a model
|
|
72
|
+
* suggestion, not a vetted artifact. Validation gates *runnability*
|
|
73
|
+
* (schema shape, known node types, resolvable references) — it does NOT
|
|
74
|
+
* vet field *values*: a malicious or mistaken value (e.g. an
|
|
75
|
+
* exfiltrating URL in an `http_request` node) passes validation. Never
|
|
76
|
+
* auto-apply a proposal without human review and/or a downstream
|
|
77
|
+
* field-value policy check. Treat `summary` as untrusted model output
|
|
78
|
+
* too (output-encode before rendering).
|
|
79
|
+
*/
|
|
80
|
+
export type AuthorProposal = {
|
|
81
|
+
kind: "proposal";
|
|
82
|
+
graph: AuthoredGraph;
|
|
83
|
+
summary: string;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* A text reply — a plain answer, a refusal, or a **clarifying
|
|
87
|
+
* question** when the command is ambiguous. Conversation emerges only
|
|
88
|
+
* when useful; the default is a one-shot proposal.
|
|
89
|
+
*/
|
|
90
|
+
export type AuthorMessage = {
|
|
91
|
+
kind: "message";
|
|
92
|
+
text: string;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* The discriminated result of {@link author}: exactly one
|
|
96
|
+
* {@link AuthorProposal} or {@link AuthorMessage}. Discriminate on
|
|
97
|
+
* `kind`. This is a plain value — serialize it over whatever transport
|
|
98
|
+
* you run (a JSON response, a queue message, an in-process return); the
|
|
99
|
+
* library pins no wire format.
|
|
100
|
+
*/
|
|
101
|
+
export type AuthorEvent = AuthorProposal | AuthorMessage;
|
|
102
|
+
/**
|
|
103
|
+
* A streamed authoring event (see {@link authorStream}). Assistant text
|
|
104
|
+
* arrives incrementally as `text-delta`s; the stream ends with exactly one
|
|
105
|
+
* terminal `proposal`, `message`, or `error`. The terminal `message.text` is
|
|
106
|
+
* the full, authoritative text (a client that rendered the deltas can ignore
|
|
107
|
+
* it or use it to reconcile). A mid-stream failure is a terminal `error`.
|
|
108
|
+
*
|
|
109
|
+
* Discriminate on `kind` — the same key as {@link AuthorEvent}. These are
|
|
110
|
+
* plain values: frame them over SSE, a WebSocket, or an in-process async
|
|
111
|
+
* iterator as you see fit. The library pins no wire format.
|
|
112
|
+
*/
|
|
113
|
+
export type AuthorStreamEvent = {
|
|
114
|
+
kind: "text-delta";
|
|
115
|
+
delta: string;
|
|
116
|
+
} | {
|
|
117
|
+
kind: "proposal";
|
|
118
|
+
graph: AuthoredGraph;
|
|
119
|
+
summary: string;
|
|
120
|
+
} | {
|
|
121
|
+
kind: "message";
|
|
122
|
+
text: string;
|
|
123
|
+
} | {
|
|
124
|
+
kind: "error";
|
|
125
|
+
error: string;
|
|
126
|
+
};
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes `author()` / `authorStream()` return.
|
|
3
|
+
*
|
|
4
|
+
* Stateless: every request is self-contained. The workflow graph IS
|
|
5
|
+
* the state, so there is no session store. Optional continuity is
|
|
6
|
+
* carried by the CALLER via {@link AuthorRequest.context} — the library
|
|
7
|
+
* never persists anything across requests.
|
|
8
|
+
*
|
|
9
|
+
* These are plain data shapes, not a wire format. YOU choose the
|
|
10
|
+
* transport: return an {@link AuthorEvent} as a JSON response, push
|
|
11
|
+
* {@link AuthorStreamEvent}s over SSE / WebSocket, or consume them
|
|
12
|
+
* in-process. The library ships no HTTP server.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|