@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/prompt.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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 { loadWorkflowSchema } from "./authoring-schema.js";
|
|
21
|
+
// NOTE: the decline / already-satisfied rules below shape model
|
|
22
|
+
// behavior on edge commands (e.g. "clear the canvas") and are only
|
|
23
|
+
// observable with a real LLM — the deterministic mock adapter in the
|
|
24
|
+
// test suite scripts its own turns, so these are verified in the
|
|
25
|
+
// founder's playground, not by unit tests.
|
|
26
|
+
export 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.
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
- Author the smallest correct workflow that satisfies the request — do not add nodes the request did not ask for.
|
|
30
|
+
- A runnable workflow needs at least one node whose category is "trigger".
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
- 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.
|
|
34
|
+
- Node "position" is optional — omit it; the builder lays the graph out.
|
|
35
|
+
- 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" }.
|
|
36
|
+
- 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.
|
|
37
|
+
- If the request is NOT an authorable runnable workflow — e.g. "clear/discard/empty the canvas", "delete everything", "start over", or anything that would require an empty or non-runnable graph — 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.
|
|
38
|
+
- 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.
|
|
39
|
+
- When (and only when) you have a complete, valid workflow to author or change, call the "propose_workflow" tool exactly once.`;
|
|
40
|
+
export function buildSystemPrompt(input) {
|
|
41
|
+
const persona = input.persona ?? DEFAULT_PERSONA;
|
|
42
|
+
const catalog = JSON.stringify(input.catalog, null, 2);
|
|
43
|
+
const schema = JSON.stringify(stripVerbose(loadWorkflowSchema()), null, 2);
|
|
44
|
+
return [
|
|
45
|
+
persona,
|
|
46
|
+
"## Node catalog (the only node types you may use)",
|
|
47
|
+
"```json",
|
|
48
|
+
catalog,
|
|
49
|
+
"```",
|
|
50
|
+
"## Workflow graph shape",
|
|
51
|
+
"```json",
|
|
52
|
+
schema,
|
|
53
|
+
"```",
|
|
54
|
+
].join("\n\n");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The EXACT default system prompt `author` sends for a given catalog
|
|
58
|
+
* — {@link DEFAULT_PERSONA} + the catalog + the workflow-graph schema
|
|
59
|
+
* (loaded from `@flowget/types`, the single source of truth). This is
|
|
60
|
+
* the un-overridden `buildSystemPrompt({ catalog })` the author loop
|
|
61
|
+
* uses when `config.persona` is unset (see `runToolLoop` in
|
|
62
|
+
* `author.ts`).
|
|
63
|
+
*
|
|
64
|
+
* A thin, intent-revealing public entry point for consumers that need
|
|
65
|
+
* the real prompt without reaching into the author loop or replicating
|
|
66
|
+
* the persona — e.g. an out-of-process evaluation harness that measures
|
|
67
|
+
* the raw model output the library's own prompt elicits. The schema is
|
|
68
|
+
* loaded internally (never injected) so the prompt can never drift from
|
|
69
|
+
* the shipped `@flowget/types` schema.
|
|
70
|
+
*/
|
|
71
|
+
export function getDefaultSystemPrompt(catalog) {
|
|
72
|
+
return buildSystemPrompt({ catalog });
|
|
73
|
+
}
|
|
74
|
+
export function buildUserPrompt(request) {
|
|
75
|
+
const parts = [`## Command\n${request.command}`];
|
|
76
|
+
if (request.currentGraph !== undefined) {
|
|
77
|
+
parts.push("## Current graph (edit this — preserve nodes the command does not touch)", "```json", safeStringify(request.currentGraph), "```");
|
|
78
|
+
}
|
|
79
|
+
if (request.context !== undefined) {
|
|
80
|
+
parts.push("## Additional context (client-provided)", "```json", safeStringify(request.context), "```");
|
|
81
|
+
}
|
|
82
|
+
return parts.join("\n\n");
|
|
83
|
+
}
|
|
84
|
+
export function buildInitialMessages(request, system) {
|
|
85
|
+
return [
|
|
86
|
+
{ role: "system", content: buildSystemPrompt(system) },
|
|
87
|
+
{ role: "user", content: buildUserPrompt(request) },
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
function safeStringify(value) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return String(value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Recursively drop `markdownDescription` keys (the biggest token sink). */
|
|
99
|
+
function stripVerbose(value) {
|
|
100
|
+
if (Array.isArray(value))
|
|
101
|
+
return value.map(stripVerbose);
|
|
102
|
+
if (typeof value === "object" && value !== null) {
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
105
|
+
if (key === "markdownDescription")
|
|
106
|
+
continue;
|
|
107
|
+
out[key] = stripVerbose(inner);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
package/dist/ssrf.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Construct-time URL validator used by `openaiAdapter({ baseURL })`
|
|
3
|
+
* and any BYO adapter that accepts a URL from caller config.
|
|
4
|
+
*
|
|
5
|
+
* Ported verbatim (behavior-preserving) from `@flowget/engine`'s
|
|
6
|
+
* `_shared/ssrf-guard.ts` — a cross-repo-locked security primitive.
|
|
7
|
+
* Reusing the audited logic (and its `ipaddr.js` classifier) is a
|
|
8
|
+
* deliberate decision over a hand-rolled re-implementation.
|
|
9
|
+
*
|
|
10
|
+
* **Static check, not runtime.** Runs once at the call site that
|
|
11
|
+
* constructs the adapter, before the URL is captured in closure. It
|
|
12
|
+
* catches the common config-leak shape (loopback, private IPv4 ranges,
|
|
13
|
+
* cloud metadata IP, IPv6 loopback/link-local/ULA, IPv4-mapped AND
|
|
14
|
+
* IPv4-compatible IPv6 wrapping a private IPv4, `file://`, etc.). It
|
|
15
|
+
* does NOT defend against DNS rebinding (a public hostname that
|
|
16
|
+
* resolves to a private IP at request time) or open redirects — those
|
|
17
|
+
* belong to the HTTP transport / network policy.
|
|
18
|
+
*
|
|
19
|
+
* **Scope**: `http:` / `https:` only. Other schemes are rejected.
|
|
20
|
+
*/
|
|
21
|
+
export type AssertSafeUrlOptions = {
|
|
22
|
+
/**
|
|
23
|
+
* Hostnames that bypass the private-range / loopback checks. Use
|
|
24
|
+
* only when the caller knowingly points at a private mirror (e.g. an
|
|
25
|
+
* internal LLM proxy at `llm.corp.internal`). Case-insensitive full
|
|
26
|
+
* hostname match — no wildcards.
|
|
27
|
+
*/
|
|
28
|
+
readonly allowHosts?: ReadonlyArray<string>;
|
|
29
|
+
};
|
|
30
|
+
export declare class UnsafeUrlError extends Error {
|
|
31
|
+
name: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function assertSafeUrl(url: string, opts?: AssertSafeUrlOptions): void;
|
package/dist/ssrf.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Construct-time URL validator used by `openaiAdapter({ baseURL })`
|
|
3
|
+
* and any BYO adapter that accepts a URL from caller config.
|
|
4
|
+
*
|
|
5
|
+
* Ported verbatim (behavior-preserving) from `@flowget/engine`'s
|
|
6
|
+
* `_shared/ssrf-guard.ts` — a cross-repo-locked security primitive.
|
|
7
|
+
* Reusing the audited logic (and its `ipaddr.js` classifier) is a
|
|
8
|
+
* deliberate decision over a hand-rolled re-implementation.
|
|
9
|
+
*
|
|
10
|
+
* **Static check, not runtime.** Runs once at the call site that
|
|
11
|
+
* constructs the adapter, before the URL is captured in closure. It
|
|
12
|
+
* catches the common config-leak shape (loopback, private IPv4 ranges,
|
|
13
|
+
* cloud metadata IP, IPv6 loopback/link-local/ULA, IPv4-mapped AND
|
|
14
|
+
* IPv4-compatible IPv6 wrapping a private IPv4, `file://`, etc.). It
|
|
15
|
+
* does NOT defend against DNS rebinding (a public hostname that
|
|
16
|
+
* resolves to a private IP at request time) or open redirects — those
|
|
17
|
+
* belong to the HTTP transport / network policy.
|
|
18
|
+
*
|
|
19
|
+
* **Scope**: `http:` / `https:` only. Other schemes are rejected.
|
|
20
|
+
*/
|
|
21
|
+
import ipaddr from "ipaddr.js";
|
|
22
|
+
/**
|
|
23
|
+
* `http:` is intentionally permitted alongside `https:`. Self-hosted
|
|
24
|
+
* tenants and dev workflows (Ollama on loopback, an LLM mirror inside
|
|
25
|
+
* a VPN) genuinely need plaintext HTTP. Operators carrying an `apiKey`
|
|
26
|
+
* over `http:` accept the MITM exposure — this guard does not enforce
|
|
27
|
+
* TLS. Other schemes stay rejected outright.
|
|
28
|
+
*/
|
|
29
|
+
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
|
30
|
+
/** Hostnames that always indicate loopback/local even if not an IP. */
|
|
31
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
32
|
+
"localhost",
|
|
33
|
+
"ip6-localhost",
|
|
34
|
+
"ip6-loopback",
|
|
35
|
+
]);
|
|
36
|
+
export class UnsafeUrlError extends Error {
|
|
37
|
+
name = "UnsafeUrlError";
|
|
38
|
+
}
|
|
39
|
+
export function assertSafeUrl(url, opts = {}) {
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = new URL(url);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new UnsafeUrlError(`Refusing unparseable URL passed to an adapter factory: ${truncate(url)}`);
|
|
46
|
+
}
|
|
47
|
+
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
|
48
|
+
throw new UnsafeUrlError(`Refusing non-HTTP(S) URL passed to an adapter factory: ${parsed.protocol}// (only http: / https: are allowed)`);
|
|
49
|
+
}
|
|
50
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
51
|
+
const ipCandidate = hostname.startsWith("[") && hostname.endsWith("]")
|
|
52
|
+
? hostname.slice(1, -1)
|
|
53
|
+
: hostname;
|
|
54
|
+
const allowList = new Set((opts.allowHosts ?? []).map((h) => h.toLowerCase()));
|
|
55
|
+
if (allowList.has(hostname) || allowList.has(ipCandidate))
|
|
56
|
+
return;
|
|
57
|
+
if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
|
|
58
|
+
throw new UnsafeUrlError(`Refusing loopback hostname passed to an adapter factory: ${hostname}`);
|
|
59
|
+
}
|
|
60
|
+
if (!ipaddr.isValid(ipCandidate)) {
|
|
61
|
+
// Not an IP — treat as a regular hostname and allow.
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const ip = ipaddr.parse(ipCandidate);
|
|
65
|
+
if (ip.kind() === "ipv4") {
|
|
66
|
+
assertIPv4NotPrivateOrReserved(ip, hostname);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const v6 = ip;
|
|
70
|
+
const v6Range = v6.range();
|
|
71
|
+
if (v6Range === "loopback" ||
|
|
72
|
+
v6Range === "unspecified" ||
|
|
73
|
+
v6Range === "linkLocal" ||
|
|
74
|
+
v6Range === "uniqueLocal") {
|
|
75
|
+
throw new UnsafeUrlError(`Refusing private/reserved IPv6 (${v6Range}) passed to an adapter factory: ${ipCandidate}`);
|
|
76
|
+
}
|
|
77
|
+
const embedded = embeddedIPv4(v6);
|
|
78
|
+
if (embedded) {
|
|
79
|
+
assertIPv4NotPrivateOrReserved(embedded, `${ipCandidate} (IPv4 inside IPv6)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function assertIPv4NotPrivateOrReserved(ip, display) {
|
|
83
|
+
const range = ip.range();
|
|
84
|
+
if (range === "unspecified" ||
|
|
85
|
+
range === "broadcast" ||
|
|
86
|
+
range === "multicast" ||
|
|
87
|
+
range === "linkLocal" ||
|
|
88
|
+
range === "loopback" ||
|
|
89
|
+
range === "carrierGradeNat" ||
|
|
90
|
+
range === "private" ||
|
|
91
|
+
range === "reserved") {
|
|
92
|
+
throw new UnsafeUrlError(`Refusing private/reserved IPv4 (${range}) passed to an adapter factory: ${display}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function embeddedIPv4(v6) {
|
|
96
|
+
const p = v6.parts;
|
|
97
|
+
const topClear = p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 0 && p[4] === 0;
|
|
98
|
+
if (!topClear)
|
|
99
|
+
return null;
|
|
100
|
+
const isMapped = p[5] === 0xffff;
|
|
101
|
+
const isCompatLike = p[5] === 0;
|
|
102
|
+
if (!isMapped && !isCompatLike)
|
|
103
|
+
return null;
|
|
104
|
+
const lo = p[6] ?? 0;
|
|
105
|
+
const hi = p[7] ?? 0;
|
|
106
|
+
const octets = [
|
|
107
|
+
(lo >> 8) & 0xff,
|
|
108
|
+
lo & 0xff,
|
|
109
|
+
(hi >> 8) & 0xff,
|
|
110
|
+
hi & 0xff,
|
|
111
|
+
];
|
|
112
|
+
return new ipaddr.IPv4(octets);
|
|
113
|
+
}
|
|
114
|
+
function truncate(s) {
|
|
115
|
+
return s.length > 80 ? s.slice(0, 77) + "..." : s;
|
|
116
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolsets — actor-scoped, model-invoked tools that run in-process
|
|
3
|
+
* (host-side) against the customer's data. The marquee capability.
|
|
4
|
+
*
|
|
5
|
+
* A tool is a plain async function — no Temporal-activity ceremony.
|
|
6
|
+
* The model calls it mid-loop; `author` dispatches it with the trusted
|
|
7
|
+
* `actor` and feeds the result back to the model. The tool's RETURN
|
|
8
|
+
* value is a domain result (including "not found"); a THROW is an infra
|
|
9
|
+
* fault that aborts the request. That distinction is the whole
|
|
10
|
+
* contract: model-recoverable outcomes are return values, operational
|
|
11
|
+
* failures are exceptions.
|
|
12
|
+
*
|
|
13
|
+
* Reference example: `dbToolset` in `examples/db-toolset.ts` resolves a
|
|
14
|
+
* user from a customer DB, scoped by the actor's tenant.
|
|
15
|
+
*/
|
|
16
|
+
import type { Actor } from "@flowget/types";
|
|
17
|
+
import type { AiToolCall, AiToolDefinition } from "./adapter.js";
|
|
18
|
+
/** Runtime context handed to every tool — carries the trusted actor. */
|
|
19
|
+
export type ToolContext = {
|
|
20
|
+
actor?: Actor;
|
|
21
|
+
};
|
|
22
|
+
export type Tool = {
|
|
23
|
+
/** Tool name (letters, digits, `_`, `-`). Namespaced on the wire. */
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
/** JSON Schema for the tool's arguments object. */
|
|
27
|
+
parameters: Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Run the tool. Return a domain result (serialized back to the
|
|
30
|
+
* model). Throw only on an infra fault — a throw aborts the request.
|
|
31
|
+
*/
|
|
32
|
+
run: (input: Record<string, unknown>, context: ToolContext) => Promise<unknown> | unknown;
|
|
33
|
+
};
|
|
34
|
+
export type Toolset = {
|
|
35
|
+
/**
|
|
36
|
+
* Optional namespace. When set, wire names become
|
|
37
|
+
* `${namespace}__${tool.name}` so tools from different toolsets never
|
|
38
|
+
* collide.
|
|
39
|
+
*/
|
|
40
|
+
namespace?: string;
|
|
41
|
+
tools: Tool[];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Flattens toolsets into a wire-name-addressable registry, enforcing
|
|
45
|
+
* name validity, uniqueness, and non-collision with reserved tool
|
|
46
|
+
* names (e.g. the emit tool).
|
|
47
|
+
*/
|
|
48
|
+
export declare class ToolRegistry {
|
|
49
|
+
private readonly entries;
|
|
50
|
+
private readonly defs;
|
|
51
|
+
constructor(toolsets: readonly Toolset[], reservedNames?: readonly string[]);
|
|
52
|
+
/** Tool definitions to advertise to the model. */
|
|
53
|
+
definitions(): AiToolDefinition[];
|
|
54
|
+
has(wireName: string): boolean;
|
|
55
|
+
/** Dispatch a model tool call; returns the serialized tool result. */
|
|
56
|
+
dispatch(call: AiToolCall, context: ToolContext): Promise<string>;
|
|
57
|
+
}
|
package/dist/toolset.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolsets — actor-scoped, model-invoked tools that run in-process
|
|
3
|
+
* (host-side) against the customer's data. The marquee capability.
|
|
4
|
+
*
|
|
5
|
+
* A tool is a plain async function — no Temporal-activity ceremony.
|
|
6
|
+
* The model calls it mid-loop; `author` dispatches it with the trusted
|
|
7
|
+
* `actor` and feeds the result back to the model. The tool's RETURN
|
|
8
|
+
* value is a domain result (including "not found"); a THROW is an infra
|
|
9
|
+
* fault that aborts the request. That distinction is the whole
|
|
10
|
+
* contract: model-recoverable outcomes are return values, operational
|
|
11
|
+
* failures are exceptions.
|
|
12
|
+
*
|
|
13
|
+
* Reference example: `dbToolset` in `examples/db-toolset.ts` resolves a
|
|
14
|
+
* user from a customer DB, scoped by the actor's tenant.
|
|
15
|
+
*/
|
|
16
|
+
const TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
17
|
+
/**
|
|
18
|
+
* Flattens toolsets into a wire-name-addressable registry, enforcing
|
|
19
|
+
* name validity, uniqueness, and non-collision with reserved tool
|
|
20
|
+
* names (e.g. the emit tool).
|
|
21
|
+
*/
|
|
22
|
+
export class ToolRegistry {
|
|
23
|
+
entries = new Map();
|
|
24
|
+
defs = [];
|
|
25
|
+
constructor(toolsets, reservedNames = []) {
|
|
26
|
+
const reserved = new Set(reservedNames);
|
|
27
|
+
for (const toolset of toolsets) {
|
|
28
|
+
for (const tool of toolset.tools) {
|
|
29
|
+
const wireName = toolset.namespace !== undefined
|
|
30
|
+
? `${toolset.namespace}__${tool.name}`
|
|
31
|
+
: tool.name;
|
|
32
|
+
if (!TOOL_NAME_PATTERN.test(wireName)) {
|
|
33
|
+
throw new Error(`Invalid tool name "${wireName}" — names (and namespaces) may use letters, digits, "_" and "-" only, up to 64 chars.`);
|
|
34
|
+
}
|
|
35
|
+
if (reserved.has(wireName)) {
|
|
36
|
+
throw new Error(`Tool name "${wireName}" collides with a reserved tool.`);
|
|
37
|
+
}
|
|
38
|
+
if (this.entries.has(wireName)) {
|
|
39
|
+
throw new Error(`Duplicate tool name "${wireName}".`);
|
|
40
|
+
}
|
|
41
|
+
this.entries.set(wireName, tool);
|
|
42
|
+
this.defs.push({
|
|
43
|
+
name: wireName,
|
|
44
|
+
description: tool.description,
|
|
45
|
+
parameters: tool.parameters,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Tool definitions to advertise to the model. */
|
|
51
|
+
definitions() {
|
|
52
|
+
return [...this.defs];
|
|
53
|
+
}
|
|
54
|
+
has(wireName) {
|
|
55
|
+
return this.entries.has(wireName);
|
|
56
|
+
}
|
|
57
|
+
/** Dispatch a model tool call; returns the serialized tool result. */
|
|
58
|
+
async dispatch(call, context) {
|
|
59
|
+
const tool = this.entries.get(call.name);
|
|
60
|
+
if (tool === undefined) {
|
|
61
|
+
throw new Error(`Unknown tool "${call.name}".`);
|
|
62
|
+
}
|
|
63
|
+
const result = await tool.run(call.arguments, context);
|
|
64
|
+
return serializeToolResult(result);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function serializeToolResult(result) {
|
|
68
|
+
if (typeof result === "string")
|
|
69
|
+
return result;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.stringify(result ?? null);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return String(result);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default workflow validator — the guard that keeps un-runnable
|
|
3
|
+
* graphs from ever reaching a proposal.
|
|
4
|
+
*
|
|
5
|
+
* Checks, all cheap and dependency-free:
|
|
6
|
+
* 1. **Schema conformance** — AJV against the position-optional
|
|
7
|
+
* authoring schema (structural shape of nodes + edges).
|
|
8
|
+
* 2. **Unique node ids** — duplicate ids make edge/template routing
|
|
9
|
+
* ambiguous.
|
|
10
|
+
* 3. **Catalog cross-reference** — every `node.type` must be a node
|
|
11
|
+
* id present in the loaded catalog.
|
|
12
|
+
* 4. **Edge endpoints exist** — every edge `source`/`target` must
|
|
13
|
+
* reference a node in the graph (no dangling edges).
|
|
14
|
+
* 5. **Trigger presence** — the graph must contain at least one node
|
|
15
|
+
* whose catalog category is `"trigger"` (the engine's runnability
|
|
16
|
+
* gate). Enforced only when the catalog actually ships a trigger
|
|
17
|
+
* node, so a trigger-less catalog can't make authoring impossible.
|
|
18
|
+
* 6. **Dangling template-ref** — every `{{ steps.<id>.… }}` must
|
|
19
|
+
* reference a node id that exists in the graph.
|
|
20
|
+
* 7. **Compound field shape (schedule)** — catalog-driven: for any
|
|
21
|
+
* field a node declares with `type: "schedule"`, its emitted
|
|
22
|
+
* `data[key]` must be the canonical
|
|
23
|
+
* `{ spec: { cron | intervalSeconds, timezone? }, overlap? }`
|
|
24
|
+
* shape (not a flat `{ cron, timezone }`). The catalog carries the
|
|
25
|
+
* field's editor config but NOT its `node.data` shape, so without
|
|
26
|
+
* this the model guesses wrong for compound fields. This is the
|
|
27
|
+
* schedule-field BRIDGE — a general "catalog conveys node.data
|
|
28
|
+
* shapes for every compound field" contract is tracked separately.
|
|
29
|
+
*
|
|
30
|
+
* A failing result carries human-readable error strings; the author
|
|
31
|
+
* loop feeds them back to the model as a bounded repair prompt.
|
|
32
|
+
*
|
|
33
|
+
* Swappable: `config.validator` accepts a factory `(catalog) =>
|
|
34
|
+
* WorkflowValidator`. `createWorkflowValidator` is the default.
|
|
35
|
+
*/
|
|
36
|
+
import type { CatalogNode } from "./catalog.js";
|
|
37
|
+
export type ValidationResult = {
|
|
38
|
+
ok: true;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
errors: string[];
|
|
42
|
+
};
|
|
43
|
+
/** Validate an untrusted candidate graph (whatever the model emitted). */
|
|
44
|
+
export type WorkflowValidator = (graph: unknown) => ValidationResult;
|
|
45
|
+
/** Builds a validator bound to a specific catalog. */
|
|
46
|
+
export type WorkflowValidatorFactory = (catalog: readonly CatalogNode[]) => WorkflowValidator;
|
|
47
|
+
/**
|
|
48
|
+
* The default validator factory. Public note: compound-field VALUE
|
|
49
|
+
* validation currently covers only `type: "schedule"` fields — other
|
|
50
|
+
* compound field kinds are shape-checked by the JSON schema but not yet
|
|
51
|
+
* value-validated against their catalog declaration (a general
|
|
52
|
+
* compound-field contract is tracked separately).
|
|
53
|
+
*/
|
|
54
|
+
export declare const createWorkflowValidator: WorkflowValidatorFactory;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default workflow validator — the guard that keeps un-runnable
|
|
3
|
+
* graphs from ever reaching a proposal.
|
|
4
|
+
*
|
|
5
|
+
* Checks, all cheap and dependency-free:
|
|
6
|
+
* 1. **Schema conformance** — AJV against the position-optional
|
|
7
|
+
* authoring schema (structural shape of nodes + edges).
|
|
8
|
+
* 2. **Unique node ids** — duplicate ids make edge/template routing
|
|
9
|
+
* ambiguous.
|
|
10
|
+
* 3. **Catalog cross-reference** — every `node.type` must be a node
|
|
11
|
+
* id present in the loaded catalog.
|
|
12
|
+
* 4. **Edge endpoints exist** — every edge `source`/`target` must
|
|
13
|
+
* reference a node in the graph (no dangling edges).
|
|
14
|
+
* 5. **Trigger presence** — the graph must contain at least one node
|
|
15
|
+
* whose catalog category is `"trigger"` (the engine's runnability
|
|
16
|
+
* gate). Enforced only when the catalog actually ships a trigger
|
|
17
|
+
* node, so a trigger-less catalog can't make authoring impossible.
|
|
18
|
+
* 6. **Dangling template-ref** — every `{{ steps.<id>.… }}` must
|
|
19
|
+
* reference a node id that exists in the graph.
|
|
20
|
+
* 7. **Compound field shape (schedule)** — catalog-driven: for any
|
|
21
|
+
* field a node declares with `type: "schedule"`, its emitted
|
|
22
|
+
* `data[key]` must be the canonical
|
|
23
|
+
* `{ spec: { cron | intervalSeconds, timezone? }, overlap? }`
|
|
24
|
+
* shape (not a flat `{ cron, timezone }`). The catalog carries the
|
|
25
|
+
* field's editor config but NOT its `node.data` shape, so without
|
|
26
|
+
* this the model guesses wrong for compound fields. This is the
|
|
27
|
+
* schedule-field BRIDGE — a general "catalog conveys node.data
|
|
28
|
+
* shapes for every compound field" contract is tracked separately.
|
|
29
|
+
*
|
|
30
|
+
* A failing result carries human-readable error strings; the author
|
|
31
|
+
* loop feeds them back to the model as a bounded repair prompt.
|
|
32
|
+
*
|
|
33
|
+
* Swappable: `config.validator` accepts a factory `(catalog) =>
|
|
34
|
+
* WorkflowValidator`. `createWorkflowValidator` is the default.
|
|
35
|
+
*/
|
|
36
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
37
|
+
import { buildAuthoringSchema } from "./authoring-schema.js";
|
|
38
|
+
import { isRecord } from "./guards.js";
|
|
39
|
+
/**
|
|
40
|
+
* The default validator factory. Public note: compound-field VALUE
|
|
41
|
+
* validation currently covers only `type: "schedule"` fields — other
|
|
42
|
+
* compound field kinds are shape-checked by the JSON schema but not yet
|
|
43
|
+
* value-validated against their catalog declaration (a general
|
|
44
|
+
* compound-field contract is tracked separately).
|
|
45
|
+
*/
|
|
46
|
+
export const createWorkflowValidator = (catalog) => {
|
|
47
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
48
|
+
const validateSchema = ajv.compile(buildAuthoringSchema());
|
|
49
|
+
const knownTypes = new Set(catalog.map((node) => node.id));
|
|
50
|
+
const triggerTypes = new Set(catalog.filter((node) => node.category === "trigger").map((node) => node.id));
|
|
51
|
+
// node type id → keys of its `type: "schedule"` fields, read from the
|
|
52
|
+
// catalog (never hardcoded to a node id). `fields` is typed on
|
|
53
|
+
// `CatalogNode` (`BuilderHints.fields?: ConfigField[]`).
|
|
54
|
+
const scheduleFieldsByType = new Map();
|
|
55
|
+
for (const entry of catalog) {
|
|
56
|
+
if (entry.fields === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
const keys = entry.fields
|
|
59
|
+
.filter((field) => field.type === "schedule")
|
|
60
|
+
.map((field) => field.key);
|
|
61
|
+
if (keys.length > 0)
|
|
62
|
+
scheduleFieldsByType.set(entry.id, keys);
|
|
63
|
+
}
|
|
64
|
+
return (graph) => {
|
|
65
|
+
const errors = [];
|
|
66
|
+
if (!validateSchema(graph)) {
|
|
67
|
+
for (const err of validateSchema.errors ?? []) {
|
|
68
|
+
const where = err.instancePath.length > 0 ? err.instancePath : "/";
|
|
69
|
+
errors.push(`schema: ${where} ${err.message ?? "is invalid"}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Semantic checks run whenever the graph is structurally a
|
|
73
|
+
// node-bearing object, even if AJV flagged other issues — the more
|
|
74
|
+
// errors the repair prompt sees at once, the fewer round trips.
|
|
75
|
+
if (isRecord(graph) && Array.isArray(graph["nodes"])) {
|
|
76
|
+
const nodes = graph["nodes"];
|
|
77
|
+
// Pre-pass: collect the COMPLETE set of node ids (and detect
|
|
78
|
+
// duplicates) BEFORE any reference check. `nodes[]` order is not
|
|
79
|
+
// topological — a node may reference (via `{{ steps.X }}` or an
|
|
80
|
+
// edge) another node defined later in the array — so the id set
|
|
81
|
+
// must be fully populated first, or a valid forward reference is
|
|
82
|
+
// wrongly flagged as dangling.
|
|
83
|
+
const nodeIds = new Set();
|
|
84
|
+
const duplicateIds = new Set();
|
|
85
|
+
for (const node of nodes) {
|
|
86
|
+
if (isRecord(node) && typeof node["id"] === "string") {
|
|
87
|
+
if (nodeIds.has(node["id"]))
|
|
88
|
+
duplicateIds.add(node["id"]);
|
|
89
|
+
nodeIds.add(node["id"]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const dup of duplicateIds) {
|
|
93
|
+
errors.push(`duplicate node id "${dup}" — node ids must be unique`);
|
|
94
|
+
}
|
|
95
|
+
// Second pass: catalog cross-ref, trigger presence, and dangling
|
|
96
|
+
// template refs — all against the fully-populated id set.
|
|
97
|
+
let hasTrigger = false;
|
|
98
|
+
for (const node of nodes) {
|
|
99
|
+
if (!isRecord(node))
|
|
100
|
+
continue;
|
|
101
|
+
const id = typeof node["id"] === "string" ? node["id"] : "?";
|
|
102
|
+
const type = node["type"];
|
|
103
|
+
if (typeof type === "string") {
|
|
104
|
+
if (!knownTypes.has(type)) {
|
|
105
|
+
errors.push(`unknown node type "${type}" (node "${id}") — not in the catalog`);
|
|
106
|
+
}
|
|
107
|
+
if (triggerTypes.has(type))
|
|
108
|
+
hasTrigger = true;
|
|
109
|
+
const scheduleKeys = scheduleFieldsByType.get(type);
|
|
110
|
+
if (scheduleKeys !== undefined && isRecord(node["data"])) {
|
|
111
|
+
const data = node["data"];
|
|
112
|
+
for (const key of scheduleKeys) {
|
|
113
|
+
if (!(key in data))
|
|
114
|
+
continue; // shape-check only when emitted
|
|
115
|
+
const problem = validateScheduleShape(data[key]);
|
|
116
|
+
if (problem !== null) {
|
|
117
|
+
errors.push(`schedule field "${key}" on node "${id}" ${problem}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
collectDanglingRefs(node["data"], nodeIds, id, errors);
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(graph["edges"])) {
|
|
125
|
+
for (const edge of graph["edges"]) {
|
|
126
|
+
if (!isRecord(edge))
|
|
127
|
+
continue;
|
|
128
|
+
const edgeId = typeof edge["id"] === "string" ? edge["id"] : "?";
|
|
129
|
+
for (const end of ["source", "target"]) {
|
|
130
|
+
const ref = edge[end];
|
|
131
|
+
if (typeof ref === "string" && !nodeIds.has(ref)) {
|
|
132
|
+
errors.push(`edge "${edgeId}" ${end} "${ref}" references no node in the graph`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// The engine requires >=1 trigger node to run a workflow. Only
|
|
138
|
+
// enforce when the catalog ships a trigger — a trigger-less
|
|
139
|
+
// catalog can't produce one, so blocking would be un-authorable.
|
|
140
|
+
if (triggerTypes.size > 0 && !hasTrigger) {
|
|
141
|
+
errors.push('the workflow has no trigger node — it needs at least one node whose category is "trigger" to be runnable');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Validate an emitted `type: "schedule"` field value. Returns a repair
|
|
149
|
+
* message that NAMES the expected shape (so the bounded repair loop
|
|
150
|
+
* converges), or null when the shape is canonical:
|
|
151
|
+
* `{ spec: { cron | intervalSeconds, timezone? }, overlap? }`.
|
|
152
|
+
*/
|
|
153
|
+
function validateScheduleShape(value) {
|
|
154
|
+
if (!isRecord(value)) {
|
|
155
|
+
return "must be an object `{ spec: { cron|intervalSeconds, timezone? }, overlap? }`";
|
|
156
|
+
}
|
|
157
|
+
const spec = value["spec"];
|
|
158
|
+
if (!isRecord(spec)) {
|
|
159
|
+
if ("cron" in value || "intervalSeconds" in value || "timezone" in value) {
|
|
160
|
+
return "must nest cron/interval under `spec`: `{ spec: { cron|intervalSeconds, timezone? }, overlap? }` — you emitted a flat `{ cron, timezone }`";
|
|
161
|
+
}
|
|
162
|
+
return "must have a `spec` object: `{ spec: { cron|intervalSeconds, timezone? }, overlap? }`";
|
|
163
|
+
}
|
|
164
|
+
const hasCron = typeof spec["cron"] === "string";
|
|
165
|
+
const hasInterval = typeof spec["intervalSeconds"] === "number";
|
|
166
|
+
if (hasCron === hasInterval) {
|
|
167
|
+
return "`spec` must have exactly one of `cron` (string) or `intervalSeconds` (number)";
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
function collectDanglingRefs(value, nodeIds, nodeId, out) {
|
|
172
|
+
// Fresh regex per traversal — no shared `lastIndex` across calls.
|
|
173
|
+
const stepsRef = /\{\{\s*steps\.([A-Za-z0-9_-]+)/g;
|
|
174
|
+
walk(value);
|
|
175
|
+
function walk(inner) {
|
|
176
|
+
if (typeof inner === "string") {
|
|
177
|
+
let match;
|
|
178
|
+
while ((match = stepsRef.exec(inner)) !== null) {
|
|
179
|
+
const referenced = match[1];
|
|
180
|
+
if (referenced !== undefined && !nodeIds.has(referenced)) {
|
|
181
|
+
out.push(`dangling template ref "{{ steps.${referenced}.… }}" in node "${nodeId}" — no node has id "${referenced}"`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(inner)) {
|
|
187
|
+
for (const item of inner)
|
|
188
|
+
walk(item);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (isRecord(inner)) {
|
|
192
|
+
for (const item of Object.values(inner))
|
|
193
|
+
walk(item);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|