diff --git a/platform/components/__init__.py b/platform/components/__init__.py index f295dfb..f60a1d0 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 0000000..5c03b39 --- /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") + + results_list = [] + for rule, result in zip(rules, results): + results_list.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": results_list, + }, + } + + return assertion_node diff --git a/platform/components/operators.py b/platform/components/operators.py index 42c4240..2868b86 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 b087dbc..efa05bc 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__" diff --git a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx index f33d54c..843790c 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) @@ -206,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") @@ -390,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 } } @@ -1375,119 +1322,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 +1389,52 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { setFilterField(e.target.value)} className="text-xs h-7 font-mono" placeholder="e.g. items" /> + + rules={filterRules} + onChange={setFilterRules} + upstreamNodes={upstreamNodes} + emptyMessage="No rules defined. All items will pass through." + /> + + + )} + + {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

+
+
- {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" + {assertionJudge && ( +
+ +
+ setAssertionThreshold(Number(e.target.value))} + className="flex-1 h-1.5 accent-indigo-500" /> + {assertionThreshold.toFixed(2)}
-
- - -
- {!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" - /> -
- )}
- ))} + )}
)} diff --git a/platform/frontend/src/features/workflows/components/NodePalette.tsx b/platform/frontend/src/features/workflows/components/NodePalette.tsx index 45bfcd7..0ef7211 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[] }[] = [ @@ -63,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/RuleEditor.tsx b/platform/frontend/src/features/workflows/components/RuleEditor.tsx new file mode 100644 index 0000000..a44ffd3 --- /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, 11) + Date.now().toString(36) +} + +/** 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/features/workflows/components/WorkflowCanvas.tsx b/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx index 4d95091..f3c3655 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 ( @@ -210,8 +212,8 @@ function WorkflowNodeComponent({ data, selected }: { data: { label: string; comp )} - {isFixedWidth && !isSwitch && !isLoop &&
} - {isFixedWidth && !isSwitch && !isLoop && ( + {isFixedWidth && !isSwitch && !isLoop && !isAssertion &&
} + {isFixedWidth && !isSwitch && !isLoop && !isAssertion && (
{hasModel && (
@@ -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 diff --git a/platform/frontend/src/types/models.ts b/platform/frontend/src/types/models.ts index a9aadb3..644d15c 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 } diff --git a/platform/models/node.py b/platform/models/node.py index d7116a9..1be3795 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"} @@ -263,6 +267,7 @@ class _ReplyChatConfig(BaseComponentConfig): "router": AIComponentConfig, "extractor": AIComponentConfig, "switch": OtherComponentConfig, + "assertion": _AssertionConfig, "code": CodeComponentConfig, "loop": CodeComponentConfig, "filter": CodeComponentConfig, diff --git a/platform/schemas/node.py b/platform/schemas/node.py index d53ba7c..5b76b91 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 9bf06e8..627160a 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 db34877..b79e4ee 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", @@ -573,6 +574,20 @@ def _build_step_config( "channel": step.get("channel", ""), } + elif step_type == "assertion": + rules = [] + for rule in step.get("rules", []): + rules.append({ + "id": rule.get("id", ""), + "field": rule.get("field", ""), + "operator": rule.get("operator", "equals"), + "value": rule.get("value", ""), + }) + config["extra_config"] = { + "rules": rules, + "pass_threshold": step.get("pass_threshold", 1.0), + } + elif step_type == "switch": rules = [] for rule in step.get("rules", []): diff --git a/platform/tests/dsl_fixtures/workflow_generator/fixture.json b/platform/tests/dsl_fixtures/workflow_generator/fixture.json index 979e7dc..acf3d26 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 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)\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 = \"