From 21670c90d43e65b31c960d48c37c10d0992b9e2e Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 08:13:08 +1030 Subject: [PATCH 1/6] refactor: extract evaluate_rules() into operators.py for reuse Move the rule evaluation loop from switch.py into a shared evaluate_rules() function in operators.py with two modes: - first_match: returns first matching rule ID (switch behavior) - all: evaluates every rule, returns list of results (for assertion/filter) Switch component now delegates to evaluate_rules(mode="first_match"). Co-Authored-By: Claude Opus 4.6 --- platform/components/operators.py | 51 ++++++++++++++++++++++++++++++++ platform/components/switch.py | 15 ++-------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/platform/components/operators.py b/platform/components/operators.py index 42c4240b..2868b86e 100644 --- a/platform/components/operators.py +++ b/platform/components/operators.py @@ -101,3 +101,54 @@ def _resolve_field(path: str, state: dict): else: return None return current + + +def evaluate_rules( + rules: list[dict], + state: dict, + mode: str = "first_match", +) -> str | list[dict]: + """Evaluate a list of rules against workflow state. + + Args: + rules: List of rule dicts with keys: id, field, operator, value. + state: The workflow state dict to evaluate against. + mode: "first_match" returns the first matching rule's id (str). + "all" evaluates every rule and returns a list of result dicts. + + Returns: + mode="first_match": The matching rule's id (str), or "" if none match. + mode="all": List of {"rule_id": str, "passed": bool, "actual_value": any}. + """ + if mode == "all": + results = [] + for rule in rules: + field_path = rule.get("field", "") + operator = rule.get("operator", "equals") + value = rule.get("value", "") + rule_id = rule.get("id", "") + + field_val = _resolve_field(field_path, state) + op_fn = OPERATORS.get(operator) + passed = bool(op_fn(field_val, value)) if op_fn else False + + results.append({ + "rule_id": rule_id, + "passed": passed, + "actual_value": field_val, + }) + return results + + # mode="first_match" (default) + for rule in rules: + field_path = rule.get("field", "") + operator = rule.get("operator", "equals") + value = rule.get("value", "") + rule_id = rule.get("id", "") + + field_val = _resolve_field(field_path, state) + op_fn = OPERATORS.get(operator) + if op_fn and op_fn(field_val, value): + return rule_id + + return "" diff --git a/platform/components/switch.py b/platform/components/switch.py index b087dbc6..efa05bc4 100644 --- a/platform/components/switch.py +++ b/platform/components/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from components import register -from components.operators import OPERATORS, UNARY_OPERATORS, _resolve_field +from components.operators import _resolve_field, evaluate_rules @register("switch") @@ -17,18 +17,7 @@ def switch_factory(node): enable_fallback = extra.get("enable_fallback", False) def switch_node(state: dict) -> dict: - route = "" - for rule in rules: - field_path = rule.get("field", "") - operator = rule.get("operator", "equals") - value = rule.get("value", "") - rule_id = rule.get("id", "") - - field_val = _resolve_field(field_path, state) - op_fn = OPERATORS.get(operator) - if op_fn and op_fn(field_val, value): - route = rule_id - break + route = evaluate_rules(rules, state, mode="first_match") if not route and enable_fallback: route = "__other__" From 73d593c9fb25a6aa8ce7b93aa077eefb59958218 Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 08:16:02 +1030 Subject: [PATCH 2/6] feat: add assertion node component with pass/fail routing New assertion component evaluates ALL rules against state and routes to "pass" or "fail" based on whether every check succeeds. Registered in all backend layers: - components/assertion.py with @register("assertion") - components/__init__.py import - models/node.py polymorphic_identity - schemas/node.py ComponentTypeStr Literal - schemas/node_type_defs.py with Logic category, input/output ports - services/dsl_compiler.py STEP_TYPE_MAP Includes 8 tests covering: all pass, one fail, mixed results, empty rules, missing field, nested field resolution, and output format. Co-Authored-By: Claude Opus 4.6 --- platform/components/__init__.py | 1 + platform/components/assertion.py | 43 ++++++++++ platform/models/node.py | 4 + platform/schemas/node.py | 1 + platform/schemas/node_type_defs.py | 12 +++ platform/services/dsl_compiler.py | 1 + platform/tests/test_assertion.py | 121 +++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+) create mode 100644 platform/components/assertion.py create mode 100644 platform/tests/test_assertion.py diff --git a/platform/components/__init__.py b/platform/components/__init__.py index f295dfb0..f60a1d01 100644 --- a/platform/components/__init__.py +++ b/platform/components/__init__.py @@ -31,6 +31,7 @@ def get_component_factory(component_type: str): from components import ( # noqa: E402, F401 agent, ai_model, + assertion, categorizer, code, control_flow, diff --git a/platform/components/assertion.py b/platform/components/assertion.py new file mode 100644 index 00000000..b9d79b71 --- /dev/null +++ b/platform/components/assertion.py @@ -0,0 +1,43 @@ +"""Assertion component — evaluates all rules and routes pass/fail.""" + +from __future__ import annotations + +from components import register +from components.operators import evaluate_rules + + +@register("assertion") +def assertion_factory(node): + """Build an assertion graph node that checks all rules against state.""" + extra = node.component_config.extra_config + rules = extra.get("rules", []) + + def assertion_node(state: dict) -> dict: + if not rules: + return { + "_route": "pass", + "output": {"passed": True, "results": []}, + } + + results = evaluate_rules(rules, state, mode="all") + + checks = [] + for rule, result in zip(rules, results): + checks.append({ + "check": f"{rule.get('field', '')} {rule.get('operator', 'equals')} {rule.get('value', '')}", + "passed": result["passed"], + "actual": result["actual_value"], + "expected": rule.get("value", ""), + }) + + all_passed = all(r["passed"] for r in results) + + return { + "_route": "pass" if all_passed else "fail", + "output": { + "passed": all_passed, + "results": checks, + }, + } + + return assertion_node diff --git a/platform/models/node.py b/platform/models/node.py index d7116a93..330a50a8 100644 --- a/platform/models/node.py +++ b/platform/models/node.py @@ -125,6 +125,10 @@ class _SwitchConfig(BaseComponentConfig): __mapper_args__ = {"polymorphic_identity": "switch"} +class _AssertionConfig(BaseComponentConfig): + __mapper_args__ = {"polymorphic_identity": "assertion"} + + class CodeComponentConfig(BaseComponentConfig): """Config for code-type components.""" __mapper_args__ = {"polymorphic_identity": "code"} diff --git a/platform/schemas/node.py b/platform/schemas/node.py index d53ba7c3..5b76b915 100644 --- a/platform/schemas/node.py +++ b/platform/schemas/node.py @@ -13,6 +13,7 @@ "agent", "deep_agent", "switch", + "assertion", "run_command", "get_totp_code", "platform_api", diff --git a/platform/schemas/node_type_defs.py b/platform/schemas/node_type_defs.py index 9bf06e8f..627160a4 100644 --- a/platform/schemas/node_type_defs.py +++ b/platform/schemas/node_type_defs.py @@ -458,6 +458,18 @@ outputs=[PortDefinition(name="route", data_type=DataType.STRING)], )) +register_node_type(NodeTypeSpec( + component_type="assertion", + display_name="Assertion", + description="Evaluates all rules against state and routes pass/fail", + category="logic", + inputs=[PortDefinition(name="input", data_type=DataType.ANY, required=True)], + outputs=[ + PortDefinition(name="output", data_type=DataType.OBJECT, description="Results with passed flag and per-rule details"), + PortDefinition(name="route", data_type=DataType.STRING, description="'pass' or 'fail'"), + ], +)) + register_node_type(NodeTypeSpec( component_type="code", display_name="Code", diff --git a/platform/services/dsl_compiler.py b/platform/services/dsl_compiler.py index db348778..005adcaf 100644 --- a/platform/services/dsl_compiler.py +++ b/platform/services/dsl_compiler.py @@ -44,6 +44,7 @@ "agent": "agent", "deep_agent": "deep_agent", "switch": "switch", + "assertion": "assertion", "loop": "loop", "workflow": "workflow", "human": "human_confirmation", diff --git a/platform/tests/test_assertion.py b/platform/tests/test_assertion.py new file mode 100644 index 00000000..fbc1852e --- /dev/null +++ b/platform/tests/test_assertion.py @@ -0,0 +1,121 @@ +"""Tests for assertion component — rule evaluation with pass/fail routing.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from components.assertion import assertion_factory + + +def _make_node(extra_config: dict): + """Build a mock node with the given extra_config.""" + node = MagicMock() + node.component_config.extra_config = extra_config + return node + + +class TestAssertionComponent: + def test_all_rules_pass(self): + node = _make_node({ + "rules": [ + {"id": "r1", "field": "status", "operator": "equals", "value": "active"}, + {"id": "r2", "field": "count", "operator": "gt", "value": "0"}, + ], + }) + fn = assertion_factory(node) + result = fn({"status": "active", "count": 5}) + + assert result["_route"] == "pass" + assert result["output"]["passed"] is True + assert len(result["output"]["results"]) == 2 + assert all(r["passed"] for r in result["output"]["results"]) + + def test_one_rule_fails(self): + node = _make_node({ + "rules": [ + {"id": "r1", "field": "status", "operator": "equals", "value": "active"}, + {"id": "r2", "field": "status", "operator": "equals", "value": "deleted"}, + ], + }) + fn = assertion_factory(node) + result = fn({"status": "active"}) + + assert result["_route"] == "fail" + assert result["output"]["passed"] is False + assert result["output"]["results"][0]["passed"] is True + assert result["output"]["results"][1]["passed"] is False + + def test_multiple_rules_mixed_results(self): + node = _make_node({ + "rules": [ + {"id": "r1", "field": "name", "operator": "contains", "value": "foo"}, + {"id": "r2", "field": "age", "operator": "gt", "value": "18"}, + {"id": "r3", "field": "role", "operator": "equals", "value": "admin"}, + ], + }) + fn = assertion_factory(node) + result = fn({"name": "foobar", "age": 10, "role": "admin"}) + + assert result["_route"] == "fail" + assert result["output"]["passed"] is False + results = result["output"]["results"] + assert results[0]["passed"] is True # name contains foo + assert results[1]["passed"] is False # age not > 18 + assert results[2]["passed"] is True # role equals admin + + def test_empty_rules_passes(self): + node = _make_node({"rules": []}) + fn = assertion_factory(node) + result = fn({"anything": "value"}) + + assert result["_route"] == "pass" + assert result["output"]["passed"] is True + assert result["output"]["results"] == [] + + def test_no_rules_key_passes(self): + node = _make_node({}) + fn = assertion_factory(node) + result = fn({"anything": "value"}) + + assert result["_route"] == "pass" + assert result["output"]["passed"] is True + + def test_missing_field_fails_gracefully(self): + node = _make_node({ + "rules": [ + {"id": "r1", "field": "nonexistent", "operator": "equals", "value": "something"}, + ], + }) + fn = assertion_factory(node) + result = fn({"other_field": "value"}) + + assert result["_route"] == "fail" + assert result["output"]["passed"] is False + assert result["output"]["results"][0]["passed"] is False + assert result["output"]["results"][0]["actual"] is None + + def test_nested_field_resolution(self): + node = _make_node({ + "rules": [ + {"id": "r1", "field": "node_outputs.agent_1.output", "operator": "contains", "value": "hello"}, + ], + }) + fn = assertion_factory(node) + result = fn({"node_outputs": {"agent_1": {"output": "hello world"}}}) + + assert result["_route"] == "pass" + assert result["output"]["passed"] is True + + def test_check_description_format(self): + node = _make_node({ + "rules": [ + {"id": "r1", "field": "status", "operator": "equals", "value": "ok"}, + ], + }) + fn = assertion_factory(node) + result = fn({"status": "ok"}) + + check = result["output"]["results"][0] + assert check["check"] == "status equals ok" + assert check["expected"] == "ok" + assert check["actual"] == "ok" From 0359109e324a91dfcaeb61cf298c50be51f358c9 Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 08:17:00 +1030 Subject: [PATCH 3/6] refactor(frontend): unify Rule types, extract RuleEditor component Create base Rule interface extended by SwitchRule (with label). Extract shared rule editing UI from NodeDetailsPanel into reusable RuleEditor component used by both switch and filter sections. Add assertion to ComponentType and NodePalette ICONS for upcoming assertion node. Co-Authored-By: Claude Opus 4.6 --- .../workflows/components/NodeDetailsPanel.tsx | 263 ++---------------- .../workflows/components/NodePalette.tsx | 3 +- .../workflows/components/RuleEditor.tsx | 248 +++++++++++++++++ platform/frontend/src/types/models.ts | 10 +- 4 files changed, 274 insertions(+), 250 deletions(-) create mode 100644 platform/frontend/src/features/workflows/components/RuleEditor.tsx diff --git a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx index f33d54c1..c823536c 100644 --- a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx +++ b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx @@ -21,6 +21,7 @@ import CodeMirrorExpressionEditor from "@/components/CodeMirrorExpressionEditor" import PopoutWindow from "@/components/PopoutWindow" import type { CodeMirrorLanguage } from "@/components/CodeMirrorEditor" import type { WorkflowNode, WorkflowDetail, SwitchRule, FilterRule, ScheduleJobInfo } from "@/types/models" +import RuleEditor from "./RuleEditor" interface Props { slug: string @@ -31,52 +32,6 @@ interface Props { const TRIGGER_TYPES = ["trigger_telegram", "trigger_schedule", "trigger_manual", "trigger_workflow", "trigger_error", "trigger_chat"] -const OPERATOR_OPTIONS = [ - { group: "Universal", options: [ - { value: "exists", label: "Exists" }, - { value: "does_not_exist", label: "Does not exist" }, - { value: "is_empty", label: "Is empty" }, - { value: "is_not_empty", label: "Is not empty" }, - ]}, - { group: "String", options: [ - { value: "equals", label: "Equals" }, - { value: "not_equals", label: "Not equals" }, - { value: "contains", label: "Contains" }, - { value: "not_contains", label: "Not contains" }, - { value: "starts_with", label: "Starts with" }, - { value: "not_starts_with", label: "Not starts with" }, - { value: "ends_with", label: "Ends with" }, - { value: "not_ends_with", label: "Not ends with" }, - { value: "matches_regex", label: "Matches regex" }, - { value: "not_matches_regex", label: "Not matches regex" }, - ]}, - { group: "Number", options: [ - { value: "gt", label: "Greater than" }, - { value: "lt", label: "Less than" }, - { value: "gte", label: "Greater or equal" }, - { value: "lte", label: "Less or equal" }, - ]}, - { group: "Datetime", options: [ - { value: "after", label: "After" }, - { value: "before", label: "Before" }, - { value: "after_or_equal", label: "After or equal" }, - { value: "before_or_equal", label: "Before or equal" }, - ]}, - { group: "Boolean", options: [ - { value: "is_true", label: "Is true" }, - { value: "is_false", label: "Is false" }, - ]}, - { group: "Array", options: [ - { value: "length_eq", label: "Length equals" }, - { value: "length_neq", label: "Length not equals" }, - { value: "length_gt", label: "Length greater than" }, - { value: "length_lt", label: "Length less than" }, - { value: "length_gte", label: "Length greater or equal" }, - { value: "length_lte", label: "Length less or equal" }, - ]}, -] - -const UNARY_OPERATORS = new Set(["exists", "does_not_exist", "is_empty", "is_not_empty", "is_true", "is_false"]) /** Close a popout window and clear its state. Use for Save/Cancel buttons — NOT for onClose (which fires from beforeunload when the popup is already closing). */ function closePopout(popup: Window | null, setter: (w: Window | null) => void) { @@ -84,9 +39,6 @@ function closePopout(popup: Window | null, setter: (w: Window | null) => void) { setter(null) } -function generateRuleId(): string { - return "r_" + Math.random().toString(36).slice(2, 8) -} function formatTimestamp(ts: string | undefined): string { if (!ts) return "" @@ -110,19 +62,6 @@ export default function NodeDetailsPanel({ slug, node, workflow, onClose }: Prop return } -/** Parse a full field path like "node_outputs.cat_1.category" into { sourceNodeId, outputField }. */ -function parseFieldPath(field: string): { sourceNodeId: string; outputField: string } { - if (!field || !field.startsWith("node_outputs.")) return { sourceNodeId: "", outputField: field } - const rest = field.slice("node_outputs.".length) // "cat_1.category" - const dotIdx = rest.indexOf(".") - if (dotIdx === -1) return { sourceNodeId: rest, outputField: "" } - return { sourceNodeId: rest.slice(0, dotIdx), outputField: rest.slice(dotIdx + 1) } -} - -function buildFieldPath(sourceNodeId: string, outputField: string): string { - if (!sourceNodeId) return outputField - return `node_outputs.${sourceNodeId}${outputField ? "." + outputField : ""}` -} function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const updateNode = useUpdateNode(slug) @@ -1375,119 +1314,18 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { {node.component_type === "switch" && ( <> -
-
- - -
- {switchRules.length === 0 && ( -

No rules defined. Add a rule to create routing branches.

- )} - {switchRules.map((rule, idx) => ( -
-
- Rule {idx + 1} - -
-
- - setSwitchRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, label: e.target.value } : r))} - className="text-xs h-7" - placeholder="e.g. Good" - /> -
-
- - {upstreamNodes.length > 0 ? ( - - ) : ( - setSwitchRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, field: buildFieldPath(e.target.value, parseFieldPath(r.field).outputField) } : r))} - className="text-xs h-7 font-mono" - placeholder="node_id" - /> - )} -
-
- - setSwitchRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, field: buildFieldPath(parseFieldPath(r.field).sourceNodeId, e.target.value) } : r))} - className="text-xs h-7 font-mono" - placeholder="e.g. category" - /> -
-
- - -
- {!UNARY_OPERATORS.has(rule.operator) && ( -
- - setSwitchRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, value: e.target.value } : r))} - className="text-xs h-7" - placeholder="comparison value" - /> -
- )} -
- ))} -
-
- -

Route to "other" when no rules match

-
- -
-
+ + rules={switchRules} + onChange={setSwitchRules} + upstreamNodes={upstreamNodes} + showLabel + showSourceNode + showFallback + enableFallback={enableFallback} + onFallbackChange={setEnableFallback} + title="Routing Rules" + emptyMessage="No rules defined. Add a rule to create routing branches." + /> )} @@ -1543,75 +1381,12 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { setFilterField(e.target.value)} className="text-xs h-7 font-mono" placeholder="e.g. items" /> -
- - -
- {filterRules.length === 0 && ( -

No rules defined. All items will pass through.

- )} - {filterRules.map((rule, idx) => ( -
-
- Rule {idx + 1} - -
-
- - setFilterRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, field: e.target.value } : r))} - className="text-xs h-7 font-mono" - placeholder="e.g. name, status" - /> -
-
- - -
- {!UNARY_OPERATORS.has(rule.operator) && ( -
- - setFilterRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, value: e.target.value } : r))} - className="text-xs h-7" - placeholder="comparison value" - /> -
- )} -
- ))} + + rules={filterRules} + onChange={setFilterRules} + upstreamNodes={upstreamNodes} + emptyMessage="No rules defined. All items will pass through." + /> )} diff --git a/platform/frontend/src/features/workflows/components/NodePalette.tsx b/platform/frontend/src/features/workflows/components/NodePalette.tsx index 45bfcd75..b0082099 100644 --- a/platform/frontend/src/features/workflows/components/NodePalette.tsx +++ b/platform/frontend/src/features/workflows/components/NodePalette.tsx @@ -7,7 +7,7 @@ import { Cpu, Bot, Brain, GraduationCap, GitFork, Route, FileOutput, Split, Terminal, - Repeat, Pause, Merge, Filter, + Repeat, Pause, Merge, Filter, ClipboardCheck, Code, UserCheck, ShieldAlert, FileText, CheckSquare, FileCheck, Database, DatabaseZap, UserSearch, UserPlus, Plug, Fingerprint, KeyRound, Rocket, PencilRuler, CalendarClock, HeartPulse, @@ -54,6 +54,7 @@ const ICONS: Record = { reply_chat: MessageSquare, validate_gherkin: CheckSquare, validate_topology: FileCheck, + assertion: ClipboardCheck, } const NODE_CATEGORIES: { label: string; types: ComponentType[] }[] = [ diff --git a/platform/frontend/src/features/workflows/components/RuleEditor.tsx b/platform/frontend/src/features/workflows/components/RuleEditor.tsx new file mode 100644 index 00000000..d856075f --- /dev/null +++ b/platform/frontend/src/features/workflows/components/RuleEditor.tsx @@ -0,0 +1,248 @@ +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Trash2, Plus } from "lucide-react" +import type { Rule, SwitchRule } from "@/types/models" + +const OPERATOR_OPTIONS = [ + { group: "Universal", options: [ + { value: "exists", label: "Exists" }, + { value: "does_not_exist", label: "Does not exist" }, + { value: "is_empty", label: "Is empty" }, + { value: "is_not_empty", label: "Is not empty" }, + ]}, + { group: "String", options: [ + { value: "equals", label: "Equals" }, + { value: "not_equals", label: "Not equals" }, + { value: "contains", label: "Contains" }, + { value: "not_contains", label: "Not contains" }, + { value: "starts_with", label: "Starts with" }, + { value: "not_starts_with", label: "Not starts with" }, + { value: "ends_with", label: "Ends with" }, + { value: "not_ends_with", label: "Not ends with" }, + { value: "matches_regex", label: "Matches regex" }, + { value: "not_matches_regex", label: "Not matches regex" }, + ]}, + { group: "Number", options: [ + { value: "gt", label: "Greater than" }, + { value: "lt", label: "Less than" }, + { value: "gte", label: "Greater or equal" }, + { value: "lte", label: "Less or equal" }, + ]}, + { group: "Datetime", options: [ + { value: "after", label: "After" }, + { value: "before", label: "Before" }, + { value: "after_or_equal", label: "After or equal" }, + { value: "before_or_equal", label: "Before or equal" }, + ]}, + { group: "Boolean", options: [ + { value: "is_true", label: "Is true" }, + { value: "is_false", label: "Is false" }, + ]}, + { group: "Array", options: [ + { value: "length_eq", label: "Length equals" }, + { value: "length_neq", label: "Length not equals" }, + { value: "length_gt", label: "Length greater than" }, + { value: "length_lt", label: "Length less than" }, + { value: "length_gte", label: "Length greater or equal" }, + { value: "length_lte", label: "Length less or equal" }, + ]}, +] + +const UNARY_OPERATORS = new Set(["exists", "does_not_exist", "is_empty", "is_not_empty", "is_true", "is_false"]) + +export function generateRuleId(): string { + return "r_" + Math.random().toString(36).slice(2, 8) +} + +/** Parse a full field path like "node_outputs.cat_1.category" into { sourceNodeId, outputField }. */ +export function parseFieldPath(field: string): { sourceNodeId: string; outputField: string } { + if (!field || !field.startsWith("node_outputs.")) return { sourceNodeId: "", outputField: field } + const rest = field.slice("node_outputs.".length) + const dotIdx = rest.indexOf(".") + if (dotIdx === -1) return { sourceNodeId: rest, outputField: "" } + return { sourceNodeId: rest.slice(0, dotIdx), outputField: rest.slice(dotIdx + 1) } +} + +export function buildFieldPath(sourceNodeId: string, outputField: string): string { + if (!sourceNodeId) return outputField + return `node_outputs.${sourceNodeId}${outputField ? "." + outputField : ""}` +} + +interface RuleEditorProps { + rules: T[] + onChange: (rules: T[]) => void + upstreamNodes: string[] + /** Show per-rule label field (switch mode) */ + showLabel?: boolean + /** Show source node dropdown per rule (switch mode — source is part of field path) */ + showSourceNode?: boolean + /** Show fallback toggle */ + showFallback?: boolean + enableFallback?: boolean + onFallbackChange?: (v: boolean) => void + /** Title for the rules section */ + title?: string + /** Empty state message */ + emptyMessage?: string +} + +export default function RuleEditor({ + rules, + onChange, + upstreamNodes, + showLabel = false, + showSourceNode = false, + showFallback = false, + enableFallback = false, + onFallbackChange, + title = "Rules", + emptyMessage = "No rules defined.", +}: RuleEditorProps) { + const addRule = () => { + const defaultField = showSourceNode && upstreamNodes.length === 1 ? buildFieldPath(upstreamNodes[0], "") : "" + const newRule = { id: generateRuleId(), field: defaultField, operator: "equals", value: "", ...(showLabel ? { label: "" } : {}) } as T + onChange([...rules, newRule]) + } + + const removeRule = (id: string) => { + onChange(rules.filter((r) => r.id !== id)) + } + + const updateRule = (id: string, patch: Partial) => { + onChange(rules.map((r) => r.id === id ? { ...r, ...patch } : r)) + } + + return ( +
+
+ + +
+ {rules.length === 0 && ( +

{emptyMessage}

+ )} + {rules.map((rule, idx) => ( +
+
+ Rule {idx + 1} + +
+ {showLabel && ( +
+ + updateRule(rule.id, { label: e.target.value } as unknown as Partial)} + className="text-xs h-7" + placeholder="e.g. Good" + /> +
+ )} + {showSourceNode ? ( + <> +
+ + {upstreamNodes.length > 0 ? ( + + ) : ( + updateRule(rule.id, { field: buildFieldPath(e.target.value, parseFieldPath(rule.field).outputField) } as Partial)} + className="text-xs h-7 font-mono" + placeholder="node_id" + /> + )} +
+
+ + updateRule(rule.id, { field: buildFieldPath(parseFieldPath(rule.field).sourceNodeId, e.target.value) } as Partial)} + className="text-xs h-7 font-mono" + placeholder="e.g. category" + /> +
+ + ) : ( +
+ + updateRule(rule.id, { field: e.target.value } as Partial)} + className="text-xs h-7 font-mono" + placeholder="e.g. name, status" + /> +
+ )} +
+ + +
+ {!UNARY_OPERATORS.has(rule.operator) && ( +
+ + updateRule(rule.id, { value: e.target.value } as Partial)} + className="text-xs h-7" + placeholder="comparison value" + /> +
+ )} +
+ ))} + {showFallback && ( +
+
+ +

Route to "other" when no rules match

+
+ +
+ )} +
+ ) +} diff --git a/platform/frontend/src/types/models.ts b/platform/frontend/src/types/models.ts index a9aadb30..644d15c2 100644 --- a/platform/frontend/src/types/models.ts +++ b/platform/frontend/src/types/models.ts @@ -39,6 +39,7 @@ export type ComponentType = | "skill" | "validate_gherkin" | "validate_topology" + | "assertion" export type EdgeType = "direct" | "conditional" // "memory" was removed — migration 0d301d48b86a converts all memory edges to tool edges. export type EdgeLabel = "" | "llm" | "tool" | "output_parser" | "loop_body" | "loop_return" | "skill" @@ -112,11 +113,10 @@ export interface WorkspaceUpdate { allow_network?: boolean; env_vars?: Workspace // Paginated response export interface PaginatedResponse { items: T[]; total: number } -// Switch rules -export interface SwitchRule { id: string; field: string; operator: string; value: string; label: string } - -// Filter rules -export interface FilterRule { id: string; field: string; operator: string; value: string } +// Rules (shared base for switch, filter, assertion) +export interface Rule { id: string; field: string; operator: string; value: string } +export interface SwitchRule extends Rule { label: string } +export type FilterRule = Rule // Checkpoints export interface Checkpoint { thread_id: string; checkpoint_ns: string; checkpoint_id: string; parent_checkpoint_id: string | null; step: number | null; source: string | null; blob_size: number } From 04c35d1812735fc3429373084d51a4dad17d338f Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 08:20:01 +1030 Subject: [PATCH 4/6] feat(frontend): add assertion node to canvas, palette, and config panel Add assertion node with indigo color, ClipboardCheck icon, fixed width, and two conditional handles (pass/fail). Config panel uses shared RuleEditor component with optional LLM Judge toggle and Pass Threshold slider. Listed under Logic category in palette. Co-Authored-By: Claude Opus 4.6 --- .../workflows/components/NodeDetailsPanel.tsx | 48 +++++++++++++++++++ .../workflows/components/NodePalette.tsx | 2 +- .../workflows/components/WorkflowCanvas.tsx | 37 ++++++++++++-- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx index c823536c..843790c1 100644 --- a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx +++ b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx @@ -145,6 +145,11 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const [filterSourceNode, setFilterSourceNode] = useState((node.config.extra_config?.source_node as string) ?? "") const [filterField, setFilterField] = useState((node.config.extra_config?.field as string) ?? "") + // Assertion state + const [assertionRules, setAssertionRules] = useState(() => (node.config.extra_config?.rules as FilterRule[]) ?? []) + const [assertionJudge, setAssertionJudge] = useState(Boolean(node.config.extra_config?.use_llm_judge)) + const [assertionThreshold, setAssertionThreshold] = useState((node.config.extra_config?.pass_threshold as number) ?? 1.0) + // Merge state const [mergeMode, setMergeMode] = useState((node.config.extra_config?.mode as string) ?? "append") @@ -329,6 +334,9 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { if (node.component_type === "filter") { parsedExtra = { ...parsedExtra, rules: filterRules, source_node: filterSourceNode || undefined, field: filterField || undefined } } + if (node.component_type === "assertion") { + parsedExtra = { ...parsedExtra, rules: assertionRules, use_llm_judge: assertionJudge, pass_threshold: assertionThreshold } + } if (node.component_type === "merge") { parsedExtra = { ...parsedExtra, mode: mergeMode } } @@ -1391,6 +1399,46 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { )} + {node.component_type === "assertion" && ( + <> + + + rules={assertionRules} + onChange={setAssertionRules} + upstreamNodes={upstreamNodes} + title="Assertion Rules" + emptyMessage="No rules defined. Add rules to validate data." + /> + +
+
+
+ +

Use an LLM to evaluate assertions

+
+ +
+ {assertionJudge && ( +
+ +
+ setAssertionThreshold(Number(e.target.value))} + className="flex-1 h-1.5 accent-indigo-500" + /> + {assertionThreshold.toFixed(2)} +
+
+ )} +
+ + )} + {node.component_type === "merge" && ( <> diff --git a/platform/frontend/src/features/workflows/components/NodePalette.tsx b/platform/frontend/src/features/workflows/components/NodePalette.tsx index b0082099..0ef7211a 100644 --- a/platform/frontend/src/features/workflows/components/NodePalette.tsx +++ b/platform/frontend/src/features/workflows/components/NodePalette.tsx @@ -64,7 +64,7 @@ const NODE_CATEGORIES: { label: string; types: ComponentType[] }[] = [ { label: "Memory", types: ["memory_read", "memory_write", "identify_user"] }, { label: "Agent", types: ["whoami", "create_agent_user", "get_totp_code", "platform_api", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create"] }, { label: "Tools", types: ["run_command", "workflow_discover", "validate_gherkin", "validate_topology"] }, - { label: "Logic", types: ["switch", "loop", "filter", "merge", "wait"] }, + { label: "Logic", types: ["switch", "loop", "filter", "merge", "wait", "assertion"] }, { label: "Output", types: ["reply_chat"] }, { label: "Other", types: ["workflow", "code", "human_confirmation", "error_handler", "output_parser"] }, ] diff --git a/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx b/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx index 4d950913..0c42bfa9 100644 --- a/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx +++ b/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import type { IconDefinition } from "@fortawesome/fontawesome-svg-core" import { faMicrochip, faRobot, faTags, faCodeBranch, faWrench, faMagnifyingGlassChart, - faSitemap, faCode, faTriangleExclamation, faUserCheck, + faSitemap, faCode, faTriangleExclamation, faUserCheck, faClipboardCheck, faFileExport, faRepeat, faClock, faCodeMerge, faFilter, faCalendarDays, faHandPointer, faHourglass, faHeartPulse, faPlay, faBug, faComments, faCircleNotch, faCircleCheck, faCircleXmark, faMinus, @@ -81,6 +81,7 @@ const COMPONENT_COLORS: Record = { filter: "#6366f1", merge: "#6366f1", wait: "#6366f1", + assertion: "#6366f1", workflow: "#6366f1", code: "#64748b", memory_read: "#f59e0b", @@ -108,7 +109,7 @@ const COMPONENT_ICONS: Record = { code: faCode, error_handler: faTriangleExclamation, memory_read: faDatabase, memory_write: faFloppyDisk, identify_user: faIdCard, human_confirmation: faUserCheck, output_parser: faFileExport, - loop: faRepeat, wait: faClock, merge: faCodeMerge, filter: faFilter, + loop: faRepeat, wait: faClock, merge: faCodeMerge, filter: faFilter, assertion: faClipboardCheck, trigger_telegram: faTelegram, trigger_schedule: faCalendarDays, trigger_manual: faHandPointer, trigger_workflow: faPlay, trigger_error: faBug, trigger_chat: faComments, @@ -129,7 +130,7 @@ function WorkflowNodeComponent({ data, selected }: { data: { label: string; comp const isWaiting = data.executionStatus === "waiting" const isTrigger = data.componentType.startsWith("trigger_") const isLoop = data.componentType === "loop" - const isFixedWidth = ["router", "categorizer", "agent", "deep_agent", "extractor", "switch", "loop"].includes(data.componentType) + const isFixedWidth = ["router", "categorizer", "agent", "deep_agent", "extractor", "switch", "loop", "assertion"].includes(data.componentType) const isTool = ["run_command", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create", "workflow_discover", "validate_gherkin", "validate_topology"].includes(data.componentType) const isSubComponent = ["ai_model", "run_command", "output_parser", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create", "workflow_discover", "skill", "validate_gherkin", "validate_topology"].includes(data.componentType) const isAiModel = data.componentType === "ai_model" @@ -146,6 +147,7 @@ function WorkflowNodeComponent({ data, selected }: { data: { label: string; comp const isSuccess = data.executionStatus === "success" const isFailed = data.executionStatus === "failed" const isSwitch = data.componentType === "switch" + const isAssertion = data.componentType === "assertion" const switchHandles = isSwitch ? (data.rules ?? []) : [] const showFallbackHandle = isSwitch && data.enableFallback return ( @@ -283,7 +285,22 @@ function WorkflowNodeComponent({ data, selected }: { data: { label: string; comp /> )} - {!isSubComponent && !(isSwitch && (switchHandles.length > 0 || showFallbackHandle)) && !isLoop && } + {isAssertion && ( + <> +
+
+
+ Pass + +
+
+ Fail + +
+
+ + )} + {!isSubComponent && !(isSwitch && (switchHandles.length > 0 || showFallbackHandle)) && !isLoop && !isAssertion && } ) } @@ -450,6 +467,8 @@ export default function WorkflowCanvas({ slug, workflow, selectedNodeId, onSelec const rules = (srcNode.config?.extra_config?.rules as SwitchRule[]) ?? [] const rule = rules.find((r) => r.id === e.condition_value) condLabel = rule?.label || e.condition_value + } else if (srcNode?.component_type === "assertion") { + condLabel = e.condition_value // "pass" or "fail" } else { condLabel = e.condition_value } @@ -514,6 +533,16 @@ export default function WorkflowCanvas({ slug, workflow, selectedNodeId, onSelec }) return } + if (sourceNode?.component_type === "assertion" && !edge_label && (params.sourceHandle === "pass" || params.sourceHandle === "fail")) { + createEdge.mutate({ + source_node_id: params.source, + target_node_id: params.target, + edge_type: "conditional", + edge_label, + condition_value: params.sourceHandle, + }) + return + } if (sourceNode?.component_type === "switch" && !edge_label) { // If dragged from a specific rule handle, auto-create conditional edge const ruleId = params.sourceHandle From 500be11b98c6e368d57b3283ec785b439652a17f Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 18:07:28 +1030 Subject: [PATCH 5/6] feat: add assertion nodes to workflow-generator fixture Added gherkin_check and topology_check assertion nodes as observers that fork from the agent outputs without interfering with the main flow. Each validates structural correctness: - gherkin_check: verifies Feature/Scenario/Given/Then keywords - topology_check: verifies name/trigger/steps/type fields 22 nodes, 21 edges (was 20/19). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../workflow_generator/fixture.json | 244 ++++++++++++++---- 1 file changed, 194 insertions(+), 50 deletions(-) diff --git a/platform/tests/dsl_fixtures/workflow_generator/fixture.json b/platform/tests/dsl_fixtures/workflow_generator/fixture.json index 979e7dcd..6ce8c1a4 100644 --- a/platform/tests/dsl_fixtures/workflow_generator/fixture.json +++ b/platform/tests/dsl_fixtures/workflow_generator/fixture.json @@ -11,8 +11,8 @@ "max_execution_seconds": 900, "input_schema": null, "output_schema": null, - "node_count": 20, - "edge_count": 19, + "node_count": 22, + "edge_count": 21, "created_at": "2026-03-20T09:01:27", "updated_at": "2026-03-20T09:01:27", "nodes": [ @@ -25,7 +25,7 @@ "interrupt_before": false, "interrupt_after": false, "position_x": 1486, - "position_y": -5, + "position_y": -17, "config": { "system_prompt": "You are the Builder. The topology and behavior spec have been verified. Now create the actual workflow in Pipelit using the platform API.\n\nYou have access to the platform_api and workflow_create tools. Use them to:\n1. Create the workflow (POST /api/v1/workflows/)\n2. Create each node from the topology (POST /api/v1/workflows/{slug}/nodes/)\n3. Create edges between nodes (POST /api/v1/workflows/{slug}/edges/)\n4. For agent/deep_agent nodes: create ai_model sub-components and connect via llm edges\n5. Validate the workflow (POST /api/v1/workflows/{slug}/validate/)\n\nThe LLM credential ID is 1. The model is claude-sonnet-4-20250514.\n\nTopology: {{ topology_agent.output }}\nBehavior spec: {{ gherkin_agent.output }}\n\nCreate the workflow now. Report success or any errors encountered.", "extra_config": {}, @@ -48,7 +48,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-20T21:52:12", "schedule_job": null }, { @@ -59,8 +59,8 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": 1562, - "position_y": 150, + "position_x": 1532, + "position_y": 151, "config": { "system_prompt": "", "extra_config": {}, @@ -83,7 +83,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-20T21:52:13", "schedule_job": null }, { @@ -94,8 +94,8 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": 1089, - "position_y": -18, + "position_x": 1094, + "position_y": -61, "config": { "system_prompt": "", "extra_config": { @@ -134,7 +134,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-20T22:01:04", "schedule_job": null }, { @@ -145,8 +145,8 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": -104, - "position_y": -85, + "position_x": -98, + "position_y": -113, "config": { "system_prompt": "", "extra_config": { @@ -173,7 +173,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-21T06:08:32", "schedule_job": null }, { @@ -187,7 +187,7 @@ "position_x": 170, "position_y": -264, "config": { - "system_prompt": "[agent]\nname = \"Gherkin Agent\"\nrole = \"\"\"\nYou are the Gherkin Agent. You receive a requirements document from the Scribe\nand produce a Gherkin behavior specification for the described workflow.\nYour output is the execution contract for the Verifier Agent — a live LLM-driven\nworkflow that will send each scenario as a real chat request to the workflow trigger\nand match actual responses against your Then clauses. Write accordingly.\n\"\"\"\n\n[context]\n# The trigger IS the entry point — do NOT create scenarios for \"receiving\" a message\ntrigger_is_entry_point = true\n\n# reply_chat is a terminal node that sends a message back to the chat caller\nreply_chat_is_terminal = true\n\n# Scenarios test end-to-end observable behaviour via the trigger endpoint\n# Node IDs are used for traceability only — assertions are always on reply_chat output\ntest_observable_behaviour_only = true\n\n[rules]\n# Every processing step from the requirements MUST appear in at least one scenario\nfull_step_coverage = true\n\n# Use the exact node IDs from the requirements document\nuse_exact_node_ids = true\n\n# Use standard Given/When/Then format\nformat = \"Given/When/Then\"\n\n# Every scenario must be independently executable\n# No scenario may depend on state or output from a previous scenario\nscenarios_are_stateless = true\n\n# Every Then clause must assert a specific, verifiable value or condition\n# The Verifier matches Then clauses directly against raw response strings — no inference\nverifiable_assertions_only = true\n\n[rules.given_when]\n# Given defines the exact input precondition — use concrete literal values\n# When defines the action — name the exact node_id performing it\n# Given + When must compose into an unambiguous natural language chat message\n# The Verifier derives the actual message to send from these two clauses verbatim\nmust_compose_to_sendable_message = true\n\n[rules.then]\n# Then clauses must be matchable against a raw response string\n# Specify exact strings, HTTP status codes, or unambiguous structural conditions\nmust_be_raw_response_matchable = true\n\n[rules.error_scenario_requirements]\n# Error scenarios must specify the exact HTTP status code returned\nrequire_http_status_code = true\n\n# Error scenarios must specify the exact error message or response body shape\nrequire_error_message = true\n\n# Error scenarios must state whether the node retries or fails immediately\nrequire_retry_behaviour = true\n\n[rules.input_boundary_requirements]\n# Every workflow that accepts string input MUST include these boundary scenarios\n[[rules.input_boundary_requirements.cases]]\ncase = \"empty_string\"\ninput = '\"\"'\nreason = \"empty input must have a defined rejection or fallback behaviour\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"whitespace_only\"\ninput = '\" \"'\nreason = \"whitespace-only must not be treated as valid content\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"null_or_missing\"\ninput = \"null / field absent\"\nreason = \"missing input must return HTTP 400 with a specific error message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"oversized_payload\"\ninput = \"string exceeding max token or byte limit\"\nreason = \"must return HTTP 413 or equivalent with a size exceeded message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"malformed_encoding\"\ninput = \"non-UTF-8 or broken JSON\"\nreason = \"must return HTTP 400 with a parse error message\"\n\n[[rules.then_antipatterns]]\npattern = \"should generate an? (appropriate|correct|valid|concise) .*\"\nreason = \"adjective without qualification is untestable — specify exact value or format\"\n\n[[rules.then_antipatterns]]\npattern = \"should handle .* appropriately\"\nreason = \"unverifiable — specify the exact output or behaviour\"\n\n[[rules.then_antipatterns]]\npattern = \"should send an? (appropriate|correct|valid) response\"\nreason = \"must specify the exact response value or structure\"\n\n[[rules.then_antipatterns]]\npattern = \"should fail gracefully\"\nreason = \"specify the exact HTTP code, error message, and response shape\"\n\n[[rules.then_antipatterns]]\npattern = \"should return an error\"\nreason = \"specify HTTP 4xx/5xx code, error field value, and whether retry is expected\"\n\n[[rules.then_antipatterns]]\npattern = \"should contain relevant information\"\nreason = \"Verifier cannot match 'relevant' — specify exact field or substring\"\n\n[[rules.then_antipatterns]]\npattern = \"should respond within .* seconds\"\nreason = \"Verifier is not a latency tester — remove timing assertions\"\n\n[[rules.then_antipatterns]]\npattern = \"should process the (input|message)\"\nreason = \"processing is not observable — assert on the output only\"\n\n[input]\n# Receives the full requirements document from the Scribe\nsource = \"scribe.output\"\ntemplate = \"{{ scribe.output }}\"\n\n[output]\n# Emit ONLY a single fenced code block — no preamble, no explanation\nformat = \"single fenced code block, label: behavior.feature\"\n\n[output.structure]\n# Feature name derived from workflow PURPOSE in the requirements doc\nfeature = \"\"\n\n# Required scenario types per processing step:\n# 1. happy path — concrete input, exact expected output\n# 2. error case — specific failure condition, HTTP code, exact error message\n# 3. input boundary — one scenario per boundary case for every string-accepting node\n[[output.structure.scenarios]]\ntype = \"happy_path\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When processes the message\n Then should return \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"error_case\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When encounters \n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"input_boundary\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \n When attempts to process the input\n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[validation]\n# Before returning your output, you MUST call the validate_gherkin tool\n# to check your Gherkin spec for syntax errors and lint warnings.\n# Fix any issues the tool reports before returning your final output.\nrequire_validation = true\ntool_name = \"validate_gherkin\"\n\n[validation.workflow]\n# 1. Generate your Gherkin spec\n# 2. Call validate_gherkin with the raw spec text (no fences)\n# 3. If valid=false or lint_errors exist, fix the issues and re-validate\n# 4. Only return the spec once it passes validation\nmust_pass_before_output = true\n", + "system_prompt": "[agent]\nname = \"Gherkin Agent\"\nrole = \"\"\"\nYou are the Gherkin Agent. You receive a requirements document from the Scribe\nand produce a Gherkin behavior specification for the described workflow.\nYour output is the execution contract for the Verifier Agent \u2014 a live LLM-driven\nworkflow that will send each scenario as a real chat request to the workflow trigger\nand match actual responses against your Then clauses. Write accordingly.\n\"\"\"\n\n[context]\n# The trigger IS the entry point \u2014 do NOT create scenarios for \"receiving\" a message\ntrigger_is_entry_point = true\n\n# reply_chat is a terminal node that sends a message back to the chat caller\nreply_chat_is_terminal = true\n\n# Scenarios test end-to-end observable behaviour via the trigger endpoint\n# Node IDs are used for traceability only \u2014 assertions are always on reply_chat output\ntest_observable_behaviour_only = true\n\n[rules]\n# Every processing step from the requirements MUST appear in at least one scenario\nfull_step_coverage = true\n\n# Use the exact node IDs from the requirements document\nuse_exact_node_ids = true\n\n# Use standard Given/When/Then format\nformat = \"Given/When/Then\"\n\n# Every scenario must be independently executable\n# No scenario may depend on state or output from a previous scenario\nscenarios_are_stateless = true\n\n# Every Then clause must assert a specific, verifiable value or condition\n# The Verifier matches Then clauses directly against raw response strings \u2014 no inference\nverifiable_assertions_only = true\n\n[rules.given_when]\n# Given defines the exact input precondition \u2014 use concrete literal values\n# When defines the action \u2014 name the exact node_id performing it\n# Given + When must compose into an unambiguous natural language chat message\n# The Verifier derives the actual message to send from these two clauses verbatim\nmust_compose_to_sendable_message = true\n\n[rules.then]\n# Then clauses must be matchable against a raw response string\n# Specify exact strings, HTTP status codes, or unambiguous structural conditions\nmust_be_raw_response_matchable = true\n\n[rules.error_scenario_requirements]\n# Error scenarios must specify the exact HTTP status code returned\nrequire_http_status_code = true\n\n# Error scenarios must specify the exact error message or response body shape\nrequire_error_message = true\n\n# Error scenarios must state whether the node retries or fails immediately\nrequire_retry_behaviour = true\n\n[rules.input_boundary_requirements]\n# Every workflow that accepts string input MUST include these boundary scenarios\n[[rules.input_boundary_requirements.cases]]\ncase = \"empty_string\"\ninput = '\"\"'\nreason = \"empty input must have a defined rejection or fallback behaviour\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"whitespace_only\"\ninput = '\" \"'\nreason = \"whitespace-only must not be treated as valid content\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"null_or_missing\"\ninput = \"null / field absent\"\nreason = \"missing input must return HTTP 400 with a specific error message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"oversized_payload\"\ninput = \"string exceeding max token or byte limit\"\nreason = \"must return HTTP 413 or equivalent with a size exceeded message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"malformed_encoding\"\ninput = \"non-UTF-8 or broken JSON\"\nreason = \"must return HTTP 400 with a parse error message\"\n\n[[rules.then_antipatterns]]\npattern = \"should generate an? (appropriate|correct|valid|concise) .*\"\nreason = \"adjective without qualification is untestable \u2014 specify exact value or format\"\n\n[[rules.then_antipatterns]]\npattern = \"should handle .* appropriately\"\nreason = \"unverifiable \u2014 specify the exact output or behaviour\"\n\n[[rules.then_antipatterns]]\npattern = \"should send an? (appropriate|correct|valid) response\"\nreason = \"must specify the exact response value or structure\"\n\n[[rules.then_antipatterns]]\npattern = \"should fail gracefully\"\nreason = \"specify the exact HTTP code, error message, and response shape\"\n\n[[rules.then_antipatterns]]\npattern = \"should return an error\"\nreason = \"specify HTTP 4xx/5xx code, error field value, and whether retry is expected\"\n\n[[rules.then_antipatterns]]\npattern = \"should contain relevant information\"\nreason = \"Verifier cannot match 'relevant' \u2014 specify exact field or substring\"\n\n[[rules.then_antipatterns]]\npattern = \"should respond within .* seconds\"\nreason = \"Verifier is not a latency tester \u2014 remove timing assertions\"\n\n[[rules.then_antipatterns]]\npattern = \"should process the (input|message)\"\nreason = \"processing is not observable \u2014 assert on the output only\"\n\n[input]\n# Receives the full requirements document from the Scribe\nsource = \"scribe.output\"\ntemplate = \"{{ scribe.output }}\"\n\n[output]\n# Emit ONLY a single fenced code block \u2014 no preamble, no explanation\nformat = \"single fenced code block, label: behavior.feature\"\n\n[output.structure]\n# Feature name derived from workflow PURPOSE in the requirements doc\nfeature = \"\"\n\n# Required scenario types per processing step:\n# 1. happy path \u2014 concrete input, exact expected output\n# 2. error case \u2014 specific failure condition, HTTP code, exact error message\n# 3. input boundary \u2014 one scenario per boundary case for every string-accepting node\n[[output.structure.scenarios]]\ntype = \"happy_path\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When processes the message\n Then should return \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"error_case\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When encounters \n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"input_boundary\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \n When attempts to process the input\n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[validation]\n# Before returning your output, you MUST call the validate_gherkin tool\n# to check your Gherkin spec for syntax errors and lint warnings.\n# Fix any issues the tool reports before returning your final output.\nrequire_validation = true\ntool_name = \"validate_gherkin\"\n\n[validation.workflow]\n# 1. Generate your Gherkin spec\n# 2. Call validate_gherkin with the raw spec text (no fences)\n# 3. If valid=false or lint_errors exist, fix the issues and re-validate\n# 4. Only return the spec once it passes validation\nmust_pass_before_output = true\n", "extra_config": { "input_template": "{{ scribe.output }}", "conversation_memory": false, @@ -221,6 +221,68 @@ "updated_at": "2026-03-20T09:05:20", "schedule_job": null }, + { + "id": 27, + "node_id": "gherkin_check", + "label": "Gherkin Valid?", + "component_type": "assertion", + "is_entry_point": false, + "interrupt_before": false, + "interrupt_after": false, + "position_x": 700, + "position_y": -100, + "config": { + "system_prompt": "", + "extra_config": { + "rules": [ + { + "id": "has_feature", + "field": "node_outputs.gherkin_agent.output", + "operator": "contains", + "value": "Feature:" + }, + { + "id": "has_scenario", + "field": "node_outputs.gherkin_agent.output", + "operator": "contains", + "value": "Scenario:" + }, + { + "id": "has_given", + "field": "node_outputs.gherkin_agent.output", + "operator": "contains", + "value": "Given" + }, + { + "id": "has_then", + "field": "node_outputs.gherkin_agent.output", + "operator": "contains", + "value": "Then" + } + ] + }, + "llm_credential_id": null, + "model_name": "", + "temperature": null, + "max_tokens": null, + "frequency_penalty": null, + "presence_penalty": null, + "top_p": null, + "timeout": null, + "max_retries": null, + "response_format": null, + "llm_model_config_id": null, + "credential_id": null, + "is_active": true, + "priority": 0, + "trigger_config": {}, + "input_template": null + }, + "subworkflow_id": null, + "code_block_id": null, + "updated_at": "2026-03-21T07:31:26", + "schedule_job": null + }, { "id": 12, "node_id": "gherkin_model", @@ -229,8 +291,8 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": 113, - "position_y": -2, + "position_x": 83, + "position_y": -85, "config": { "system_prompt": "", "extra_config": {}, @@ -253,7 +315,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T13:05:32", + "updated_at": "2026-03-21T06:08:37", "schedule_job": null }, { @@ -264,8 +326,8 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": 517, - "position_y": -48, + "position_x": 549, + "position_y": -42, "config": { "system_prompt": "", "extra_config": { @@ -294,7 +356,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-20T21:59:55", "schedule_job": null }, { @@ -305,8 +367,8 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": -116, - "position_y": 151, + "position_x": -98, + "position_y": 101, "config": { "system_prompt": "{{ scribe.output }}", "extra_config": {}, @@ -329,7 +391,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-21T06:08:33", "schedule_job": null }, { @@ -375,10 +437,10 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": 1482, - "position_y": -141, + "position_x": 1480, + "position_y": -165, "config": { - "system_prompt": "✅ Verification passed! Here is what I designed:\n\n**Topology:**\n{{ topology_agent.output }}\n\n---\n\n**Behavior Spec:**\n{{ gherkin_agent.output }}", + "system_prompt": "\u2705 Verification passed! Here is what I designed:\n\n**Topology:**\n{{ topology_agent.output }}\n\n---\n\n**Behavior Spec:**\n{{ gherkin_agent.output }}", "extra_config": {}, "llm_credential_id": null, "model_name": "", @@ -399,7 +461,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-20T21:52:11", "schedule_job": null }, { @@ -413,7 +475,7 @@ "position_x": -840, "position_y": -45, "config": { - "system_prompt": "[agent]\nname = \"Scribe\"\nrole = \"\"\"\nYou are the Scribe. Your job is to turn the user's request into a structured requirements document that feeds a validation gate, a Gherkin test scenario agent, and a topology/DSL builder agent.\n\"\"\"\n\n[rules]\n# Always produce the requirements document immediately, even if the request is incomplete\nalways_produce_document = true\n\n# Do not ask the user directly — the validation layer owns the clarification loop\nask_user_directly = false\nclarification_owner = \"validation layer\"\n\n# The trigger (chat, schedule, manual, telegram) IS the entry point — do NOT list a separate \"receive\" or \"capture\" step\ntrigger_is_entry_point = true\n\n# STEPS should only include PROCESSING nodes\nsteps_processing_only = true\n\n# Make reasonable assumptions for anything unspecified — record them in ASSUMPTIONS\n# If anything is genuinely unresolvable without user input, record it in OPEN_QUESTIONS\nrecord_assumptions = true\nrecord_open_questions = true\n\n[node_catalog]\n\n# Triggers (entry points — every workflow needs exactly one)\ntrigger_chat = \"Receives messages from external chat clients via msg-gateway generic adapter\"\ntrigger_error = \"Triggered when a workflow execution encounters an error\"\ntrigger_manual = \"Manually triggered workflow execution\"\ntrigger_schedule = \"Executes workflow on a scheduled interval\"\ntrigger_telegram = \"Receives messages from Telegram via msg-gateway\"\ntrigger_workflow = \"Triggered by another workflow execution\"\n\n# AI Nodes (call an LLM to reason, classify, generate, extract)\nagent = \"LangGraph react agent with tools\"\ncategorizer = \"Classifies input into categories\"\ndeep_agent = \"Advanced agent with built-in task planning, filesystem tools, and subagents\"\nextractor = \"Extracts structured data from input\"\nrouter = \"Routes to different branches based on input\"\n\n# Logic Nodes (no LLM — deterministic control flow)\ncode = \"Python sandbox for data transformation, API calls, parsing\"\nfilter = \"Filter array items using rule-based matching\"\nloop = \"Iterate over an array, executing body nodes for each item\"\nmerge = \"Merge outputs from multiple branches into one (fan-in barrier)\"\nswitch = \"Routes to different branches based on a state field or expression\"\nwait = \"Delay downstream execution by a specified duration\"\nworkflow = \"Execute another workflow as a child and return its output\"\n\n# Flow Control\nhuman_confirmation = \"Pause execution and wait for human approval before continuing\"\n\n# Output Nodes (terminal — end the workflow)\nreply_chat = \"Sends a message back to the chat caller and ends the workflow\"\n\n# security and privileged access\nidentify_user = \"Only relevant as a step before totp, which means whenever there is intention to acquire privileged access to information or actions\"\n\n# Agent Tools (attach to agent/deep_agent via tool edges)\nget_totp_code = \"Retrieve the current TOTP code for agent identity verification\"\nplatform_api = \"Make authenticated requests to the platform API\"\nscheduler_tools = \"Create, pause, resume, stop, and list scheduled recurring jobs\"\nspawn_and_await = \"Spawn a child workflow and wait for its result inside an agent's reasoning loop\"\nsystem_health = \"Check platform infrastructure health: Redis, RQ workers, queues, stuck executions\"\nwhoami = \"Get self-awareness — workflow, node ID, and how to modify yourself\"\nworkflow_create = \"Create workflows programmatically from a YAML DSL specification\"\nworkflow_discover = \"Search existing workflows by requirements and get reuse recommendations\"\n\n# Sub-components (wired by the builder, not listed in STEPS)\nai_model = \"LLM model configuration — attached to agent/deep_agent via llm edge\"\nmemory_read = \"Recall tool — retrieves information from global memory\"\nmemory_write = \"Remember tool — stores information in global memory\"\noutput_parser = \"Structured output parsing for agent responses\"\nrun_command = \"Execute shell commands\"\nskill = \"SKILL.md behavioral instructions for agents via progressive disclosure\"\n\n[output.requirements]\nformat = \"fenced code block, label: requirements\"\nfields = [\n \"PURPOSE: \",\n \"TRIGGER: \",\n \"STEPS: \",\n \"INTEGRATIONS: \",\n \"ASSUMPTIONS: \",\n \"OPEN_QUESTIONS: \",\n \"ERROR_HANDLING: \",\n \"OUTPUT: \",\n]\n\n[output.mermaid]\nformat = \"fenced code block, label: mermaid\"\ndiagram_type = \"flowchart TD\"\n\n[output.mermaid.rules]\nregular_nodes = \"Square brackets []\"\nswitch_nodes = \"Diamond shapes {}\"\nterminal_nodes = \"Stadium shape (( ))\"\nbranch_labels = true\nparallel_paths = true\n\n[output.order]\n1 = \"requirements block\"\n2 = \"mermaid block\"\n3 = \"closing prompt: 'Does this match what you had in mind? I can adjust before proceeding.'\"\n\n[downstream]\nvalidator = \"Gates on OPEN_QUESTIONS — empty means pass, non-empty triggers clarification back to user\"\ngherkin = \"Generates test scenario scripts from full requirements document\"\ntopology_dsl = \"Builds workflow DSL from full requirements document\"\n", + "system_prompt": "[agent]\nname = \"Scribe\"\nrole = \"\"\"\nYou are the Scribe. Your job is to turn the user's request into a structured requirements document that feeds a validation gate, a Gherkin test scenario agent, and a topology/DSL builder agent.\n\"\"\"\n\n[rules]\n# Always produce the requirements document immediately, even if the request is incomplete\nalways_produce_document = true\n\n# Do not ask the user directly \u2014 the validation layer owns the clarification loop\nask_user_directly = false\nclarification_owner = \"validation layer\"\n\n# The trigger (chat, schedule, manual, telegram) IS the entry point \u2014 do NOT list a separate \"receive\" or \"capture\" step\ntrigger_is_entry_point = true\n\n# STEPS should only include PROCESSING nodes\nsteps_processing_only = true\n\n# Make reasonable assumptions for anything unspecified \u2014 record them in ASSUMPTIONS\n# If anything is genuinely unresolvable without user input, record it in OPEN_QUESTIONS\nrecord_assumptions = true\nrecord_open_questions = true\n\n[node_catalog]\n\n# Triggers (entry points \u2014 every workflow needs exactly one)\ntrigger_chat = \"Receives messages from external chat clients via msg-gateway generic adapter\"\ntrigger_error = \"Triggered when a workflow execution encounters an error\"\ntrigger_manual = \"Manually triggered workflow execution\"\ntrigger_schedule = \"Executes workflow on a scheduled interval\"\ntrigger_telegram = \"Receives messages from Telegram via msg-gateway\"\ntrigger_workflow = \"Triggered by another workflow execution\"\n\n# AI Nodes (call an LLM to reason, classify, generate, extract)\nagent = \"LangGraph react agent with tools\"\ncategorizer = \"Classifies input into categories\"\ndeep_agent = \"Advanced agent with built-in task planning, filesystem tools, and subagents\"\nextractor = \"Extracts structured data from input\"\nrouter = \"Routes to different branches based on input\"\n\n# Logic Nodes (no LLM \u2014 deterministic control flow)\ncode = \"Python sandbox for data transformation, API calls, parsing\"\nfilter = \"Filter array items using rule-based matching\"\nloop = \"Iterate over an array, executing body nodes for each item\"\nmerge = \"Merge outputs from multiple branches into one (fan-in barrier)\"\nswitch = \"Routes to different branches based on a state field or expression\"\nwait = \"Delay downstream execution by a specified duration\"\nworkflow = \"Execute another workflow as a child and return its output\"\n\n# Flow Control\nhuman_confirmation = \"Pause execution and wait for human approval before continuing\"\n\n# Output Nodes (terminal \u2014 end the workflow)\nreply_chat = \"Sends a message back to the chat caller and ends the workflow\"\n\n# security and previlaged access\nidentify_user = \"Only revelent as a step before totp, which means whenever there is intention to acquire previlaged access to information or actions\"\n\n# Agent Tools (attach to agent/deep_agent via tool edges)\nepic_tools = \"Create, query, update, and search epics for task delegation\"\nget_totp_code = \"Retrieve the current TOTP code for agent identity verification\"\nplatform_api = \"Make authenticated requests to the platform API\"\nscheduler_tools = \"Create, pause, resume, stop, and list scheduled recurring jobs\"\nspawn_and_await = \"Spawn a child workflow and wait for its result inside an agent's reasoning loop\"\nsystem_health = \"Check platform infrastructure health: Redis, RQ workers, queues, stuck executions\"\ntask_tools = \"Create, list, update, and cancel tasks within epics\"\nwhoami = \"Get self-awareness \u2014 workflow, node ID, and how to modify yourself\"\nworkflow_create = \"Create workflows programmatically from a YAML DSL specification\"\nworkflow_discover = \"Search existing workflows by requirements and get reuse recommendations\"\n\n# Sub-components (wired by the builder, not listed in STEPS)\nai_model = \"LLM model configuration \u2014 attached to agent/deep_agent via llm edge\"\nmemory_read = \"Recall tool \u2014 retrieves information from global memory\"\nmemory_write = \"Remember tool \u2014 stores information in global memory\"\noutput_parser = \"Structured output parsing for agent responses\"\nrun_command = \"Execute shell commands\"\nskill = \"SKILL.md behavioral instructions for agents via progressive disclosure\"\n\n[output.requirements]\nformat = \"fenced code block, label: requirements\"\nfields = [\n \"PURPOSE: \",\n \"TRIGGER: \",\n \"STEPS: \",\n \"INTEGRATIONS: \",\n \"ASSUMPTIONS: \",\n \"OPEN_QUESTIONS: \",\n \"ERROR_HANDLING: \",\n \"OUTPUT: \",\n]\n\n[output.mermaid]\nformat = \"fenced code block, label: mermaid\"\ndiagram_type = \"flowchart TD\"\n\n[output.mermaid.rules]\nregular_nodes = \"Square brackets []\"\nswitch_nodes = \"Diamond shapes {}\"\nterminal_nodes = \"Stadium shape (( ))\"\nbranch_labels = true\nparallel_paths = true\n\n[output.order]\n1 = \"requirements block\"\n2 = \"mermaid block\"\n3 = \"closing prompt: 'Does this match what you had in mind? I can adjust before proceeding.'\"\n\n[downstream]\nvalidator = \"Gates on OPEN_QUESTIONS \u2014 empty means pass, non-empty triggers clarification back to user\"\ngherkin = \"Generates test scenario scripts from full requirements document\"\ntopology_dsl = \"Builds workflow DSL from full requirements document\"\n", "extra_config": { "conversation_memory": false, "context_window": null, @@ -499,8 +561,8 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": -995, - "position_y": 178, + "position_x": -940, + "position_y": 134, "config": { "system_prompt": "", "extra_config": {}, @@ -523,7 +585,7 @@ }, "subworkflow_id": null, "code_block_id": null, - "updated_at": "2026-03-20T09:01:27", + "updated_at": "2026-03-20T22:02:23", "schedule_job": null }, { @@ -534,10 +596,10 @@ "is_entry_point": false, "interrupt_before": false, "interrupt_after": false, - "position_x": 178, - "position_y": 90, + "position_x": 186, + "position_y": 61, "config": { - "system_prompt": "[agent]\nname = \"Topology Agent\"\nrole = \"You are the Topology Agent. You receive a requirements document from the Scribe and produce a Pipelit workflow topology in YAML. Your output is the structural blueprint -- node types, IDs, and edges only. Prompts, code, and runtime config are filled in later by the Builder.\"\n\n[context]\n# The trigger IS the entry point -- do NOT create a separate receive/capture node\ntrigger_is_entry_point = true\n\n# Topology is STRUCTURE ONLY -- no prompts, no code snippets, no config values\nstructure_only = true\n\n# Use the exact node IDs from the Scribe's STEPS section\nuse_exact_node_ids = true\n\n[node_catalog]\n\n# Triggers (entry points -- every workflow needs exactly one)\ntrigger_chat = \"Receives messages from external chat clients\"\ntrigger_error = \"Triggered when a workflow execution encounters an error\"\ntrigger_manual = \"Manually triggered workflow execution\"\ntrigger_schedule = \"Executes workflow on a scheduled interval\"\ntrigger_telegram = \"Receives messages from Telegram via msg-gateway\"\ntrigger_workflow = \"Triggered by another workflow execution\"\n\n# AI Nodes (call an LLM)\nagent = \"LangGraph react agent with tools\"\ncategorizer = \"Classifies input into categories\"\ndeep_agent = \"Advanced agent with planning, filesystem tools, and subagents\"\nextractor = \"Extracts structured data from input\"\nrouter = \"Routes to different branches based on LLM classification\"\n\n# Logic Nodes (no LLM -- deterministic)\ncode = \"Python sandbox for data transformation, API calls, parsing\"\nfilter = \"Filter array items using rule-based matching\"\nloop = \"Iterate over an array, executing body nodes for each item\"\nmerge = \"Fan-in barrier -- waits for all incoming parallel branches\"\nswitch = \"Routes to branches based on a state field or expression\"\nwait = \"Delay downstream execution by a specified duration\"\nworkflow = \"Execute another workflow as a child and return its output\"\n\n# Flow Control\nhuman_confirmation = \"Pause execution and wait for human approval\"\n\n# Output Nodes (terminal -- end the workflow)\nreply_chat = \"Sends a message back to the chat caller and ends the workflow\"\nerror_handler = \"Catches errors from upstream nodes\"\n\n# Agent Tools (attach via tool edges -- not listed in steps)\nplatform_api = \"Make authenticated requests to the platform API\"\nworkflow_create = \"Create workflows from YAML DSL\"\nspawn_and_await = \"Spawn a child workflow and wait for its result\"\nscheduler_tools = \"Manage scheduled recurring jobs\"\n\n# Sub-components (wired by the builder)\nai_model = \"LLM model config -- attached to agent/deep_agent via llm edge\"\nmemory_read = \"Recall tool -- retrieves from global memory\"\nmemory_write = \"Remember tool -- stores to global memory\"\nskill = \"SKILL.md behavioral instructions for agents\"\n\n[rules]\n# Do NOT include trigger as a step -- it goes in the trigger: field only\ntrigger_not_in_steps = true\n\n# Every step from requirements maps to exactly one node\none_node_per_step = true\n\n# Use snake_case for all node IDs\nsnake_case_ids = true\n\n# For non-linear flows, include explicit edge definitions\nexplicit_edges_for_branches = true\n\n[rules.edges]\n# Default flow is linear top-to-bottom (step 1 -> step 2 -> step 3)\n# Only specify edges explicitly when flow is non-linear:\n# - switch branches (conditional edges with labels)\n# - parallel fan-out (one node -> multiple nodes)\n# - loops (back-edges)\n# - merge (multiple nodes -> one node)\nonly_explicit_for_nonlinear = true\n\n[input]\nsource = \"scribe.output\"\ntemplate = \"{{ scribe.output }}\"\n\n[output]\nformat = \"single fenced code block, label: topology.yaml\"\n# No preamble, no explanation -- ONLY the fenced code block\n\n[output.yaml_structure]\n# Top-level fields\nname = \"\"\nslug = \"\"\ntrigger = \"\"\n\n[output.yaml_structure.model]\ncredential_id = 1\nname = \"claude-sonnet-4-20250514\"\n\n[output.yaml_structure.steps]\n# Ordered list matching the Scribe's STEPS\n# Each step has: type, id, label\ntype = \"\"\nid = \"\"\nlabel = \"\"\n\n[output.yaml_structure.edges]\n# Only include when flow is non-linear\nfrom = \"\"\nto = \"\"\ncondition = \"