From 1ffe521ad862d4ab19cb5bc8988a99c49e91e8a1 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 19 Apr 2026 01:31:57 +0200
Subject: [PATCH] grapheditor enhanced para set for ai
---
.../routeFeatureGraphicalEditor.py | 31 +-
.../services/serviceAgent/workflowTools.py | 303 +++++++++++++++++-
2 files changed, 317 insertions(+), 17 deletions(-)
diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
index 7b30ec16..494cebb9 100644
--- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
@@ -757,12 +757,31 @@ async def _runEditorAgent(
"for the user — you must NEVER execute the workflow or any of its actions. "
"Even when the user says 'create a workflow that sends an email', you build the "
"graph (e.g. add an email node, connect it) — you do NOT actually send an email. "
- "Use these workflow tools to mutate the graph: "
- "readWorkflowGraph, listAvailableNodeTypes, addNode, removeNode, connectNodes, "
- "setNodeParameter, validateGraph. "
- "Always read the current graph and list available node types first, then plan the "
- "smallest set of mutations, then apply them. Respond concisely in the user's "
- "language and confirm what you changed in the graph."
+ "\n\nGraph-mutating tools: readWorkflowGraph, listAvailableNodeTypes, "
+ "describeNodeType, addNode, removeNode, connectNodes, setNodeParameter, "
+ "autoLayoutWorkflow, validateGraph. "
+ "Connection discovery (for parameters of frontendType='userConnection'): listConnections."
+ "\n\nMandatory build sequence:"
+ "\n1. readWorkflowGraph — understand current state."
+ "\n2. listAvailableNodeTypes — find candidate node ids."
+ "\n3. For EACH node type you plan to add: call describeNodeType(nodeType=...) "
+ "to learn its requiredParameters, allowedValues and ports. Never skip this "
+ "step — guessing parameters leaves the user with empty config cards."
+ "\n4. If any required parameter has frontendType='userConnection' (e.g. "
+ "email.checkEmail.connectionReference), call listConnections and pick the "
+ "connectionId that matches the user's intent (or ask the user if none clearly fits)."
+ "\n5. addNode with parameters={...} containing AT LEAST every requiredParameter "
+ "filled with a sensible value (use the user's request, the parameter "
+ "description, sane defaults, or — for required user-connection fields — "
+ "an actual connectionId). Do NOT pass position; the layout step handles it."
+ "\n6. connectNodes — wire the nodes consistent with port schemas from describeNodeType."
+ "\n7. autoLayoutWorkflow — call exactly once as the LAST graph-mutating step so the "
+ "canvas shows a readable top-down layout instead of overlapping boxes."
+ "\n8. validateGraph — sanity check, then answer the user."
+ "\n\nIf a required parameter cannot be filled from the user's request and has "
+ "no safe default, ask the user once for that specific value (e.g. recipient "
+ "address, target language, prompt text) instead of leaving the field blank. "
+ "Respond concisely in the user's language and list what you changed in the graph."
)
editorConfig = AgentConfig(
diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py
index 3e1bb9ad..e0be2278 100644
--- a/modules/serviceCenter/services/serviceAgent/workflowTools.py
+++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py
@@ -3,7 +3,8 @@
"""
Workflow Toolbox - AI-assisted graph manipulation tools for the GraphicalEditor.
Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter,
- listAvailableNodeTypes, validateGraph, listWorkflowHistory, readWorkflowMessages.
+ listAvailableNodeTypes, describeNodeType, autoLayoutWorkflow,
+ validateGraph, listWorkflowHistory, readWorkflowMessages.
Conventions enforced here (matches coreTools / actionToolAdapter):
- Every ``ToolResult(...)`` provides ``toolCallId`` and ``toolName`` (pydantic
@@ -144,14 +145,32 @@ async def _addNode(params: Dict[str, Any], context: Any) -> ToolResult:
nodeId = params.get("nodeId") or str(uuid.uuid4())[:8]
title = params.get("title", "")
nodeParams = params.get("parameters", {}) or {}
- position = params.get("position") or {"x": len(nodes) * 200, "y": 100}
+
+ # Frontend stores positions as TOP-LEVEL ``x`` / ``y`` on the node
+ # (see ``fromApiGraph`` / ``toApiGraph``). Accept either explicit
+ # ``x`` / ``y`` or a ``position={x,y}`` shape from the model and
+ # always persist as top-level ``x`` / ``y``. Fallback puts new
+ # nodes in a horizontal stripe so the user sees them even before
+ # ``autoLayoutWorkflow`` runs.
+ position = params.get("position") or {}
+ x = params.get("x")
+ if x is None:
+ x = position.get("x") if isinstance(position, dict) else None
+ if x is None:
+ x = 40 + len(nodes) * 260
+ y = params.get("y")
+ if y is None:
+ y = position.get("y") if isinstance(position, dict) else None
+ if y is None:
+ y = 40
newNode = {
"id": nodeId,
"type": nodeType,
"title": title,
"parameters": nodeParams,
- "position": position,
+ "x": x,
+ "y": y,
}
nodes.append(newNode)
graph["nodes"] = nodes
@@ -287,8 +306,55 @@ def _coerceLabel(rawLabel: Any, fallback: str) -> str:
return fallback
+def _summarizeNodeForCatalog(n: Dict[str, Any]) -> Dict[str, Any]:
+ """Compact summary used in ``listAvailableNodeTypes`` — small but
+ informative enough that the model can pick the right type and knows
+ whether ``describeNodeType`` is worth a follow-up call."""
+ nodeId = n.get("id") or ""
+ paramsList = n.get("parameters") or []
+ requiredCount = sum(1 for p in paramsList if isinstance(p, dict) and p.get("required"))
+ return {
+ "id": nodeId,
+ "category": n.get("category"),
+ "label": _coerceLabel(n.get("label"), nodeId),
+ "description": _coerceLabel(n.get("description"), ""),
+ "paramCount": len(paramsList),
+ "requiredParamCount": requiredCount,
+ "usesAi": bool(((n.get("meta") or {}).get("usesAi"))),
+ }
+
+
+def _summarizeParameter(p: Dict[str, Any]) -> Dict[str, Any]:
+ """Reduce a node parameter spec to just what the AI needs to fill it."""
+ out: Dict[str, Any] = {
+ "name": p.get("name"),
+ "type": p.get("type"),
+ "required": bool(p.get("required")),
+ "frontendType": p.get("frontendType"),
+ "description": _coerceLabel(p.get("description"), ""),
+ }
+ if "default" in p:
+ out["default"] = p.get("default")
+ feOpts = p.get("frontendOptions")
+ if isinstance(feOpts, dict):
+ # Expose enum-style choices ("options") so the model sticks to allowed values.
+ if isinstance(feOpts.get("options"), list):
+ out["allowedValues"] = feOpts.get("options")
+ if p.get("frontendType") == "userConnection":
+ out["hint"] = (
+ "Call listConnections to discover available connections; pass the "
+ "connectionId here. Required before this node can run."
+ )
+ return out
+
+
async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolResult:
- """List all available node types for the flow builder."""
+ """List all available node types for the flow builder (compact catalog).
+
+ Returns ``id``, ``category``, ``label``, short ``description``, and the
+ parameter counts. To learn HOW to fill a node's parameters use
+ ``describeNodeType(nodeType=...)`` — that returns the full schema.
+ """
name = "listAvailableNodeTypes"
try:
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
@@ -296,18 +362,188 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR
for n in STATIC_NODE_TYPES:
if not isinstance(n, dict):
continue
- nodeId = n.get("id") or ""
- nodeTypes.append({
- "id": nodeId,
- "category": n.get("category"),
- "label": _coerceLabel(n.get("label"), nodeId),
- })
+ nodeTypes.append(_summarizeNodeForCatalog(n))
return _ok(name, {"nodeTypes": nodeTypes, "count": len(nodeTypes)})
except Exception as e:
logger.exception("listAvailableNodeTypes failed: %s", e)
return _err(name, str(e))
+async def _describeNodeType(params: Dict[str, Any], context: Any) -> ToolResult:
+ """Return the full schema for a single node type so the AI can fill
+ ``addNode.parameters`` correctly (which fields are required, what types,
+ default values, allowed enum values, what each port expects/produces).
+
+ This is the canonical way to discover required parameters before
+ calling ``addNode`` — without it the model guesses ``parameters={}``
+ and the user gets empty configuration cards.
+ """
+ name = "describeNodeType"
+ try:
+ nodeType = params.get("nodeType") or params.get("id")
+ if not nodeType:
+ return _err(name, "nodeType required")
+ from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
+ target: Dict[str, Any] = {}
+ for n in STATIC_NODE_TYPES:
+ if isinstance(n, dict) and n.get("id") == nodeType:
+ target = n
+ break
+ if not target:
+ return _err(name, f"Unknown nodeType '{nodeType}' — call listAvailableNodeTypes first")
+
+ rawParams = target.get("parameters") or []
+ parameters = [
+ _summarizeParameter(p) for p in rawParams if isinstance(p, dict)
+ ]
+
+ def _portList(portsDict: Any) -> List[Dict[str, Any]]:
+ if not isinstance(portsDict, dict):
+ return []
+ out: List[Dict[str, Any]] = []
+ for idx, spec in sorted(portsDict.items(), key=lambda kv: int(kv[0]) if str(kv[0]).isdigit() else 0):
+ if not isinstance(spec, dict):
+ continue
+ entry: Dict[str, Any] = {"index": int(idx) if str(idx).isdigit() else idx}
+ if "schema" in spec:
+ entry["schema"] = spec.get("schema")
+ if "accepts" in spec:
+ entry["accepts"] = spec.get("accepts")
+ out.append(entry)
+ return out
+
+ meta = target.get("meta") or {}
+ return _ok(name, {
+ "id": target.get("id"),
+ "category": target.get("category"),
+ "label": _coerceLabel(target.get("label"), target.get("id") or ""),
+ "description": _coerceLabel(target.get("description"), ""),
+ "usesAi": bool(meta.get("usesAi")),
+ "inputs": int(target.get("inputs") or 0),
+ "outputs": int(target.get("outputs") or 0),
+ "inputPorts": _portList(target.get("inputPorts")),
+ "outputPorts": _portList(target.get("outputPorts")),
+ "parameters": parameters,
+ "requiredParameters": [p["name"] for p in parameters if p.get("required")],
+ })
+ except Exception as e:
+ logger.exception("describeNodeType failed: %s", e)
+ return _err(name, str(e))
+
+
+# Geometry constants — MUST match the frontend (FlowCanvas.tsx) so the
+# server-side auto-layout produces the exact same coordinates the user
+# would get by clicking "Arrange" in the UI.
+_NODE_WIDTH = 200
+_NODE_HEIGHT = 72
+_LAYOUT_V_GAP = 80
+_LAYOUT_H_GAP = 60
+_LAYOUT_START_X = 40
+_LAYOUT_START_Y = 40
+
+
+def _computeAutoLayout(
+ nodes: List[Dict[str, Any]],
+ connections: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Topological-layer layout — port of ``computeAutoLayout`` in FlowCanvas.tsx.
+
+ Arranges nodes top-to-bottom in layers (one layer per BFS step from the
+ sources). Disconnected nodes are appended as extra single-node layers,
+ same as the frontend. Returns a NEW node list with updated top-level
+ ``x``/``y``; legacy ``position`` keys are stripped to avoid two
+ competing sources of truth.
+ """
+ if not nodes:
+ return nodes
+
+ nodeIds = {n.get("id") for n in nodes if n.get("id")}
+ inDegree: Dict[str, int] = {nid: 0 for nid in nodeIds if nid}
+ children: Dict[str, List[str]] = {nid: [] for nid in nodeIds if nid}
+
+ for c in connections or []:
+ src = c.get("source")
+ tgt = c.get("target")
+ if src in inDegree and tgt in inDegree:
+ inDegree[tgt] = inDegree[tgt] + 1
+ children[src].append(tgt)
+
+ layers: List[List[str]] = []
+ layerOf: Dict[str, int] = {}
+ queue: List[str] = [nid for nid, deg in inDegree.items() if deg == 0]
+
+ while queue:
+ batch = list(queue)
+ queue = []
+ layerIdx = len(layers)
+ layers.append(batch)
+ for nid in batch:
+ layerOf[nid] = layerIdx
+ for childId in children.get(nid, []):
+ inDegree[childId] = inDegree[childId] - 1
+ if inDegree[childId] == 0:
+ queue.append(childId)
+
+ # Cycles: append remaining nodes as their own layers (matches frontend).
+ for n in nodes:
+ nid = n.get("id")
+ if nid and nid not in layerOf:
+ layerIdx = len(layers)
+ layers.append([nid])
+ layerOf[nid] = layerIdx
+
+ out: List[Dict[str, Any]] = []
+ for n in nodes:
+ nid = n.get("id")
+ layer = layerOf.get(nid, 0) if nid else 0
+ siblings = layers[layer] if 0 <= layer < len(layers) else [nid]
+ idxInLayer = siblings.index(nid) if nid in siblings else 0
+ new = dict(n)
+ new["x"] = _LAYOUT_START_X + idxInLayer * (_NODE_WIDTH + _LAYOUT_H_GAP)
+ new["y"] = _LAYOUT_START_Y + layer * (_NODE_HEIGHT + _LAYOUT_V_GAP)
+ # Strip legacy ``position`` so frontend never sees two coordinates.
+ new.pop("position", None)
+ out.append(new)
+ return out
+
+
+async def _autoLayoutWorkflow(params: Dict[str, Any], context: Any) -> ToolResult:
+ """Re-arrange all nodes of the workflow into a clean top-down layered layout.
+
+ Same algorithm as the editor's "Arrange" button — call this after you
+ finished adding/connecting nodes so the user doesn't see an unreadable
+ pile of overlapping boxes.
+ """
+ name = "autoLayoutWorkflow"
+ try:
+ workflowId, instanceId = _resolveIds(params, context)
+ if not workflowId or not instanceId:
+ return _err(name, "workflowId and instanceId required (and not present in agent context)")
+
+ iface = _getInterface(context, instanceId)
+ wf = iface.getWorkflow(workflowId)
+ if not wf:
+ return _err(name, f"Workflow {workflowId} not found")
+
+ graph = dict(wf.get("graph", {}) or {})
+ nodes = list(graph.get("nodes", []) or [])
+ connections = list(graph.get("connections", []) or [])
+ if not nodes:
+ return _ok(name, {"message": "No nodes to layout", "nodeCount": 0})
+
+ graph["nodes"] = _computeAutoLayout(nodes, connections)
+ iface.updateWorkflow(workflowId, {"graph": graph})
+
+ return _ok(name, {
+ "message": f"Auto-layout applied to {len(nodes)} nodes",
+ "nodeCount": len(nodes),
+ "layerCount": max((c.get("y", 0) for c in graph["nodes"]), default=_LAYOUT_START_Y) // (_NODE_HEIGHT + _LAYOUT_V_GAP) + 1,
+ })
+ except Exception as e:
+ logger.exception("autoLayoutWorkflow failed: %s", e)
+ return _err(name, str(e))
+
+
async def _validateGraph(params: Dict[str, Any], context: Any) -> ToolResult:
"""Validate a workflow graph for common issues."""
name = "validateGraph"
@@ -507,11 +743,56 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
{
"name": "listAvailableNodeTypes",
"handler": _listAvailableNodeTypes,
- "description": "List all available node types for the flow builder. Call this once to discover ids before using addNode.",
+ "description": (
+ "List all available node types (compact catalog: id, label, "
+ "description, paramCount, requiredParamCount, usesAi). Call this "
+ "once to discover ids; then call describeNodeType for each type "
+ "you intend to add to learn the parameter schema."
+ ),
"parameters": {"type": "object", "properties": {}},
"readOnly": True,
"toolSet": TOOLBOX_ID,
},
+ {
+ "name": "describeNodeType",
+ "handler": _describeNodeType,
+ "description": (
+ "Return the FULL parameter schema for a single node type "
+ "(name, type, required, default, allowedValues, description) "
+ "plus input/output ports. ALWAYS call this before addNode for "
+ "any node type that has requiredParamCount > 0, and pass all "
+ "required parameters into addNode — otherwise the user sees an "
+ "empty configuration card. For parameters with "
+ "frontendType='userConnection' call listConnections to obtain "
+ "a connectionId."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "nodeType": {"type": "string", "description": "Node type id from listAvailableNodeTypes (e.g. 'email.checkEmail', 'ai.prompt')"},
+ },
+ "required": ["nodeType"],
+ },
+ "readOnly": True,
+ "toolSet": TOOLBOX_ID,
+ },
+ {
+ "name": "autoLayoutWorkflow",
+ "handler": _autoLayoutWorkflow,
+ "description": (
+ "Re-arrange ALL nodes into a clean top-down layered layout "
+ "(same algorithm as the editor's 'Arrange' button). Call this "
+ "AFTER you finished adding nodes and connections, otherwise the "
+ "user sees a pile of overlapping boxes. Idempotent — safe to "
+ "call multiple times."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {**_idFields},
+ "required": [],
+ },
+ "toolSet": TOOLBOX_ID,
+ },
{
"name": "validateGraph",
"handler": _validateGraph,