@synergenius/flow-weaver 0.20.5 → 0.20.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/validate.js +2 -0
- package/dist/cli/flow-weaver.mjs +360 -8
- package/dist/cli/templates/workflows/ai-agent.js +4 -1
- package/dist/cli/templates/workflows/ai-rag.js +1 -1
- package/dist/cli/templates/workflows/ai-react.js +3 -1
- package/dist/cli/templates/workflows/foreach.js +1 -0
- package/dist/doc-metadata/extractors/error-codes.d.ts +1 -1
- package/dist/doc-metadata/extractors/error-codes.js +50 -0
- package/dist/friendly-errors.js +65 -0
- package/dist/generated-version.d.ts +1 -1
- package/dist/generated-version.js +1 -1
- package/dist/mcp/response-utils.js +8 -0
- package/dist/validation/design-rules.d.ts +61 -0
- package/dist/validation/design-rules.js +377 -0
- package/docs/reference/error-codes.md +74 -0
- package/package.json +1 -1
package/dist/api/validate.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { validator } from "../validator.js";
|
|
8
8
|
import { getAgentValidationRules } from "../validation/agent-rules.js";
|
|
9
|
+
import { getDesignValidationRules } from "../validation/design-rules.js";
|
|
9
10
|
import { validationRuleRegistry } from "./validation-registry.js";
|
|
10
11
|
/**
|
|
11
12
|
* Validates a workflow AST
|
|
@@ -24,6 +25,7 @@ export function validateWorkflow(ast, options) {
|
|
|
24
25
|
// including CI/CD when applicable), and custom rules
|
|
25
26
|
const allRules = [
|
|
26
27
|
...getAgentValidationRules(),
|
|
28
|
+
...getDesignValidationRules(),
|
|
27
29
|
...validationRuleRegistry.getApplicableRules(ast),
|
|
28
30
|
...(options?.customRules || []),
|
|
29
31
|
];
|
package/dist/cli/flow-weaver.mjs
CHANGED
|
@@ -9671,7 +9671,7 @@ var VERSION;
|
|
|
9671
9671
|
var init_generated_version = __esm({
|
|
9672
9672
|
"src/generated-version.ts"() {
|
|
9673
9673
|
"use strict";
|
|
9674
|
-
VERSION = "0.20.
|
|
9674
|
+
VERSION = "0.20.6";
|
|
9675
9675
|
}
|
|
9676
9676
|
});
|
|
9677
9677
|
|
|
@@ -10456,9 +10456,9 @@ function generateScopeFunctionClosure(scopeName, parentNodeId, parentNodeType, w
|
|
|
10456
10456
|
const childInstance = childInstances.find((c) => c.id === sourceNode);
|
|
10457
10457
|
const sourceNodeTypeName = childInstance?.nodeType ?? "";
|
|
10458
10458
|
const varAddr = `{ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${toValidIdentifier(sourceNode)}Idx, nodeTypeName: '${sourceNodeTypeName}' }`;
|
|
10459
|
-
const
|
|
10459
|
+
const isStepPort2 = portName === "success" || portName === "failure";
|
|
10460
10460
|
const defaultValue = portName === "success" ? "true" : portName === "failure" ? "false" : "undefined";
|
|
10461
|
-
if (
|
|
10461
|
+
if (isStepPort2) {
|
|
10462
10462
|
lines.push(
|
|
10463
10463
|
` const ${varName} = ctx.hasVariable(${varAddr}) ? ${getCallAfterMerge}(${varAddr}) as ${portType} : ${defaultValue};`
|
|
10464
10464
|
);
|
|
@@ -39987,6 +39987,277 @@ var init_agent_rules = __esm({
|
|
|
39987
39987
|
}
|
|
39988
39988
|
});
|
|
39989
39989
|
|
|
39990
|
+
// src/validation/design-rules.ts
|
|
39991
|
+
function resolveNodeType2(ast, instance) {
|
|
39992
|
+
return ast.nodeTypes.find(
|
|
39993
|
+
(nt) => nt.name === instance.nodeType || nt.functionName === instance.nodeType
|
|
39994
|
+
);
|
|
39995
|
+
}
|
|
39996
|
+
function getOutgoing2(ast, nodeId, portName) {
|
|
39997
|
+
return ast.connections.filter((c) => {
|
|
39998
|
+
if (c.from.node !== nodeId) return false;
|
|
39999
|
+
if (portName && c.from.port !== portName) return false;
|
|
40000
|
+
return true;
|
|
40001
|
+
});
|
|
40002
|
+
}
|
|
40003
|
+
function getIncoming(ast, nodeId, portName) {
|
|
40004
|
+
return ast.connections.filter((c) => {
|
|
40005
|
+
if (c.to.node !== nodeId) return false;
|
|
40006
|
+
if (portName && c.to.port !== portName) return false;
|
|
40007
|
+
return true;
|
|
40008
|
+
});
|
|
40009
|
+
}
|
|
40010
|
+
function isStepPort(portName, portDef) {
|
|
40011
|
+
if (portDef?.dataType === "STEP") return true;
|
|
40012
|
+
return STEP_PORTS.has(portName);
|
|
40013
|
+
}
|
|
40014
|
+
function getReachableNodes(ast, startNode) {
|
|
40015
|
+
const visited = /* @__PURE__ */ new Set();
|
|
40016
|
+
const queue = [startNode];
|
|
40017
|
+
while (queue.length > 0) {
|
|
40018
|
+
const current2 = queue.shift();
|
|
40019
|
+
if (visited.has(current2)) continue;
|
|
40020
|
+
visited.add(current2);
|
|
40021
|
+
for (const conn of ast.connections) {
|
|
40022
|
+
if (conn.from.node === current2 && !visited.has(conn.to.node)) {
|
|
40023
|
+
queue.push(conn.to.node);
|
|
40024
|
+
}
|
|
40025
|
+
}
|
|
40026
|
+
}
|
|
40027
|
+
return visited;
|
|
40028
|
+
}
|
|
40029
|
+
function getDesignValidationRules() {
|
|
40030
|
+
return designValidationRules;
|
|
40031
|
+
}
|
|
40032
|
+
var STEP_PORTS, asyncNoErrorPathRule, scopeNoFailureExitRule, unboundedRetryRule, fanoutNoFaninRule, exitDataUnreachableRule, pullCandidateRule, pullUnusedRule, designValidationRules;
|
|
40033
|
+
var init_design_rules = __esm({
|
|
40034
|
+
"src/validation/design-rules.ts"() {
|
|
40035
|
+
"use strict";
|
|
40036
|
+
STEP_PORTS = /* @__PURE__ */ new Set(["execute", "onSuccess", "onFailure", "start", "success", "failure"]);
|
|
40037
|
+
asyncNoErrorPathRule = {
|
|
40038
|
+
name: "DESIGN_ASYNC_NO_ERROR_PATH",
|
|
40039
|
+
validate(ast) {
|
|
40040
|
+
const errors2 = [];
|
|
40041
|
+
for (const instance of ast.instances) {
|
|
40042
|
+
const nt = resolveNodeType2(ast, instance);
|
|
40043
|
+
if (!nt) continue;
|
|
40044
|
+
if (!nt.isAsync) continue;
|
|
40045
|
+
if (!nt.hasFailurePort) continue;
|
|
40046
|
+
const failureConns = getOutgoing2(ast, instance.id, "onFailure");
|
|
40047
|
+
if (failureConns.length === 0) {
|
|
40048
|
+
errors2.push({
|
|
40049
|
+
type: "warning",
|
|
40050
|
+
code: "DESIGN_ASYNC_NO_ERROR_PATH",
|
|
40051
|
+
message: `Async node '${instance.id}' has no onFailure connection. Async operations (network, disk, AI) can fail, and errors will be silently lost.`,
|
|
40052
|
+
node: instance.id
|
|
40053
|
+
});
|
|
40054
|
+
}
|
|
40055
|
+
}
|
|
40056
|
+
return errors2;
|
|
40057
|
+
}
|
|
40058
|
+
};
|
|
40059
|
+
scopeNoFailureExitRule = {
|
|
40060
|
+
name: "DESIGN_SCOPE_NO_FAILURE_EXIT",
|
|
40061
|
+
validate(ast) {
|
|
40062
|
+
const errors2 = [];
|
|
40063
|
+
for (const instance of ast.instances) {
|
|
40064
|
+
const nt = resolveNodeType2(ast, instance);
|
|
40065
|
+
if (!nt) continue;
|
|
40066
|
+
const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
|
|
40067
|
+
if (scopeNames.length === 0) continue;
|
|
40068
|
+
if (!nt.hasFailurePort) continue;
|
|
40069
|
+
const failureConns = getOutgoing2(ast, instance.id, "onFailure");
|
|
40070
|
+
const failureConns2 = getOutgoing2(ast, instance.id, "failure");
|
|
40071
|
+
if (failureConns.length === 0 && failureConns2.length === 0) {
|
|
40072
|
+
errors2.push({
|
|
40073
|
+
type: "warning",
|
|
40074
|
+
code: "DESIGN_SCOPE_NO_FAILURE_EXIT",
|
|
40075
|
+
message: `Scope node '${instance.id}' has no failure path out. If all iterations fail, execution stalls with no error surfaced.`,
|
|
40076
|
+
node: instance.id
|
|
40077
|
+
});
|
|
40078
|
+
}
|
|
40079
|
+
}
|
|
40080
|
+
return errors2;
|
|
40081
|
+
}
|
|
40082
|
+
};
|
|
40083
|
+
unboundedRetryRule = {
|
|
40084
|
+
name: "DESIGN_UNBOUNDED_RETRY",
|
|
40085
|
+
validate(ast) {
|
|
40086
|
+
const errors2 = [];
|
|
40087
|
+
const retryPatterns = /retry|repeat|loop|poll|backoff/i;
|
|
40088
|
+
for (const instance of ast.instances) {
|
|
40089
|
+
const nt = resolveNodeType2(ast, instance);
|
|
40090
|
+
if (!nt) continue;
|
|
40091
|
+
const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
|
|
40092
|
+
if (scopeNames.length === 0) continue;
|
|
40093
|
+
const nameHint = `${nt.name} ${nt.functionName} ${nt.label ?? ""}`;
|
|
40094
|
+
if (!retryPatterns.test(nameHint)) continue;
|
|
40095
|
+
const limitInputs = Object.keys(nt.inputs).filter(
|
|
40096
|
+
(p) => /max|limit|attempts|retries|count/i.test(p)
|
|
40097
|
+
);
|
|
40098
|
+
if (limitInputs.length === 0) {
|
|
40099
|
+
errors2.push({
|
|
40100
|
+
type: "warning",
|
|
40101
|
+
code: "DESIGN_UNBOUNDED_RETRY",
|
|
40102
|
+
message: `Scope node '${instance.id}' appears to be a retry loop but has no visible attempt limit input. This could loop indefinitely.`,
|
|
40103
|
+
node: instance.id
|
|
40104
|
+
});
|
|
40105
|
+
}
|
|
40106
|
+
}
|
|
40107
|
+
return errors2;
|
|
40108
|
+
}
|
|
40109
|
+
};
|
|
40110
|
+
fanoutNoFaninRule = {
|
|
40111
|
+
name: "DESIGN_FANOUT_NO_FANIN",
|
|
40112
|
+
validate(ast) {
|
|
40113
|
+
const errors2 = [];
|
|
40114
|
+
for (const instance of ast.instances) {
|
|
40115
|
+
const nt = resolveNodeType2(ast, instance);
|
|
40116
|
+
const stepOutConns = getOutgoing2(ast, instance.id).filter((c) => {
|
|
40117
|
+
if (nt) {
|
|
40118
|
+
const portDef = nt.outputs[c.from.port];
|
|
40119
|
+
return isStepPort(c.from.port, portDef);
|
|
40120
|
+
}
|
|
40121
|
+
return isStepPort(c.from.port);
|
|
40122
|
+
});
|
|
40123
|
+
const stepTargets = [...new Set(stepOutConns.map((c) => c.to.node))].filter(
|
|
40124
|
+
(n) => n !== "Exit"
|
|
40125
|
+
);
|
|
40126
|
+
if (stepTargets.length < 3) continue;
|
|
40127
|
+
const reachableSets = stepTargets.map((target) => getReachableNodes(ast, target));
|
|
40128
|
+
const allNodes = /* @__PURE__ */ new Set();
|
|
40129
|
+
let hasMerge = false;
|
|
40130
|
+
for (const reachable of reachableSets) {
|
|
40131
|
+
for (const node of reachable) {
|
|
40132
|
+
if (allNodes.has(node)) {
|
|
40133
|
+
hasMerge = true;
|
|
40134
|
+
break;
|
|
40135
|
+
}
|
|
40136
|
+
}
|
|
40137
|
+
if (hasMerge) break;
|
|
40138
|
+
for (const node of reachable) {
|
|
40139
|
+
allNodes.add(node);
|
|
40140
|
+
}
|
|
40141
|
+
}
|
|
40142
|
+
if (!hasMerge) {
|
|
40143
|
+
for (const target of stepTargets) {
|
|
40144
|
+
const targetInst = ast.instances.find((i) => i.id === target);
|
|
40145
|
+
if (!targetInst) continue;
|
|
40146
|
+
const targetNt = resolveNodeType2(ast, targetInst);
|
|
40147
|
+
if (!targetNt) continue;
|
|
40148
|
+
const hasMergePort = Object.values(targetNt.inputs).some((p) => p.mergeStrategy);
|
|
40149
|
+
if (hasMergePort) {
|
|
40150
|
+
hasMerge = true;
|
|
40151
|
+
break;
|
|
40152
|
+
}
|
|
40153
|
+
}
|
|
40154
|
+
}
|
|
40155
|
+
if (!hasMerge) {
|
|
40156
|
+
errors2.push({
|
|
40157
|
+
type: "warning",
|
|
40158
|
+
code: "DESIGN_FANOUT_NO_FANIN",
|
|
40159
|
+
message: `Node '${instance.id}' fans out to ${stepTargets.length} step targets (${stepTargets.join(", ")}) but those paths never merge back. Data from parallel branches may be lost.`,
|
|
40160
|
+
node: instance.id
|
|
40161
|
+
});
|
|
40162
|
+
}
|
|
40163
|
+
}
|
|
40164
|
+
return errors2;
|
|
40165
|
+
}
|
|
40166
|
+
};
|
|
40167
|
+
exitDataUnreachableRule = {
|
|
40168
|
+
name: "DESIGN_EXIT_DATA_UNREACHABLE",
|
|
40169
|
+
validate(ast) {
|
|
40170
|
+
const errors2 = [];
|
|
40171
|
+
const stepPorts = /* @__PURE__ */ new Set(["onSuccess", "onFailure"]);
|
|
40172
|
+
for (const [portName, portDef] of Object.entries(ast.exitPorts)) {
|
|
40173
|
+
if (portDef.dataType === "STEP") continue;
|
|
40174
|
+
const incoming = getIncoming(ast, "Exit", portName);
|
|
40175
|
+
if (incoming.length > 0) continue;
|
|
40176
|
+
const hasPullProvider = ast.instances.some((inst) => {
|
|
40177
|
+
const isPull = inst.config?.pullExecution || resolveNodeType2(ast, inst)?.defaultConfig?.pullExecution;
|
|
40178
|
+
if (!isPull) return false;
|
|
40179
|
+
return getOutgoing2(ast, inst.id).some(
|
|
40180
|
+
(c) => c.to.node === "Exit" && c.to.port === portName
|
|
40181
|
+
);
|
|
40182
|
+
});
|
|
40183
|
+
if (!hasPullProvider) {
|
|
40184
|
+
errors2.push({
|
|
40185
|
+
type: "warning",
|
|
40186
|
+
code: "DESIGN_EXIT_DATA_UNREACHABLE",
|
|
40187
|
+
message: `Exit port '${portName}' has no incoming connection and no pull-execution node provides it. The output will be undefined.`
|
|
40188
|
+
});
|
|
40189
|
+
}
|
|
40190
|
+
}
|
|
40191
|
+
return errors2;
|
|
40192
|
+
}
|
|
40193
|
+
};
|
|
40194
|
+
pullCandidateRule = {
|
|
40195
|
+
name: "DESIGN_PULL_CANDIDATE",
|
|
40196
|
+
validate(ast) {
|
|
40197
|
+
const errors2 = [];
|
|
40198
|
+
for (const instance of ast.instances) {
|
|
40199
|
+
const nt = resolveNodeType2(ast, instance);
|
|
40200
|
+
if (!nt) continue;
|
|
40201
|
+
if (instance.config?.pullExecution || nt.defaultConfig?.pullExecution) continue;
|
|
40202
|
+
if (nt.expression) continue;
|
|
40203
|
+
const incomingStep = getIncoming(ast, instance.id).filter((c) => {
|
|
40204
|
+
const portDef = nt.inputs[c.to.port];
|
|
40205
|
+
return isStepPort(c.to.port, portDef);
|
|
40206
|
+
});
|
|
40207
|
+
if (incomingStep.length > 0) continue;
|
|
40208
|
+
const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
|
|
40209
|
+
const hasConsumedOutput = dataOutputs.some(
|
|
40210
|
+
(port) => getOutgoing2(ast, instance.id, port).length > 0
|
|
40211
|
+
);
|
|
40212
|
+
if (hasConsumedOutput) {
|
|
40213
|
+
errors2.push({
|
|
40214
|
+
type: "warning",
|
|
40215
|
+
code: "DESIGN_PULL_CANDIDATE",
|
|
40216
|
+
message: `Node '${instance.id}' has no incoming step connection but its data outputs are consumed downstream. Consider adding [pullExecution: execute] so it executes on demand.`,
|
|
40217
|
+
node: instance.id
|
|
40218
|
+
});
|
|
40219
|
+
}
|
|
40220
|
+
}
|
|
40221
|
+
return errors2;
|
|
40222
|
+
}
|
|
40223
|
+
};
|
|
40224
|
+
pullUnusedRule = {
|
|
40225
|
+
name: "DESIGN_PULL_UNUSED",
|
|
40226
|
+
validate(ast) {
|
|
40227
|
+
const errors2 = [];
|
|
40228
|
+
for (const instance of ast.instances) {
|
|
40229
|
+
const nt = resolveNodeType2(ast, instance);
|
|
40230
|
+
if (!nt) continue;
|
|
40231
|
+
const isPull = instance.config?.pullExecution || nt.defaultConfig?.pullExecution;
|
|
40232
|
+
if (!isPull) continue;
|
|
40233
|
+
const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
|
|
40234
|
+
const hasConnectedOutput = dataOutputs.some(
|
|
40235
|
+
(port) => getOutgoing2(ast, instance.id, port).length > 0
|
|
40236
|
+
);
|
|
40237
|
+
if (!hasConnectedOutput) {
|
|
40238
|
+
errors2.push({
|
|
40239
|
+
type: "warning",
|
|
40240
|
+
code: "DESIGN_PULL_UNUSED",
|
|
40241
|
+
message: `Node '${instance.id}' is marked with pullExecution but no downstream node reads its data output. It will never execute.`,
|
|
40242
|
+
node: instance.id
|
|
40243
|
+
});
|
|
40244
|
+
}
|
|
40245
|
+
}
|
|
40246
|
+
return errors2;
|
|
40247
|
+
}
|
|
40248
|
+
};
|
|
40249
|
+
designValidationRules = [
|
|
40250
|
+
asyncNoErrorPathRule,
|
|
40251
|
+
scopeNoFailureExitRule,
|
|
40252
|
+
unboundedRetryRule,
|
|
40253
|
+
fanoutNoFaninRule,
|
|
40254
|
+
exitDataUnreachableRule,
|
|
40255
|
+
pullCandidateRule,
|
|
40256
|
+
pullUnusedRule
|
|
40257
|
+
];
|
|
40258
|
+
}
|
|
40259
|
+
});
|
|
40260
|
+
|
|
39990
40261
|
// src/api/validate.ts
|
|
39991
40262
|
var validate_exports = {};
|
|
39992
40263
|
__export(validate_exports, {
|
|
@@ -39996,6 +40267,7 @@ function validateWorkflow(ast, options) {
|
|
|
39996
40267
|
const result = validator.validate(ast, { mode: options?.mode });
|
|
39997
40268
|
const allRules = [
|
|
39998
40269
|
...getAgentValidationRules(),
|
|
40270
|
+
...getDesignValidationRules(),
|
|
39999
40271
|
...validationRuleRegistry.getApplicableRules(ast),
|
|
40000
40272
|
...options?.customRules || []
|
|
40001
40273
|
];
|
|
@@ -40030,6 +40302,7 @@ var init_validate = __esm({
|
|
|
40030
40302
|
"use strict";
|
|
40031
40303
|
init_validator();
|
|
40032
40304
|
init_agent_rules();
|
|
40305
|
+
init_design_rules();
|
|
40033
40306
|
init_validation_registry();
|
|
40034
40307
|
}
|
|
40035
40308
|
});
|
|
@@ -46512,6 +46785,7 @@ function aggregateResults(
|
|
|
46512
46785
|
* @connect iterator.results -> Exit.results
|
|
46513
46786
|
* @connect iterator.results -> aggregator.results
|
|
46514
46787
|
* @connect iterator.onSuccess -> aggregator.execute
|
|
46788
|
+
* @connect iterator.onFailure -> aggregator.execute
|
|
46515
46789
|
* @connect aggregator.successCount -> Exit.successCount
|
|
46516
46790
|
* @connect aggregator.failedCount -> Exit.failedCount
|
|
46517
46791
|
* @connect aggregator.onSuccess -> Exit.onSuccess
|
|
@@ -47149,6 +47423,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
|
|
|
47149
47423
|
* @flowWeaver nodeType
|
|
47150
47424
|
* @label Agent Loop
|
|
47151
47425
|
* @input userMessage [order:1] - User's input message
|
|
47426
|
+
* @input [maxIterations] [order:2] - Maximum loop iterations (default: 10)
|
|
47152
47427
|
* @input success scope:iteration [order:0] - From LLM onSuccess
|
|
47153
47428
|
* @input failure scope:iteration [order:1] - From LLM onFailure
|
|
47154
47429
|
* @input llmResponse scope:iteration [order:2] - LLM response
|
|
@@ -47163,6 +47438,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
|
|
|
47163
47438
|
async function agentLoop(
|
|
47164
47439
|
execute: boolean,
|
|
47165
47440
|
userMessage: string,
|
|
47441
|
+
maxIterations: number = MAX_ITERATIONS,
|
|
47166
47442
|
iteration: (start: boolean, state: AgentState) => Promise<{
|
|
47167
47443
|
success: boolean;
|
|
47168
47444
|
failure: boolean;
|
|
@@ -47181,7 +47457,7 @@ async function agentLoop(
|
|
|
47181
47457
|
terminated: false,
|
|
47182
47458
|
};
|
|
47183
47459
|
|
|
47184
|
-
while (state.iteration <
|
|
47460
|
+
while (state.iteration < maxIterations) {
|
|
47185
47461
|
const result = await iteration(true, state);
|
|
47186
47462
|
|
|
47187
47463
|
state.iteration++;
|
|
@@ -47319,6 +47595,7 @@ async function executeTools(
|
|
|
47319
47595
|
* @connect llm.onSuccess -> loop.success:iteration
|
|
47320
47596
|
* @connect llm.onFailure -> loop.failure:iteration
|
|
47321
47597
|
* @connect tools.messages -> loop.toolMessages:iteration
|
|
47598
|
+
* @connect tools.onFailure -> loop.failure:iteration
|
|
47322
47599
|
* @connect loop.response -> Exit.response
|
|
47323
47600
|
* @connect loop.onSuccess -> Exit.onSuccess
|
|
47324
47601
|
* @connect loop.onFailure -> Exit.onFailure
|
|
@@ -47448,6 +47725,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
|
|
|
47448
47725
|
* @label ReAct Loop
|
|
47449
47726
|
* @input execute [order:0] - Execute
|
|
47450
47727
|
* @input task [order:1] - Task for the agent
|
|
47728
|
+
* @input [maxSteps] [order:2] - Maximum reasoning steps (default: 10)
|
|
47451
47729
|
* @input success scope:step [order:0] - Iteration succeeded
|
|
47452
47730
|
* @input failure scope:step [order:1] - Iteration failed
|
|
47453
47731
|
* @input thought scope:step [order:2] - Agent's reasoning
|
|
@@ -47463,6 +47741,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
|
|
|
47463
47741
|
async function reactLoop(
|
|
47464
47742
|
execute: boolean,
|
|
47465
47743
|
task: string,
|
|
47744
|
+
maxSteps: number = MAX_STEPS,
|
|
47466
47745
|
step: (start: boolean, messages: LLMMessage[]) => Promise<{
|
|
47467
47746
|
success: boolean;
|
|
47468
47747
|
failure: boolean;
|
|
@@ -47478,7 +47757,7 @@ async function reactLoop(
|
|
|
47478
47757
|
|
|
47479
47758
|
const messages: LLMMessage[] = [{ role: 'user', content: task }];
|
|
47480
47759
|
|
|
47481
|
-
for (let i = 0; i <
|
|
47760
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
47482
47761
|
const result = await step(true, messages);
|
|
47483
47762
|
|
|
47484
47763
|
if (result.failure) {
|
|
@@ -47791,7 +48070,7 @@ Answer:\`;
|
|
|
47791
48070
|
* RAG Pipeline for knowledge-based Q&A
|
|
47792
48071
|
*
|
|
47793
48072
|
* @flowWeaver workflow
|
|
47794
|
-
* @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT"]
|
|
48073
|
+
* @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT", "DESIGN_ASYNC_NO_ERROR_PATH"]
|
|
47795
48074
|
* @node generator generate [position: 200 0] [color: "purple"] [icon: "autoAwesome"]
|
|
47796
48075
|
* @position Start -300 0
|
|
47797
48076
|
* @position Exit 400 0
|
|
@@ -78203,6 +78482,71 @@ var errorMappers = {
|
|
|
78203
78482
|
code: error2.code
|
|
78204
78483
|
};
|
|
78205
78484
|
},
|
|
78485
|
+
// ── Design quality rules ─────────────────────────────────────────────
|
|
78486
|
+
DESIGN_ASYNC_NO_ERROR_PATH(error2) {
|
|
78487
|
+
const nodeName = error2.node || "unknown";
|
|
78488
|
+
return {
|
|
78489
|
+
title: "Async Node Missing Error Path",
|
|
78490
|
+
explanation: `Async node '${nodeName}' has no onFailure connection. Async operations (network calls, file I/O, AI calls) can fail, and errors will be silently lost.`,
|
|
78491
|
+
fix: `Connect ${nodeName}.onFailure to an error handler, retry node, or Exit.onFailure.`,
|
|
78492
|
+
code: error2.code
|
|
78493
|
+
};
|
|
78494
|
+
},
|
|
78495
|
+
DESIGN_SCOPE_NO_FAILURE_EXIT(error2) {
|
|
78496
|
+
const nodeName = error2.node || "unknown";
|
|
78497
|
+
return {
|
|
78498
|
+
title: "Scope Missing Failure Exit",
|
|
78499
|
+
explanation: `Scope node '${nodeName}' has no failure path out. If all iterations fail, execution stalls with no error surfaced upstream.`,
|
|
78500
|
+
fix: `Connect ${nodeName}.onFailure to an error handler or Exit.onFailure so scope failures propagate.`,
|
|
78501
|
+
code: error2.code
|
|
78502
|
+
};
|
|
78503
|
+
},
|
|
78504
|
+
DESIGN_UNBOUNDED_RETRY(error2) {
|
|
78505
|
+
const nodeName = error2.node || "unknown";
|
|
78506
|
+
return {
|
|
78507
|
+
title: "Unbounded Retry Loop",
|
|
78508
|
+
explanation: `Scope node '${nodeName}' looks like a retry loop but has no visible attempt limit input. This could loop indefinitely on persistent failures.`,
|
|
78509
|
+
fix: `Add a maxAttempts or retries input port to the node type, or use a counter to break out of the loop after N attempts.`,
|
|
78510
|
+
code: error2.code
|
|
78511
|
+
};
|
|
78512
|
+
},
|
|
78513
|
+
DESIGN_FANOUT_NO_FANIN(error2) {
|
|
78514
|
+
const nodeName = error2.node || "unknown";
|
|
78515
|
+
return {
|
|
78516
|
+
title: "Fan-Out Without Fan-In",
|
|
78517
|
+
explanation: `Node '${nodeName}' fans out to multiple step targets, but those paths never merge back to a shared downstream node. Data from parallel branches may be lost.`,
|
|
78518
|
+
fix: `Add a merge node downstream where the parallel branches converge, or wire them to a shared node before Exit.`,
|
|
78519
|
+
code: error2.code
|
|
78520
|
+
};
|
|
78521
|
+
},
|
|
78522
|
+
DESIGN_EXIT_DATA_UNREACHABLE(error2) {
|
|
78523
|
+
const quoted = extractQuoted(error2.message);
|
|
78524
|
+
const portName = quoted[0] || "unknown";
|
|
78525
|
+
return {
|
|
78526
|
+
title: "Exit Data Unreachable",
|
|
78527
|
+
explanation: `Exit port '${portName}' has no incoming data connection and no pull-execution node provides it. The workflow will return undefined for this output.`,
|
|
78528
|
+
fix: `Connect a node's data output to Exit.${portName}, or add a pull-execution node that computes this value on demand.`,
|
|
78529
|
+
code: error2.code
|
|
78530
|
+
};
|
|
78531
|
+
},
|
|
78532
|
+
DESIGN_PULL_CANDIDATE(error2) {
|
|
78533
|
+
const nodeName = error2.node || "unknown";
|
|
78534
|
+
return {
|
|
78535
|
+
title: "Pull Execution Candidate",
|
|
78536
|
+
explanation: `Node '${nodeName}' has no incoming step connection but its data outputs are consumed downstream. Without a step trigger or pullExecution, this node may never execute.`,
|
|
78537
|
+
fix: `Add [pullExecution: execute] to the @node annotation so the node runs on demand when downstream nodes read its output.`,
|
|
78538
|
+
code: error2.code
|
|
78539
|
+
};
|
|
78540
|
+
},
|
|
78541
|
+
DESIGN_PULL_UNUSED(error2) {
|
|
78542
|
+
const nodeName = error2.node || "unknown";
|
|
78543
|
+
return {
|
|
78544
|
+
title: "Unused Pull Execution",
|
|
78545
|
+
explanation: `Node '${nodeName}' is marked with pullExecution but no downstream node reads its data output. The node will never execute since pull execution requires a consumer.`,
|
|
78546
|
+
fix: `Connect a data output from '${nodeName}' to a downstream node, or remove the pullExecution config if the node isn't needed.`,
|
|
78547
|
+
code: error2.code
|
|
78548
|
+
};
|
|
78549
|
+
},
|
|
78206
78550
|
COERCE_TYPE_MISMATCH(error2) {
|
|
78207
78551
|
const coerceMatch = error2.message.match(/`as (\w+)`/);
|
|
78208
78552
|
const coerceType = coerceMatch?.[1] || "unknown";
|
|
@@ -100073,7 +100417,15 @@ var ERROR_HINTS = {
|
|
|
100073
100417
|
AGENT_UNGUARDED_TOOL_EXECUTOR: 'Add a human-approval node before the tool executor. Use fw_scaffold(template="human-approval") to create one',
|
|
100074
100418
|
AGENT_MISSING_MEMORY_IN_LOOP: 'Add a conversation-memory node inside the loop scope. Use fw_scaffold(template="conversation-memory") to create one',
|
|
100075
100419
|
AGENT_LLM_NO_FALLBACK: "Add a retry or fallback node between the LLM onFailure and Exit. Consider a second LLM provider as fallback",
|
|
100076
|
-
AGENT_TOOL_NO_OUTPUT_HANDLING: 'Use fw_modify(operation="addConnection") to wire tool output ports to downstream nodes'
|
|
100420
|
+
AGENT_TOOL_NO_OUTPUT_HANDLING: 'Use fw_modify(operation="addConnection") to wire tool output ports to downstream nodes',
|
|
100421
|
+
// Design quality rules
|
|
100422
|
+
DESIGN_ASYNC_NO_ERROR_PATH: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) or add a retry/error handler',
|
|
100423
|
+
DESIGN_SCOPE_NO_FAILURE_EXIT: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) to surface scope failures',
|
|
100424
|
+
DESIGN_UNBOUNDED_RETRY: "Add a maxAttempts or retries input port to the retry node type to bound the loop",
|
|
100425
|
+
DESIGN_FANOUT_NO_FANIN: "Add a merge node downstream where parallel branches converge before Exit",
|
|
100426
|
+
DESIGN_EXIT_DATA_UNREACHABLE: 'Use fw_modify(operation="addConnection") to connect a node output to this Exit port, or add a pull-execution node',
|
|
100427
|
+
DESIGN_PULL_CANDIDATE: "Add [pullExecution: execute] to the @node annotation so the node runs on demand",
|
|
100428
|
+
DESIGN_PULL_UNUSED: "Connect a data output from this node to a downstream consumer, or remove pullExecution"
|
|
100077
100429
|
};
|
|
100078
100430
|
function addHintsToItems(items, friendlyErrorFn) {
|
|
100079
100431
|
return items.map((item) => {
|
|
@@ -106731,7 +107083,7 @@ function displayInstalledPackage(pkg) {
|
|
|
106731
107083
|
// src/cli/index.ts
|
|
106732
107084
|
init_logger();
|
|
106733
107085
|
init_error_utils();
|
|
106734
|
-
var version2 = true ? "0.20.
|
|
107086
|
+
var version2 = true ? "0.20.6" : "0.0.0-dev";
|
|
106735
107087
|
var program2 = new Command();
|
|
106736
107088
|
program2.name("flow-weaver").description("Flow Weaver Annotations - Compile and validate workflow files").option("-v, --version", "Output the current version").option("--no-color", "Disable colors").option("--color", "Force colors").on("option:version", () => {
|
|
106737
107089
|
logger.banner(version2);
|
|
@@ -128,6 +128,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
|
|
|
128
128
|
* @flowWeaver nodeType
|
|
129
129
|
* @label Agent Loop
|
|
130
130
|
* @input userMessage [order:1] - User's input message
|
|
131
|
+
* @input [maxIterations] [order:2] - Maximum loop iterations (default: 10)
|
|
131
132
|
* @input success scope:iteration [order:0] - From LLM onSuccess
|
|
132
133
|
* @input failure scope:iteration [order:1] - From LLM onFailure
|
|
133
134
|
* @input llmResponse scope:iteration [order:2] - LLM response
|
|
@@ -142,6 +143,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
|
|
|
142
143
|
async function agentLoop(
|
|
143
144
|
execute: boolean,
|
|
144
145
|
userMessage: string,
|
|
146
|
+
maxIterations: number = MAX_ITERATIONS,
|
|
145
147
|
iteration: (start: boolean, state: AgentState) => Promise<{
|
|
146
148
|
success: boolean;
|
|
147
149
|
failure: boolean;
|
|
@@ -160,7 +162,7 @@ async function agentLoop(
|
|
|
160
162
|
terminated: false,
|
|
161
163
|
};
|
|
162
164
|
|
|
163
|
-
while (state.iteration <
|
|
165
|
+
while (state.iteration < maxIterations) {
|
|
164
166
|
const result = await iteration(true, state);
|
|
165
167
|
|
|
166
168
|
state.iteration++;
|
|
@@ -298,6 +300,7 @@ async function executeTools(
|
|
|
298
300
|
* @connect llm.onSuccess -> loop.success:iteration
|
|
299
301
|
* @connect llm.onFailure -> loop.failure:iteration
|
|
300
302
|
* @connect tools.messages -> loop.toolMessages:iteration
|
|
303
|
+
* @connect tools.onFailure -> loop.failure:iteration
|
|
301
304
|
* @connect loop.response -> Exit.response
|
|
302
305
|
* @connect loop.onSuccess -> Exit.onSuccess
|
|
303
306
|
* @connect loop.onFailure -> Exit.onFailure
|
|
@@ -145,7 +145,7 @@ Answer:\`;
|
|
|
145
145
|
* RAG Pipeline for knowledge-based Q&A
|
|
146
146
|
*
|
|
147
147
|
* @flowWeaver workflow
|
|
148
|
-
* @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT"]
|
|
148
|
+
* @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT", "DESIGN_ASYNC_NO_ERROR_PATH"]
|
|
149
149
|
* @node generator generate [position: 200 0] [color: "purple"] [icon: "autoAwesome"]
|
|
150
150
|
* @position Start -300 0
|
|
151
151
|
* @position Exit 400 0
|
|
@@ -97,6 +97,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
|
|
|
97
97
|
* @label ReAct Loop
|
|
98
98
|
* @input execute [order:0] - Execute
|
|
99
99
|
* @input task [order:1] - Task for the agent
|
|
100
|
+
* @input [maxSteps] [order:2] - Maximum reasoning steps (default: 10)
|
|
100
101
|
* @input success scope:step [order:0] - Iteration succeeded
|
|
101
102
|
* @input failure scope:step [order:1] - Iteration failed
|
|
102
103
|
* @input thought scope:step [order:2] - Agent's reasoning
|
|
@@ -112,6 +113,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
|
|
|
112
113
|
async function reactLoop(
|
|
113
114
|
execute: boolean,
|
|
114
115
|
task: string,
|
|
116
|
+
maxSteps: number = MAX_STEPS,
|
|
115
117
|
step: (start: boolean, messages: LLMMessage[]) => Promise<{
|
|
116
118
|
success: boolean;
|
|
117
119
|
failure: boolean;
|
|
@@ -127,7 +129,7 @@ async function reactLoop(
|
|
|
127
129
|
|
|
128
130
|
const messages: LLMMessage[] = [{ role: 'user', content: task }];
|
|
129
131
|
|
|
130
|
-
for (let i = 0; i <
|
|
132
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
131
133
|
const result = await step(true, messages);
|
|
132
134
|
|
|
133
135
|
if (result.failure) {
|
|
@@ -116,6 +116,7 @@ function aggregateResults(
|
|
|
116
116
|
* @connect iterator.results -> Exit.results
|
|
117
117
|
* @connect iterator.results -> aggregator.results
|
|
118
118
|
* @connect iterator.onSuccess -> aggregator.execute
|
|
119
|
+
* @connect iterator.onFailure -> aggregator.execute
|
|
119
120
|
* @connect aggregator.successCount -> Exit.successCount
|
|
120
121
|
* @connect aggregator.failedCount -> Exit.failedCount
|
|
121
122
|
* @connect aggregator.onSuccess -> Exit.onSuccess
|
|
@@ -12,7 +12,7 @@ export interface TValidationCodeDoc {
|
|
|
12
12
|
severity: 'error' | 'warning';
|
|
13
13
|
title: string;
|
|
14
14
|
description: string;
|
|
15
|
-
category: 'structural' | 'naming' | 'connection' | 'type' | 'node-ref' | 'graph' | 'data-flow' | 'agent';
|
|
15
|
+
category: 'structural' | 'naming' | 'connection' | 'type' | 'node-ref' | 'graph' | 'data-flow' | 'agent' | 'design';
|
|
16
16
|
}
|
|
17
17
|
export declare const VALIDATION_CODES: TValidationCodeDoc[];
|
|
18
18
|
//# sourceMappingURL=error-codes.d.ts.map
|
|
@@ -318,5 +318,55 @@ export const VALIDATION_CODES = [
|
|
|
318
318
|
description: 'Tool executor data outputs all unconnected',
|
|
319
319
|
category: 'agent',
|
|
320
320
|
},
|
|
321
|
+
// ── Design ──────────────────────────────────────────────────────────
|
|
322
|
+
{
|
|
323
|
+
code: 'DESIGN_ASYNC_NO_ERROR_PATH',
|
|
324
|
+
severity: 'warning',
|
|
325
|
+
title: 'Async Node Missing Error Path',
|
|
326
|
+
description: 'Async node has no onFailure connection',
|
|
327
|
+
category: 'design',
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
code: 'DESIGN_SCOPE_NO_FAILURE_EXIT',
|
|
331
|
+
severity: 'warning',
|
|
332
|
+
title: 'Scope Missing Failure Exit',
|
|
333
|
+
description: 'Scope node has no failure path out',
|
|
334
|
+
category: 'design',
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
code: 'DESIGN_UNBOUNDED_RETRY',
|
|
338
|
+
severity: 'warning',
|
|
339
|
+
title: 'Unbounded Retry Loop',
|
|
340
|
+
description: 'Retry scope has no visible attempt limit',
|
|
341
|
+
category: 'design',
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
code: 'DESIGN_FANOUT_NO_FANIN',
|
|
345
|
+
severity: 'warning',
|
|
346
|
+
title: 'Fan-Out Without Fan-In',
|
|
347
|
+
description: 'Fan-out to multiple step targets with no merge back',
|
|
348
|
+
category: 'design',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
code: 'DESIGN_EXIT_DATA_UNREACHABLE',
|
|
352
|
+
severity: 'warning',
|
|
353
|
+
title: 'Exit Data Unreachable',
|
|
354
|
+
description: 'Exit data port has no connection and no pull-execution provider',
|
|
355
|
+
category: 'design',
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
code: 'DESIGN_PULL_CANDIDATE',
|
|
359
|
+
severity: 'warning',
|
|
360
|
+
title: 'Pull Execution Candidate',
|
|
361
|
+
description: 'Node has no step trigger but its outputs are consumed downstream',
|
|
362
|
+
category: 'design',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
code: 'DESIGN_PULL_UNUSED',
|
|
366
|
+
severity: 'warning',
|
|
367
|
+
title: 'Unused Pull Execution',
|
|
368
|
+
description: 'Pull-execution node has no downstream consumers',
|
|
369
|
+
category: 'design',
|
|
370
|
+
},
|
|
321
371
|
];
|
|
322
372
|
//# sourceMappingURL=error-codes.js.map
|
package/dist/friendly-errors.js
CHANGED
|
@@ -505,6 +505,71 @@ const errorMappers = {
|
|
|
505
505
|
code: error.code,
|
|
506
506
|
};
|
|
507
507
|
},
|
|
508
|
+
// ── Design quality rules ─────────────────────────────────────────────
|
|
509
|
+
DESIGN_ASYNC_NO_ERROR_PATH(error) {
|
|
510
|
+
const nodeName = error.node || 'unknown';
|
|
511
|
+
return {
|
|
512
|
+
title: 'Async Node Missing Error Path',
|
|
513
|
+
explanation: `Async node '${nodeName}' has no onFailure connection. Async operations (network calls, file I/O, AI calls) can fail, and errors will be silently lost.`,
|
|
514
|
+
fix: `Connect ${nodeName}.onFailure to an error handler, retry node, or Exit.onFailure.`,
|
|
515
|
+
code: error.code,
|
|
516
|
+
};
|
|
517
|
+
},
|
|
518
|
+
DESIGN_SCOPE_NO_FAILURE_EXIT(error) {
|
|
519
|
+
const nodeName = error.node || 'unknown';
|
|
520
|
+
return {
|
|
521
|
+
title: 'Scope Missing Failure Exit',
|
|
522
|
+
explanation: `Scope node '${nodeName}' has no failure path out. If all iterations fail, execution stalls with no error surfaced upstream.`,
|
|
523
|
+
fix: `Connect ${nodeName}.onFailure to an error handler or Exit.onFailure so scope failures propagate.`,
|
|
524
|
+
code: error.code,
|
|
525
|
+
};
|
|
526
|
+
},
|
|
527
|
+
DESIGN_UNBOUNDED_RETRY(error) {
|
|
528
|
+
const nodeName = error.node || 'unknown';
|
|
529
|
+
return {
|
|
530
|
+
title: 'Unbounded Retry Loop',
|
|
531
|
+
explanation: `Scope node '${nodeName}' looks like a retry loop but has no visible attempt limit input. This could loop indefinitely on persistent failures.`,
|
|
532
|
+
fix: `Add a maxAttempts or retries input port to the node type, or use a counter to break out of the loop after N attempts.`,
|
|
533
|
+
code: error.code,
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
DESIGN_FANOUT_NO_FANIN(error) {
|
|
537
|
+
const nodeName = error.node || 'unknown';
|
|
538
|
+
return {
|
|
539
|
+
title: 'Fan-Out Without Fan-In',
|
|
540
|
+
explanation: `Node '${nodeName}' fans out to multiple step targets, but those paths never merge back to a shared downstream node. Data from parallel branches may be lost.`,
|
|
541
|
+
fix: `Add a merge node downstream where the parallel branches converge, or wire them to a shared node before Exit.`,
|
|
542
|
+
code: error.code,
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
DESIGN_EXIT_DATA_UNREACHABLE(error) {
|
|
546
|
+
const quoted = extractQuoted(error.message);
|
|
547
|
+
const portName = quoted[0] || 'unknown';
|
|
548
|
+
return {
|
|
549
|
+
title: 'Exit Data Unreachable',
|
|
550
|
+
explanation: `Exit port '${portName}' has no incoming data connection and no pull-execution node provides it. The workflow will return undefined for this output.`,
|
|
551
|
+
fix: `Connect a node's data output to Exit.${portName}, or add a pull-execution node that computes this value on demand.`,
|
|
552
|
+
code: error.code,
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
DESIGN_PULL_CANDIDATE(error) {
|
|
556
|
+
const nodeName = error.node || 'unknown';
|
|
557
|
+
return {
|
|
558
|
+
title: 'Pull Execution Candidate',
|
|
559
|
+
explanation: `Node '${nodeName}' has no incoming step connection but its data outputs are consumed downstream. Without a step trigger or pullExecution, this node may never execute.`,
|
|
560
|
+
fix: `Add [pullExecution: execute] to the @node annotation so the node runs on demand when downstream nodes read its output.`,
|
|
561
|
+
code: error.code,
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
DESIGN_PULL_UNUSED(error) {
|
|
565
|
+
const nodeName = error.node || 'unknown';
|
|
566
|
+
return {
|
|
567
|
+
title: 'Unused Pull Execution',
|
|
568
|
+
explanation: `Node '${nodeName}' is marked with pullExecution but no downstream node reads its data output. The node will never execute since pull execution requires a consumer.`,
|
|
569
|
+
fix: `Connect a data output from '${nodeName}' to a downstream node, or remove the pullExecution config if the node isn't needed.`,
|
|
570
|
+
code: error.code,
|
|
571
|
+
};
|
|
572
|
+
},
|
|
508
573
|
COERCE_TYPE_MISMATCH(error) {
|
|
509
574
|
const coerceMatch = error.message.match(/`as (\w+)`/);
|
|
510
575
|
const coerceType = coerceMatch?.[1] || 'unknown';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.20.
|
|
1
|
+
export declare const VERSION = "0.20.6";
|
|
2
2
|
//# sourceMappingURL=generated-version.d.ts.map
|
|
@@ -55,6 +55,14 @@ export const ERROR_HINTS = {
|
|
|
55
55
|
AGENT_MISSING_MEMORY_IN_LOOP: 'Add a conversation-memory node inside the loop scope. Use fw_scaffold(template="conversation-memory") to create one',
|
|
56
56
|
AGENT_LLM_NO_FALLBACK: 'Add a retry or fallback node between the LLM onFailure and Exit. Consider a second LLM provider as fallback',
|
|
57
57
|
AGENT_TOOL_NO_OUTPUT_HANDLING: 'Use fw_modify(operation="addConnection") to wire tool output ports to downstream nodes',
|
|
58
|
+
// Design quality rules
|
|
59
|
+
DESIGN_ASYNC_NO_ERROR_PATH: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) or add a retry/error handler',
|
|
60
|
+
DESIGN_SCOPE_NO_FAILURE_EXIT: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) to surface scope failures',
|
|
61
|
+
DESIGN_UNBOUNDED_RETRY: 'Add a maxAttempts or retries input port to the retry node type to bound the loop',
|
|
62
|
+
DESIGN_FANOUT_NO_FANIN: 'Add a merge node downstream where parallel branches converge before Exit',
|
|
63
|
+
DESIGN_EXIT_DATA_UNREACHABLE: 'Use fw_modify(operation="addConnection") to connect a node output to this Exit port, or add a pull-execution node',
|
|
64
|
+
DESIGN_PULL_CANDIDATE: 'Add [pullExecution: execute] to the @node annotation so the node runs on demand',
|
|
65
|
+
DESIGN_PULL_UNUSED: 'Connect a data output from this node to a downstream consumer, or remove pullExecution',
|
|
58
66
|
};
|
|
59
67
|
/**
|
|
60
68
|
* Enriches validation items with actionable hints from {@link ERROR_HINTS} and optional
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Quality Validation Rules
|
|
3
|
+
*
|
|
4
|
+
* Deterministic, AST-inspectable rules that catch common workflow design problems.
|
|
5
|
+
* A workflow can compile fine and still be poorly designed: missing error paths,
|
|
6
|
+
* unbounded retries, fan-out without fan-in, dead-end branches.
|
|
7
|
+
*
|
|
8
|
+
* All rules are warnings or info. None block compilation.
|
|
9
|
+
* Users can suppress any with @suppress.
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* 1. DESIGN_ASYNC_NO_ERROR_PATH - Async node has no onFailure connection
|
|
13
|
+
* 2. DESIGN_SCOPE_NO_FAILURE_EXIT - Scope has no failure path out
|
|
14
|
+
* 3. DESIGN_UNBOUNDED_RETRY - Retry scope has no visible attempt limit
|
|
15
|
+
* 4. DESIGN_FANOUT_NO_FANIN - Fan-out to step targets with no merge back
|
|
16
|
+
* 5. DESIGN_EXIT_DATA_UNREACHABLE - Exit data port has no connection and no pull-execution provider
|
|
17
|
+
* 6. DESIGN_PULL_CANDIDATE - Node with no incoming step but consumed data outputs
|
|
18
|
+
* 7. DESIGN_PULL_UNUSED - Node marked pullExecution but no downstream reads its output
|
|
19
|
+
*/
|
|
20
|
+
import type { TValidationRule } from '../ast/types.js';
|
|
21
|
+
/**
|
|
22
|
+
* Async nodes (network, disk, AI) can fail. If onFailure is unconnected,
|
|
23
|
+
* failures may be silently swallowed or crash the workflow.
|
|
24
|
+
*/
|
|
25
|
+
export declare const asyncNoErrorPathRule: TValidationRule;
|
|
26
|
+
/**
|
|
27
|
+
* A scope (retry/forEach) with no failure path out means if every iteration
|
|
28
|
+
* fails, execution stalls with no way to surface the error.
|
|
29
|
+
*/
|
|
30
|
+
export declare const scopeNoFailureExitRule: TValidationRule;
|
|
31
|
+
/**
|
|
32
|
+
* A scope used as a retry loop without a visible attempt limit could loop
|
|
33
|
+
* indefinitely. Check if the parent node type name/function suggests retry
|
|
34
|
+
* semantics but lacks a maxAttempts-like input or config.
|
|
35
|
+
*/
|
|
36
|
+
export declare const unboundedRetryRule: TValidationRule;
|
|
37
|
+
/**
|
|
38
|
+
* A node that fans out via step connections to multiple targets, but none of
|
|
39
|
+
* those paths merge back to a shared downstream node. Data from parallel
|
|
40
|
+
* branches may be lost.
|
|
41
|
+
*/
|
|
42
|
+
export declare const fanoutNoFaninRule: TValidationRule;
|
|
43
|
+
/**
|
|
44
|
+
* Extends UNREACHABLE_EXIT_PORT to consider pull execution. An exit data port
|
|
45
|
+
* is fine if a pull-executed node is wired to provide it, even without a
|
|
46
|
+
* step path.
|
|
47
|
+
*/
|
|
48
|
+
export declare const exitDataUnreachableRule: TValidationRule;
|
|
49
|
+
/**
|
|
50
|
+
* A node with no incoming step connections but data outputs consumed downstream
|
|
51
|
+
* is a candidate for pullExecution. Without it, the node may never execute.
|
|
52
|
+
*/
|
|
53
|
+
export declare const pullCandidateRule: TValidationRule;
|
|
54
|
+
/**
|
|
55
|
+
* A node marked with pullExecution but no downstream node reads its data output.
|
|
56
|
+
* The node will never execute since pull execution requires a consumer.
|
|
57
|
+
*/
|
|
58
|
+
export declare const pullUnusedRule: TValidationRule;
|
|
59
|
+
export declare const designValidationRules: TValidationRule[];
|
|
60
|
+
export declare function getDesignValidationRules(): TValidationRule[];
|
|
61
|
+
//# sourceMappingURL=design-rules.d.ts.map
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Quality Validation Rules
|
|
3
|
+
*
|
|
4
|
+
* Deterministic, AST-inspectable rules that catch common workflow design problems.
|
|
5
|
+
* A workflow can compile fine and still be poorly designed: missing error paths,
|
|
6
|
+
* unbounded retries, fan-out without fan-in, dead-end branches.
|
|
7
|
+
*
|
|
8
|
+
* All rules are warnings or info. None block compilation.
|
|
9
|
+
* Users can suppress any with @suppress.
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* 1. DESIGN_ASYNC_NO_ERROR_PATH - Async node has no onFailure connection
|
|
13
|
+
* 2. DESIGN_SCOPE_NO_FAILURE_EXIT - Scope has no failure path out
|
|
14
|
+
* 3. DESIGN_UNBOUNDED_RETRY - Retry scope has no visible attempt limit
|
|
15
|
+
* 4. DESIGN_FANOUT_NO_FANIN - Fan-out to step targets with no merge back
|
|
16
|
+
* 5. DESIGN_EXIT_DATA_UNREACHABLE - Exit data port has no connection and no pull-execution provider
|
|
17
|
+
* 6. DESIGN_PULL_CANDIDATE - Node with no incoming step but consumed data outputs
|
|
18
|
+
* 7. DESIGN_PULL_UNUSED - Node marked pullExecution but no downstream reads its output
|
|
19
|
+
*/
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
function resolveNodeType(ast, instance) {
|
|
24
|
+
return ast.nodeTypes.find((nt) => nt.name === instance.nodeType || nt.functionName === instance.nodeType);
|
|
25
|
+
}
|
|
26
|
+
function getOutgoing(ast, nodeId, portName) {
|
|
27
|
+
return ast.connections.filter((c) => {
|
|
28
|
+
if (c.from.node !== nodeId)
|
|
29
|
+
return false;
|
|
30
|
+
if (portName && c.from.port !== portName)
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function getIncoming(ast, nodeId, portName) {
|
|
36
|
+
return ast.connections.filter((c) => {
|
|
37
|
+
if (c.to.node !== nodeId)
|
|
38
|
+
return false;
|
|
39
|
+
if (portName && c.to.port !== portName)
|
|
40
|
+
return false;
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const STEP_PORTS = new Set(['execute', 'onSuccess', 'onFailure', 'start', 'success', 'failure']);
|
|
45
|
+
function isStepPort(portName, portDef) {
|
|
46
|
+
if (portDef?.dataType === 'STEP')
|
|
47
|
+
return true;
|
|
48
|
+
return STEP_PORTS.has(portName);
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Rule 1: Async Node Missing Error Path
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Async nodes (network, disk, AI) can fail. If onFailure is unconnected,
|
|
55
|
+
* failures may be silently swallowed or crash the workflow.
|
|
56
|
+
*/
|
|
57
|
+
export const asyncNoErrorPathRule = {
|
|
58
|
+
name: 'DESIGN_ASYNC_NO_ERROR_PATH',
|
|
59
|
+
validate(ast) {
|
|
60
|
+
const errors = [];
|
|
61
|
+
for (const instance of ast.instances) {
|
|
62
|
+
const nt = resolveNodeType(ast, instance);
|
|
63
|
+
if (!nt)
|
|
64
|
+
continue;
|
|
65
|
+
if (!nt.isAsync)
|
|
66
|
+
continue;
|
|
67
|
+
if (!nt.hasFailurePort)
|
|
68
|
+
continue;
|
|
69
|
+
const failureConns = getOutgoing(ast, instance.id, 'onFailure');
|
|
70
|
+
if (failureConns.length === 0) {
|
|
71
|
+
errors.push({
|
|
72
|
+
type: 'warning',
|
|
73
|
+
code: 'DESIGN_ASYNC_NO_ERROR_PATH',
|
|
74
|
+
message: `Async node '${instance.id}' has no onFailure connection. Async operations (network, disk, AI) can fail, and errors will be silently lost.`,
|
|
75
|
+
node: instance.id,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return errors;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Rule 2: Scope With No Failure Exit
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* A scope (retry/forEach) with no failure path out means if every iteration
|
|
87
|
+
* fails, execution stalls with no way to surface the error.
|
|
88
|
+
*/
|
|
89
|
+
export const scopeNoFailureExitRule = {
|
|
90
|
+
name: 'DESIGN_SCOPE_NO_FAILURE_EXIT',
|
|
91
|
+
validate(ast) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
for (const instance of ast.instances) {
|
|
94
|
+
const nt = resolveNodeType(ast, instance);
|
|
95
|
+
if (!nt)
|
|
96
|
+
continue;
|
|
97
|
+
// Only check nodes that define scopes
|
|
98
|
+
const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
|
|
99
|
+
if (scopeNames.length === 0)
|
|
100
|
+
continue;
|
|
101
|
+
// A scope parent needs an onFailure or failure port connected
|
|
102
|
+
if (!nt.hasFailurePort)
|
|
103
|
+
continue;
|
|
104
|
+
const failureConns = getOutgoing(ast, instance.id, 'onFailure');
|
|
105
|
+
// Also check 'failure' port used in some scope patterns
|
|
106
|
+
const failureConns2 = getOutgoing(ast, instance.id, 'failure');
|
|
107
|
+
if (failureConns.length === 0 && failureConns2.length === 0) {
|
|
108
|
+
errors.push({
|
|
109
|
+
type: 'warning',
|
|
110
|
+
code: 'DESIGN_SCOPE_NO_FAILURE_EXIT',
|
|
111
|
+
message: `Scope node '${instance.id}' has no failure path out. If all iterations fail, execution stalls with no error surfaced.`,
|
|
112
|
+
node: instance.id,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return errors;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Rule 3: Unbounded Retry
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/**
|
|
123
|
+
* A scope used as a retry loop without a visible attempt limit could loop
|
|
124
|
+
* indefinitely. Check if the parent node type name/function suggests retry
|
|
125
|
+
* semantics but lacks a maxAttempts-like input or config.
|
|
126
|
+
*/
|
|
127
|
+
export const unboundedRetryRule = {
|
|
128
|
+
name: 'DESIGN_UNBOUNDED_RETRY',
|
|
129
|
+
validate(ast) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
const retryPatterns = /retry|repeat|loop|poll|backoff/i;
|
|
132
|
+
for (const instance of ast.instances) {
|
|
133
|
+
const nt = resolveNodeType(ast, instance);
|
|
134
|
+
if (!nt)
|
|
135
|
+
continue;
|
|
136
|
+
const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
|
|
137
|
+
if (scopeNames.length === 0)
|
|
138
|
+
continue;
|
|
139
|
+
// Only flag nodes that look like retry patterns
|
|
140
|
+
const nameHint = `${nt.name} ${nt.functionName} ${nt.label ?? ''}`;
|
|
141
|
+
if (!retryPatterns.test(nameHint))
|
|
142
|
+
continue;
|
|
143
|
+
// Check for a maxAttempts/retries/limit input port
|
|
144
|
+
const limitInputs = Object.keys(nt.inputs).filter((p) => /max|limit|attempts|retries|count/i.test(p));
|
|
145
|
+
if (limitInputs.length === 0) {
|
|
146
|
+
errors.push({
|
|
147
|
+
type: 'warning',
|
|
148
|
+
code: 'DESIGN_UNBOUNDED_RETRY',
|
|
149
|
+
message: `Scope node '${instance.id}' appears to be a retry loop but has no visible attempt limit input. This could loop indefinitely.`,
|
|
150
|
+
node: instance.id,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return errors;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Rule 4: Fan-Out Without Fan-In
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* A node that fans out via step connections to multiple targets, but none of
|
|
162
|
+
* those paths merge back to a shared downstream node. Data from parallel
|
|
163
|
+
* branches may be lost.
|
|
164
|
+
*/
|
|
165
|
+
export const fanoutNoFaninRule = {
|
|
166
|
+
name: 'DESIGN_FANOUT_NO_FANIN',
|
|
167
|
+
validate(ast) {
|
|
168
|
+
const errors = [];
|
|
169
|
+
for (const instance of ast.instances) {
|
|
170
|
+
const nt = resolveNodeType(ast, instance);
|
|
171
|
+
// Find step output connections (excluding data ports)
|
|
172
|
+
const stepOutConns = getOutgoing(ast, instance.id).filter((c) => {
|
|
173
|
+
if (nt) {
|
|
174
|
+
const portDef = nt.outputs[c.from.port];
|
|
175
|
+
return isStepPort(c.from.port, portDef);
|
|
176
|
+
}
|
|
177
|
+
return isStepPort(c.from.port);
|
|
178
|
+
});
|
|
179
|
+
// Get unique step targets (excluding Exit, which is a natural terminus)
|
|
180
|
+
const stepTargets = [...new Set(stepOutConns.map((c) => c.to.node))].filter((n) => n !== 'Exit');
|
|
181
|
+
// Need at least 3 targets to be a meaningful fan-out (2 is just success/failure branching)
|
|
182
|
+
if (stepTargets.length < 3)
|
|
183
|
+
continue;
|
|
184
|
+
// For each target, walk forward to find all reachable nodes
|
|
185
|
+
const reachableSets = stepTargets.map((target) => getReachableNodes(ast, target));
|
|
186
|
+
// Check if any node appears in multiple reachable sets (fan-in point)
|
|
187
|
+
const allNodes = new Set();
|
|
188
|
+
let hasMerge = false;
|
|
189
|
+
for (const reachable of reachableSets) {
|
|
190
|
+
for (const node of reachable) {
|
|
191
|
+
if (allNodes.has(node)) {
|
|
192
|
+
hasMerge = true;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (hasMerge)
|
|
197
|
+
break;
|
|
198
|
+
for (const node of reachable) {
|
|
199
|
+
allNodes.add(node);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Also check if any target has a merge strategy on its input ports
|
|
203
|
+
if (!hasMerge) {
|
|
204
|
+
for (const target of stepTargets) {
|
|
205
|
+
const targetInst = ast.instances.find((i) => i.id === target);
|
|
206
|
+
if (!targetInst)
|
|
207
|
+
continue;
|
|
208
|
+
const targetNt = resolveNodeType(ast, targetInst);
|
|
209
|
+
if (!targetNt)
|
|
210
|
+
continue;
|
|
211
|
+
const hasMergePort = Object.values(targetNt.inputs).some((p) => p.mergeStrategy);
|
|
212
|
+
if (hasMergePort) {
|
|
213
|
+
hasMerge = true;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!hasMerge) {
|
|
219
|
+
errors.push({
|
|
220
|
+
type: 'warning',
|
|
221
|
+
code: 'DESIGN_FANOUT_NO_FANIN',
|
|
222
|
+
message: `Node '${instance.id}' fans out to ${stepTargets.length} step targets (${stepTargets.join(', ')}) but those paths never merge back. Data from parallel branches may be lost.`,
|
|
223
|
+
node: instance.id,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return errors;
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
/** Walk forward from a node to find all reachable nodes (BFS via step connections). */
|
|
231
|
+
function getReachableNodes(ast, startNode) {
|
|
232
|
+
const visited = new Set();
|
|
233
|
+
const queue = [startNode];
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const current = queue.shift();
|
|
236
|
+
if (visited.has(current))
|
|
237
|
+
continue;
|
|
238
|
+
visited.add(current);
|
|
239
|
+
for (const conn of ast.connections) {
|
|
240
|
+
if (conn.from.node === current && !visited.has(conn.to.node)) {
|
|
241
|
+
queue.push(conn.to.node);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return visited;
|
|
246
|
+
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Rule 5: Exit Data Port Unreachable (Pull-Execution Aware)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
/**
|
|
251
|
+
* Extends UNREACHABLE_EXIT_PORT to consider pull execution. An exit data port
|
|
252
|
+
* is fine if a pull-executed node is wired to provide it, even without a
|
|
253
|
+
* step path.
|
|
254
|
+
*/
|
|
255
|
+
export const exitDataUnreachableRule = {
|
|
256
|
+
name: 'DESIGN_EXIT_DATA_UNREACHABLE',
|
|
257
|
+
validate(ast) {
|
|
258
|
+
const errors = [];
|
|
259
|
+
const stepPorts = new Set(['onSuccess', 'onFailure']);
|
|
260
|
+
for (const [portName, portDef] of Object.entries(ast.exitPorts)) {
|
|
261
|
+
if (portDef.dataType === 'STEP')
|
|
262
|
+
continue; // Step ports handled by core validator
|
|
263
|
+
const incoming = getIncoming(ast, 'Exit', portName);
|
|
264
|
+
if (incoming.length > 0)
|
|
265
|
+
continue; // Has a direct connection, fine
|
|
266
|
+
// Check if a pull-executed node provides this port
|
|
267
|
+
const hasPullProvider = ast.instances.some((inst) => {
|
|
268
|
+
const isPull = inst.config?.pullExecution ||
|
|
269
|
+
resolveNodeType(ast, inst)?.defaultConfig?.pullExecution;
|
|
270
|
+
if (!isPull)
|
|
271
|
+
return false;
|
|
272
|
+
// Check if this pull node has a data output connected to Exit.<portName>
|
|
273
|
+
return getOutgoing(ast, inst.id).some((c) => c.to.node === 'Exit' && c.to.port === portName);
|
|
274
|
+
});
|
|
275
|
+
if (!hasPullProvider) {
|
|
276
|
+
errors.push({
|
|
277
|
+
type: 'warning',
|
|
278
|
+
code: 'DESIGN_EXIT_DATA_UNREACHABLE',
|
|
279
|
+
message: `Exit port '${portName}' has no incoming connection and no pull-execution node provides it. The output will be undefined.`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return errors;
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Rule 6: Pull Execution Candidate
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
/**
|
|
290
|
+
* A node with no incoming step connections but data outputs consumed downstream
|
|
291
|
+
* is a candidate for pullExecution. Without it, the node may never execute.
|
|
292
|
+
*/
|
|
293
|
+
export const pullCandidateRule = {
|
|
294
|
+
name: 'DESIGN_PULL_CANDIDATE',
|
|
295
|
+
validate(ast) {
|
|
296
|
+
const errors = [];
|
|
297
|
+
for (const instance of ast.instances) {
|
|
298
|
+
const nt = resolveNodeType(ast, instance);
|
|
299
|
+
if (!nt)
|
|
300
|
+
continue;
|
|
301
|
+
// Skip if already has pullExecution configured
|
|
302
|
+
if (instance.config?.pullExecution || nt.defaultConfig?.pullExecution)
|
|
303
|
+
continue;
|
|
304
|
+
// Skip expression nodes (they are already pull-executed by nature)
|
|
305
|
+
if (nt.expression)
|
|
306
|
+
continue;
|
|
307
|
+
// Check for incoming step connections
|
|
308
|
+
const incomingStep = getIncoming(ast, instance.id).filter((c) => {
|
|
309
|
+
const portDef = nt.inputs[c.to.port];
|
|
310
|
+
return isStepPort(c.to.port, portDef);
|
|
311
|
+
});
|
|
312
|
+
if (incomingStep.length > 0)
|
|
313
|
+
continue; // Has step trigger, not a candidate
|
|
314
|
+
// Check if any data outputs are consumed downstream
|
|
315
|
+
const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
|
|
316
|
+
const hasConsumedOutput = dataOutputs.some((port) => getOutgoing(ast, instance.id, port).length > 0);
|
|
317
|
+
if (hasConsumedOutput) {
|
|
318
|
+
errors.push({
|
|
319
|
+
type: 'warning',
|
|
320
|
+
code: 'DESIGN_PULL_CANDIDATE',
|
|
321
|
+
message: `Node '${instance.id}' has no incoming step connection but its data outputs are consumed downstream. Consider adding [pullExecution: execute] so it executes on demand.`,
|
|
322
|
+
node: instance.id,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return errors;
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Rule 7: Unused Pull Execution
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
/**
|
|
333
|
+
* A node marked with pullExecution but no downstream node reads its data output.
|
|
334
|
+
* The node will never execute since pull execution requires a consumer.
|
|
335
|
+
*/
|
|
336
|
+
export const pullUnusedRule = {
|
|
337
|
+
name: 'DESIGN_PULL_UNUSED',
|
|
338
|
+
validate(ast) {
|
|
339
|
+
const errors = [];
|
|
340
|
+
for (const instance of ast.instances) {
|
|
341
|
+
const nt = resolveNodeType(ast, instance);
|
|
342
|
+
if (!nt)
|
|
343
|
+
continue;
|
|
344
|
+
const isPull = instance.config?.pullExecution || nt.defaultConfig?.pullExecution;
|
|
345
|
+
if (!isPull)
|
|
346
|
+
continue;
|
|
347
|
+
// Check if any data output ports are connected
|
|
348
|
+
const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
|
|
349
|
+
const hasConnectedOutput = dataOutputs.some((port) => getOutgoing(ast, instance.id, port).length > 0);
|
|
350
|
+
if (!hasConnectedOutput) {
|
|
351
|
+
errors.push({
|
|
352
|
+
type: 'warning',
|
|
353
|
+
code: 'DESIGN_PULL_UNUSED',
|
|
354
|
+
message: `Node '${instance.id}' is marked with pullExecution but no downstream node reads its data output. It will never execute.`,
|
|
355
|
+
node: instance.id,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return errors;
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Public API
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
export const designValidationRules = [
|
|
366
|
+
asyncNoErrorPathRule,
|
|
367
|
+
scopeNoFailureExitRule,
|
|
368
|
+
unboundedRetryRule,
|
|
369
|
+
fanoutNoFaninRule,
|
|
370
|
+
exitDataUnreachableRule,
|
|
371
|
+
pullCandidateRule,
|
|
372
|
+
pullUnusedRule,
|
|
373
|
+
];
|
|
374
|
+
export function getDesignValidationRules() {
|
|
375
|
+
return designValidationRules;
|
|
376
|
+
}
|
|
377
|
+
//# sourceMappingURL=design-rules.js.map
|
|
@@ -631,6 +631,73 @@ These codes apply to AI agent workflows that use LLM, tool-executor, and memory
|
|
|
631
631
|
>
|
|
632
632
|
> **What to do:** Connect the tool executor's data output ports (e.g., `result`) to downstream nodes that consume the data.
|
|
633
633
|
|
|
634
|
+
### Design Quality Rules
|
|
635
|
+
|
|
636
|
+
These rules detect common workflow design problems that compile fine but indicate poor structure. All are warnings or info-level, suppressible with `@suppress`.
|
|
637
|
+
|
|
638
|
+
#### DESIGN_ASYNC_NO_ERROR_PATH (warning)
|
|
639
|
+
|
|
640
|
+
| Field | Value |
|
|
641
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
642
|
+
| Severity | Warning |
|
|
643
|
+
| Meaning | An async node has no onFailure connection. Async operations (network calls, file I/O, AI calls) can fail, and errors will be silently lost. |
|
|
644
|
+
| Common Causes | Adding an async node and connecting onSuccess but forgetting to wire onFailure. |
|
|
645
|
+
| Fix | Connect the node's `onFailure` port to an error handler, retry node, or `Exit.onFailure`. |
|
|
646
|
+
|
|
647
|
+
#### DESIGN_SCOPE_NO_FAILURE_EXIT (warning)
|
|
648
|
+
|
|
649
|
+
| Field | Value |
|
|
650
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
651
|
+
| Severity | Warning |
|
|
652
|
+
| Meaning | A scope node (retry/forEach) has no failure path out. If all iterations fail, execution stalls with no error surfaced upstream. |
|
|
653
|
+
| Common Causes | Creating a forEach or retry scope and only wiring the success path. |
|
|
654
|
+
| Fix | Connect the scope node's `onFailure` port to propagate errors. |
|
|
655
|
+
|
|
656
|
+
#### DESIGN_UNBOUNDED_RETRY (warning)
|
|
657
|
+
|
|
658
|
+
| Field | Value |
|
|
659
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
660
|
+
| Severity | Warning |
|
|
661
|
+
| Meaning | A scope node with retry/loop semantics (detected by name) has no visible attempt limit input. This could loop indefinitely on persistent failures. |
|
|
662
|
+
| Common Causes | Creating a custom retry loop without a maxAttempts or retries parameter. |
|
|
663
|
+
| Fix | Add a `maxAttempts` or `retries` input port to the node type, or use a counter to break out. |
|
|
664
|
+
|
|
665
|
+
#### DESIGN_FANOUT_NO_FANIN (warning)
|
|
666
|
+
|
|
667
|
+
| Field | Value |
|
|
668
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
669
|
+
| Severity | Warning |
|
|
670
|
+
| Meaning | A node fans out to 3+ step targets, but those paths never converge to a shared downstream node. Data from parallel branches may be lost. |
|
|
671
|
+
| Common Causes | Dispatching work to multiple parallel branches without a merge point before Exit. |
|
|
672
|
+
| Fix | Add a merge node downstream where parallel branches converge, or use a merge strategy on the target port. |
|
|
673
|
+
|
|
674
|
+
#### DESIGN_EXIT_DATA_UNREACHABLE (warning)
|
|
675
|
+
|
|
676
|
+
| Field | Value |
|
|
677
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
678
|
+
| Severity | Warning |
|
|
679
|
+
| Meaning | An Exit data port has no incoming connection and no pull-execution node provides it. Extends UNREACHABLE_EXIT_PORT with pull-execution awareness. |
|
|
680
|
+
| Common Causes | Declaring an exit port but never connecting any node output to it, and no pull-execution node wires to it. |
|
|
681
|
+
| Fix | Connect a node's data output to the Exit port, or add a pull-execution node that computes the value on demand. |
|
|
682
|
+
|
|
683
|
+
#### DESIGN_PULL_CANDIDATE (warning)
|
|
684
|
+
|
|
685
|
+
| Field | Value |
|
|
686
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
687
|
+
| Severity | Warning |
|
|
688
|
+
| Meaning | A node has no incoming step connection but its data outputs are consumed downstream. Without pullExecution, the node may never execute. |
|
|
689
|
+
| Common Causes | Adding a node and connecting its data outputs to downstream nodes but forgetting a step trigger or pullExecution config. |
|
|
690
|
+
| Fix | Add `[pullExecution: execute]` to the `@node` annotation so the node runs on demand. |
|
|
691
|
+
|
|
692
|
+
#### DESIGN_PULL_UNUSED (warning)
|
|
693
|
+
|
|
694
|
+
| Field | Value |
|
|
695
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
696
|
+
| Severity | Warning |
|
|
697
|
+
| Meaning | A node is marked with pullExecution but no downstream node reads its data output. Pull execution requires a consumer to trigger. |
|
|
698
|
+
| Common Causes | Setting pullExecution on a node but then not connecting any of its data outputs. |
|
|
699
|
+
| Fix | Connect a data output to a downstream node, or remove the pullExecution config if the node isn't needed. |
|
|
700
|
+
|
|
634
701
|
---
|
|
635
702
|
|
|
636
703
|
## Quick Reference: Error Severity Summary
|
|
@@ -690,4 +757,11 @@ These codes apply to AI agent workflows that use LLM, tool-executor, and memory
|
|
|
690
757
|
| AGENT_MISSING_MEMORY_IN_LOOP | Loop has LLM but no conversation memory node |
|
|
691
758
|
| AGENT_LLM_NO_FALLBACK | LLM onFailure routes directly to Exit |
|
|
692
759
|
| AGENT_TOOL_NO_OUTPUT_HANDLING | Tool executor data outputs all unconnected |
|
|
760
|
+
| DESIGN_ASYNC_NO_ERROR_PATH | Async node has no onFailure connection |
|
|
761
|
+
| DESIGN_SCOPE_NO_FAILURE_EXIT | Scope node has no failure path out |
|
|
762
|
+
| DESIGN_UNBOUNDED_RETRY | Retry scope has no visible attempt limit |
|
|
763
|
+
| DESIGN_FANOUT_NO_FANIN | Fan-out to multiple step targets with no merge back |
|
|
764
|
+
| DESIGN_EXIT_DATA_UNREACHABLE | Exit data port has no connection and no pull-execution provider |
|
|
765
|
+
| DESIGN_PULL_CANDIDATE | Node has no step trigger but its outputs are consumed downstream |
|
|
766
|
+
| DESIGN_PULL_UNUSED | Pull-execution node has no downstream consumers |
|
|
693
767
|
<!-- AUTO:END warning_summary_table -->
|
package/package.json
CHANGED