// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package dsl — single-shape canvas normalizer. // // The RAGFlow agent DSL has exactly one canonical wire shape: // // { // "globals": { ... }, // "graph": { "nodes": [...], "edges": [...] }, // React-Flow layout // "variables": { ... }, // "components": { ":": { // execution topology // "downstream": [...], "upstream": [...], // "obj": { "component_name": "Name", "params": {...} } // }}, // "path": [...], "retrieval": {...}, "history": [...] // } // // Go server code (handler/service/Compile) reads the `components` block — // the Python server (agent/canvas.py) does the same. The `graph` block is // consumed by the React-Flow front-end to render the canvas. Either side // can be missing on a given row (e.g. a hand-imported v1 export from a // Python server has `components` but no `graph`; a v1 fixture from the // Go port test suite has `graph` and `components` but a slightly // different internal layout convention). // // NormalizeForCanvas is the decoder-boundary entry point for every // front-end-facing Go path (handler.AgentHandler, service.AgentService // create/update/publish/reset, version reads). The function: // // 1. Repairs React-Flow handle ids on whatever `graph.edges` are present // (source/target handle ids: source=start, target=end). // 2. If `graph.nodes` is missing but `components` is non-empty, builds // a default-layout graph from the components (deterministic order, // 50/200/350 px column layout). // 3. Repairs any historically leaked runtime-only Parallel / // parallelNode canvas shape back to the front-end's Iteration / // iterationNode protocol. // 4. Returns a defensive copy of the input with all transforms // applied. Never mutates its input. // // The function never panics on malformed input; unparseable entries are // skipped and a best-effort graph is returned. // // IMPORTANT: this function preserves the front-end canvas protocol. It // must not leak runtime-only node kinds (for example "Parallel" / // "parallelNode") into `graph.nodes` or rewrite user-authored DSL // semantics. Runtime-only folding lives in NormalizeForRun. package dsl import ( "regexp" "sort" ) // componentNameIteration / componentNameIterationItem are the legacy // v1 names that the front-end may still emit. The Go port's runtime // uses "Parallel" for the same concept; this constant is the // pre-rename label. const ( componentNameLoop = "Loop" componentNameLoopItem = "LoopItem" componentNameIteration = "Iteration" componentNameIterationItem = "IterationItem" componentNameParallel = "Parallel" ) var legacyIterationAliasPattern = regexp.MustCompile(`IterationItem:[A-Za-z0-9_:-]+@(item|index)\b`) // NormalizeForCanvas returns a defensive copy of dsl with a derived // `graph.nodes`/`graph.edges` block when missing. // // Behaviour: // - nil in, nil out. // - graph.nodes already non-empty: handle ids are still repaired in // place (idempotent). Otherwise graph is derived from components. // - empty / components:{}: no-op, returns dsl as-is. // - any components: builds graph only. // - any historically leaked Parallel / parallelNode canvas state is // repaired back to Iteration / iterationNode. // // The function never panics on malformed input; unparseable entries are // skipped and a best-effort graph is returned. func NormalizeForCanvas(dsl map[string]any) map[string]any { return normalize(dsl, false) } // NormalizeForRun prepares a DSL for the runtime/compiler path. Unlike // NormalizeForCanvas, it is allowed to fold legacy LoopItem / // IterationItem children away and rename Iteration to Parallel because // the returned map never goes back to the front-end. func NormalizeForRun(dsl map[string]any) map[string]any { return normalize(dsl, true) } func normalize(dsl map[string]any, foldLegacy bool) map[string]any { if dsl == nil { return nil } // Defensive deep copy: the normalize pipeline rewrites // graph.edges[*].sourceHandle / targetHandle, deletes // components entries, and mutates components[*].obj.component_name // — all in place. Without the deep copy, callers that reuse // the original decoded DSL map would observe side effects. out := deepCopyDSL(dsl) // (1) Repair React-Flow handle ids on whatever edges exist. enforceHandleIds(out) // (2) Build a default-layout graph if missing. if !graphHasNodes(out) { rawComps, _ := out["components"].(map[string]any) if len(rawComps) > 0 { nodes, edges, normComps := buildGraphFromComponents(rawComps) if len(nodes) > 0 { out["graph"] = map[string]any{ "nodes": nodes, "edges": edges, } out["components"] = normComps } } } // (3) Repair any historically leaked runtime-only Parallel / // parallelNode view back to the front-end's Iteration / // iterationNode protocol. This keeps response payloads // renderable without exposing backend implementation details. repairParallelLeaksForCanvas(out) if foldLegacy { // (4) Runtime-only compatibility: fold legacy Loop+LoopItem and // Iteration+IterationItem pairs in place. This step uses // graph.nodes[*].parentId to discover parent/child // relationships; if `graph` is still missing the fold degrades // to a pure rename (component_name: "Iteration" → "Parallel"; // LoopItem/IterationItem names stay in components but // downstream compile/expand paths must tolerate them). foldLegacyLoopVariants(out) rewriteLegacyIterationAliases(out) } return out } // rewriteLegacyIterationAliases rewrites runtime-only references to the // legacy IterationItem child's synthetic outputs back to the modern // item/index aliases that CanvasState exposes. This runs only on the // runtime-normalized copy, never on the front-end-facing canvas view. func rewriteLegacyIterationAliases(dsl map[string]any) { for k, v := range dsl { switch x := v.(type) { case string: dsl[k] = replaceLegacyIterationAliasRefs(x) case map[string]any: rewriteLegacyIterationAliases(x) case []any: rewriteLegacyIterationAliasesInSlice(x) } } } func rewriteLegacyIterationAliasesInSlice(items []any) { for i, v := range items { switch x := v.(type) { case string: items[i] = replaceLegacyIterationAliasRefs(x) case map[string]any: rewriteLegacyIterationAliases(x) case []any: rewriteLegacyIterationAliasesInSlice(x) } } } func replaceLegacyIterationAliasRefs(s string) string { return legacyIterationAliasPattern.ReplaceAllStringFunc(s, func(match string) string { sub := legacyIterationAliasPattern.FindStringSubmatch(match) if len(sub) != 2 { return match } alias := sub[1] switch alias { case "item", "index": return alias default: return match } }) } // repairParallelLeaksForCanvas rewrites any historically leaked // runtime-only Parallel / parallelNode view back to the front-end's // Iteration / iterationNode protocol. This is a response-shape repair // only; it does not perform parent/child folding. func repairParallelLeaksForCanvas(dsl map[string]any) { rawComps, _ := dsl["components"].(map[string]any) for _, raw := range rawComps { comp, _ := raw.(map[string]any) if comp == nil { continue } if obj, ok := comp["obj"].(map[string]any); ok { if obj["component_name"] == componentNameParallel { obj["component_name"] = componentNameIteration } } if comp["name"] == componentNameParallel { comp["name"] = componentNameIteration } } graph, _ := dsl["graph"].(map[string]any) if graph == nil { return } nodes, _ := graph["nodes"].([]any) for _, raw := range nodes { node, _ := raw.(map[string]any) if node == nil { continue } if node["type"] == "parallelNode" { node["type"] = "iterationNode" } data, _ := node["data"].(map[string]any) if data == nil { continue } if data["label"] == componentNameParallel { data["label"] = componentNameIteration } if data["name"] == componentNameParallel { data["name"] = componentNameIteration } } } // enforceHandleIds rewrites graph.edges[*].sourceHandle / targetHandle // to the front-end's React Flow convention. Tool/agent handles (id != // "end" on source / != "start" on target) are left alone because they // are not produced by the basic component DAG. func enforceHandleIds(dsl map[string]any) { graph, _ := dsl["graph"].(map[string]any) if graph == nil { return } edges, _ := graph["edges"].([]any) if len(edges) == 0 { return } for _, e := range edges { m, _ := e.(map[string]any) if m == nil { continue } // Only rewrite plain "start"/"end" conventions. Agent/tool // handles carry semantic info we must not stomp. if src, _ := m["sourceHandle"].(string); src == "end" || src == "start" { m["sourceHandle"] = "start" } if dst, _ := m["targetHandle"].(string); dst == "start" || dst == "end" { m["targetHandle"] = "end" } } } // graphHasNodes reports whether the input already carries a non-empty // React-Flow-shaped graph. Any missing / wrong-typed sub-key counts as // "no graph", which is the conservative default. func graphHasNodes(dsl map[string]any) bool { graph, ok := dsl["graph"].(map[string]any) if !ok { return false } nodes, ok := graph["nodes"].([]any) if !ok { return false } return len(nodes) > 0 } // buildGraphFromComponents converts the `components` block into // React-Flow-shaped nodes + edges and a normalised (flat) components // map keyed the same way the input was. // // Layout strategy: simple left-to-right single row, x = 50 + i*350, // y = 200. Cycles are not detected — every component gets its own slot // in iteration order. The user is expected to re-arrange via the // front-end, which is consistent with how legacy data used to be // surfaced to the editor before the bug-fix. func buildGraphFromComponents(components map[string]any) (nodes []any, edges []any, normalized map[string]any) { nodes = make([]any, 0, len(components)) edges = make([]any, 0) normalized = make(map[string]any, len(components)) // Go's map iteration is randomised. Sort the component ids before // iterating so the layout (x = 50 + i*350) is a stable function of // the input dsl, not of Go's runtime iteration lottery. Two // normalize passes over the same dsl also used to produce // `components` and `graph.nodes` in different orders, which broke // the dslToGraph equality invariant; sorting here closes that gap. keys := make([]string, 0, len(components)) for k := range components { keys = append(keys, k) } sort.Strings(keys) const xStep = 350.0 const yBase = 200.0 i := 0 for _, key := range keys { raw := components[key] comp, _ := raw.(map[string]any) if comp == nil { continue } name, params, downstream := extractComponent(comp) if name == "" { name = key } node := map[string]any{ "id": key, "type": componentNameToNodeType(name), "position": map[string]any{"x": 50.0 + float64(i)*xStep, "y": yBase}, // Always emit `data.form` (even when empty) so the React // Flow node shape is byte-equal between the Python v1 // fallback (which reads `obj.params` and may be `{}`) and // the Go v2 path. The same invariant applies to the // normalised components map above. "data": map[string]any{"label": name, "name": name, "form": params}, "sourcePosition": "right", "targetPosition": "left", } nodes = append(nodes, node) for _, dst := range downstream { edges = append(edges, map[string]any{ "id": "xy-edge__" + key + "-" + dst, "source": key, "target": dst, // Source/target handle ids match the front-end's React Flow // convention (web/src/pages/agent/hooks/use-add-node.ts:114): // source node renders its OUTPUT handle with id = "start" // target node renders its INPUT handle with id = "end" "sourceHandle": "start", "targetHandle": "end", }) } flat := map[string]any{ "id": key, "name": name, "downstream": toStringSlice(comp["downstream"]), "upstream": toStringSlice(comp["upstream"]), // Always emit `params` (even when empty) so the normalised // component shape matches the Python v1 server byte-for-byte. "params": params, } normalized[key] = flat i++ } return nodes, edges, normalized } // extractComponent pulls (name, params, downstream) out of a component // block. The Go port stores them flat (`name` / `params` at top // level). Returns empty values for missing fields. func extractComponent(comp map[string]any) (name string, params map[string]any, downstream []string) { if obj, ok := comp["obj"].(map[string]any); ok { name, _ = obj["component_name"].(string) if p, ok := obj["params"].(map[string]any); ok { params = p } // Read obj.downstream first; the trailing outer-downstream // append below handles the case where the v1 writer put the // topology on the outer field. Use a local var so the nil // check stays unambiguous to the nilness analyser. var ds []string ds = toStringSlice(obj["downstream"]) if len(ds) > 0 { downstream = ds } } if name == "" { name, _ = comp["name"].(string) } if params == nil { if p, ok := comp["params"].(map[string]any); ok { params = p } } downstream = append(downstream, toStringSlice(comp["downstream"])...) return name, params, downstream } func toStringSlice(v any) []string { arr, ok := v.([]any) if !ok { return nil } out := make([]string, 0, len(arr)) for _, x := range arr { if s, ok := x.(string); ok && s != "" { out = append(out, s) } } if len(out) == 0 { return nil } return out } // deepCopyDSL returns a deep copy of the parts of `dsl` that // NormalizeForCanvas mutates: the top-level keys "graph" and // "components", and within `graph` the "nodes" and "edges" slices. // All other top-level keys (`globals`, `variables`, `path`, // `retrieval`, `history`, `*`) are shallow-copied by reference — // they are read-only and never modified by the normalize pipeline. // // The deep copy is required because: // - enforceHandleIds rewrites graph.edges[*].sourceHandle / // targetHandle in place. // - foldLegacyLoopVariants deletes entries from components, // rewrites components[*].obj.component_name, and rewrites // graph.nodes[*].data.label / type. // // Without the deep copy, a caller that reuses the original // decoded DSL map (e.g. for re-validation or diffing) would // observe side effects that contradict the documented // "never mutates its input" contract. // // Primitives and non-mutable values (string, number, bool) are // shared by reference; only the maps and slices that the // normalize pipeline touches are duplicated. func deepCopyDSL(dsl map[string]any) map[string]any { out := make(map[string]any, len(dsl)+1) for k, v := range dsl { switch k { case "graph": if g, ok := v.(map[string]any); ok { out["graph"] = deepCopyGraph(g) } else { out["graph"] = v } case "components": if c, ok := v.(map[string]any); ok { out["components"] = deepCopyComponents(c) } else { out["components"] = v } default: // Shallow: globals, variables, path, retrieval, // history, and any other top-level key are not // mutated by the normalize pipeline. Sharing the // reference is safe. out[k] = v } } return out } // deepCopyAny returns a recursive deep copy of v. Maps and slices // are duplicated recursively; primitives and nil are passed through. // This ensures mutations to nested fields (e.g. data.label, data.name) // never alias into the caller's original input. func deepCopyAny(v any) any { switch x := v.(type) { case map[string]any: out := make(map[string]any, len(x)) for k, val := range x { out[k] = deepCopyAny(val) } return out case []any: out := make([]any, len(x)) for i, val := range x { out[i] = deepCopyAny(val) } return out default: return v } } // deepCopyGraph copies a graph block. Nodes and edges are deep-copied // element-by-element so that later mutations (e.g. data.label rewrite // in fixComponentNames) target the copy, not the caller's input. func deepCopyGraph(g map[string]any) map[string]any { out := make(map[string]any, len(g)) for k, v := range g { switch k { case "nodes": if nodes, ok := v.([]any); ok { copied := make([]any, len(nodes)) for i, n := range nodes { copied[i] = deepCopyAny(n) } out["nodes"] = copied } else { out["nodes"] = v } case "edges": if edges, ok := v.([]any); ok { copied := make([]any, len(edges)) for i, e := range edges { copied[i] = deepCopyAny(e) } out["edges"] = copied } else { out["edges"] = v } default: out[k] = v } } return out } // deepCopyComponents copies a components block. Each component // entry is a new map; the `obj` sub-map (when present) is also // deep-copied so rewrites to component_name / params land on // the copy. func deepCopyComponents(c map[string]any) map[string]any { out := make(map[string]any, len(c)) for k, v := range c { if cm, ok := v.(map[string]any); ok { entry := deepCopyAny(cm).(map[string]any) if obj, ok := cm["obj"].(map[string]any); ok { entry["obj"] = deepCopyAny(obj) } out[k] = entry } else { out[k] = v } } return out } // copyMapStringAny returns a shallow copy of m. The new map // aliases the original values; callers that need a deeper copy // recurse on their own (e.g. deepCopyGraph / deepCopyComponents // recurse on `obj` and on each node / edge). func copyMapStringAny(m map[string]any) map[string]any { out := make(map[string]any, len(m)) for k, v := range m { out[k] = v } return out } // stringsToAny is the inverse of toStringSlice: it packs a []string // back into a []any so a downstream `.([]any)` type assertion // succeeds. The fold step needs this because it computes a // []string and the parent component's downstream slot is consumed // elsewhere as []any. func stringsToAny(s []string) []any { out := make([]any, 0, len(s)) for _, x := range s { if x == "" { continue } out = append(out, x) } if len(out) == 0 { return []any{} } return out } // componentNameToNodeType maps a component_name to the front-end React // Flow node type. Unknown names fall back to "agentNode" — the // front-end re-derives the operator from `data.label`, so an unknown // type is still rendered (just possibly in a generic shape). The user // can re-pick a type from the operator palette to refine. var componentNameToNodeTypeMap = map[string]string{ "Begin": "beginNode", "Retrieval": "ragNode", "Categorize": "categorizeNode", "Message": "messageNode", "Answer": "messageNode", "RewriteQuestion": "rewriteNode", "ExeSQL": "toolNode", "Switch": "switchNode", "Agent": "agentNode", "Tool": "toolNode", "File": "fileNode", "Parser": "parserNode", "Tokenizer": "tokenizerNode", "TokenChunker": "chunkerNode", "TitleChunker": "chunkerNode", "Extractor": "contextNode", "Loop": "loopNode", "LoopStart": "loopStartNode", "ExitLoop": "exitLoopNode", "Iteration": "iterationNode", "IterationStart": "iterationStartNode", "Parallel": "parallelNode", "DataOperations": "dataOperationsNode", "ListOperations": "listOperationsNode", "VariableAssigner": "variableAssignerNode", "VariableAggregator": "variableAggregatorNode", "Keyword": "keywordNode", "Note": "noteNode", "Placeholder": "placeholderNode", "Code": "toolNode", } func componentNameToNodeType(name string) string { if t, ok := componentNameToNodeTypeMap[name]; ok { return t } return "agentNode" } // foldLegacyLoopVariants collapses Loop+LoopItem and Iteration+IterationItem // node pairs into single Loop / Parallel nodes. The fold runs at the // decoder boundary so every Go path (handler, service, future Compile) // inherits the compatibility for free. // // The algorithm: // // 1. Build a childOf map from graph.nodes[*].parentId. If graph is // missing, childOf is empty and the fold degrades to a pure rename. // 2. For each component whose component_name is "LoopItem" or // "IterationItem": drop it from components and, if its parent is // known, append the child's downstream to the parent's downstream // (preserving the React-Flow edge topology). // 3. Rename remaining "Iteration" parents to "Parallel" so the // downstream compile / expand paths only need to know about the // modern names. Loop parents keep their canonical "Loop" name. // // Notes: // - Child params are not merged into the parent. The control surface // (loop_variables / loop_termination_condition / items_ref) lives // on the parent; child params typically carry only `outputs` schema // declarations that are derived at runtime, not stored in the dsl. // - This function mutates `dsl` in place (the caller already gave us // a defensive copy at the top of NormalizeForCanvas). func foldLegacyLoopVariants(dsl map[string]any) { rawComps, _ := dsl["components"].(map[string]any) if len(rawComps) == 0 { return } // Build parent-of map from graph.nodes. parentId is a React-Flow // node-level field (verified in testdata/all.json:309). childOf := buildParentMap(dsl) // (1) Walk every component, drop legacy children, append their // downstream to the parent's downstream. for childID, raw := range rawComps { comp, _ := raw.(map[string]any) if comp == nil { continue } childName := componentNameFromComp(comp) if !isLegacyChildName(childName) { continue } parentID, ok := childOf[childID] if !ok { // No parent visible in the graph via parentId // mapping. Keep the child — deleting it could // leave dangling downstream references in the // parent component. continue } parentRaw, ok := rawComps[parentID] if !ok { delete(rawComps, childID) continue } parentComp, _ := parentRaw.(map[string]any) if parentComp == nil { delete(rawComps, childID) continue } // Append child downstream to parent downstream, then drop // the child id itself from the parent's downstream list — // the child is the entry node, not an execution target, so // once folded it must not appear in any edge. The result is // stored as []any (not []string) so a consumer can do a // `parent["downstream"].([]any)` type assertion without // losing data. childDS := toStringSlice(childCompDownstream(comp)) merged := mergeDownstream(toStringSlice(parentComp["downstream"]), childDS) merged = removeFromSlice(merged, childID) parentComp["downstream"] = stringsToAny(merged) // Also append to the parent graph node's downstream, if we // have a graph. This keeps the React-Flow edges in sync with // the topology map. if graph, _ := dsl["graph"].(map[string]any); graph != nil { if nodes, _ := graph["nodes"].([]any); nodes != nil { for _, n := range nodes { nm, _ := n.(map[string]any) if nm == nil { continue } if id, _ := nm["id"].(string); id == parentID { // The graph node's downstream isn't a // standard field; the standard React-Flow // topology is encoded in `edges`. Leave the // graph node alone; the user will re-save // to re-derive edges from components. } } } } // Drop the child from components. delete(rawComps, childID) } // (2) Rename "Iteration" parents to "Parallel". The // `component_name` lives under `obj.component_name` (v1 shape) or // `name` (Go flat shape); we rewrite both keys for safety. for id, raw := range rawComps { comp, _ := raw.(map[string]any) if comp == nil { continue } if componentNameFromComp(comp) != componentNameIteration { continue } if obj, ok := comp["obj"].(map[string]any); ok { obj["component_name"] = componentNameParallel } comp["name"] = componentNameParallel // Also rewrite the graph node label so the React-Flow // renderer's componentNameToNodeTypeMap lookup ("Parallel" → // "parallelNode") succeeds on the next paint. if graph, _ := dsl["graph"].(map[string]any); graph != nil { if nodes, _ := graph["nodes"].([]any); nodes != nil { for _, n := range nodes { nm, _ := n.(map[string]any) if nm == nil || nm["id"] != id { continue } if data, _ := nm["data"].(map[string]any); data != nil { data["label"] = componentNameParallel data["name"] = componentNameParallel } nm["type"] = componentNameToNodeType(componentNameParallel) } } } } } // buildParentMap scans graph.nodes for React-Flow's parentId field and // returns id → parentID. Returns an empty map if graph or nodes is // missing. func buildParentMap(dsl map[string]any) map[string]string { out := map[string]string{} graph, _ := dsl["graph"].(map[string]any) if graph == nil { return out } nodes, _ := graph["nodes"].([]any) if len(nodes) == 0 { return out } for _, n := range nodes { nm, _ := n.(map[string]any) if nm == nil { continue } id, _ := nm["id"].(string) parent, _ := nm["parentId"].(string) if id != "" && parent != "" { out[id] = parent } } return out } // componentNameFromComp returns the component_name from either the // nested `obj` (v1) or the flat `name` (Go) shape. func componentNameFromComp(comp map[string]any) string { if obj, ok := comp["obj"].(map[string]any); ok { if n, _ := obj["component_name"].(string); n != "" { return n } } if n, _ := comp["name"].(string); n != "" { return n } return "" } // childCompDownstream returns a child component's downstream list, // looking at the outer `downstream` (v1) and `obj.downstream` (legacy // v1 double-write) keys. func childCompDownstream(comp map[string]any) any { if d, ok := comp["downstream"]; ok { return d } if obj, ok := comp["obj"].(map[string]any); ok { if d, ok := obj["downstream"]; ok { return d } } return nil } // mergeDownstream returns parent ∪ child in stable order, with parent // entries first. Duplicates dropped. func mergeDownstream(parent, child []string) []string { if len(child) == 0 { return parent } seen := make(map[string]bool, len(parent)+len(child)) out := make([]string, 0, len(parent)+len(child)) for _, s := range parent { if s == "" || seen[s] { continue } seen[s] = true out = append(out, s) } for _, s := range child { if s == "" || seen[s] { continue } seen[s] = true out = append(out, s) } if len(out) == 0 { return []string{} } return out } // removeFromSlice returns a copy of s with the first occurrence of // drop removed (or s unchanged if drop is absent). Used by the loop // fold to filter the child id out of the parent's downstream list // once the child has been merged in. func removeFromSlice(s []string, drop string) []string { if drop == "" { return s } out := make([]string, 0, len(s)) for _, x := range s { if x == drop { continue } out = append(out, x) } return out } // isLegacyChildName reports whether name is a legacy parent-child // control node that should be folded away. func isLegacyChildName(name string) bool { return name == componentNameLoopItem || name == componentNameIterationItem }