@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
|
@@ -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
|
+
});
|
package/dist/guards.d.ts
ADDED
package/dist/guards.js
ADDED
package/dist/hooks.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/prompt.d.ts
ADDED
|
@@ -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[];
|