Files
ragflow/internal/agent/canvas/loop_subgraph.go
Zhichang Yu 3fa15c0e2f feat(agent): Go port — canvas engine, 22 components, DSL v2, 13 endpoints (#15952)
Ports the agent canvas subsystem from Python to Go.

## What's included

### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages

### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |

### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7

### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)

### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs

### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
2026-06-12 22:58:28 +08:00

756 lines
24 KiB
Go

//
// 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.
//
// loop_subgraph.go — Loop macro expansion for BuildWorkflow.
//
// The RAGFlow DSL expresses a loop as a parent Loop component with a
// chain of downstream body components. In the Go port we collapse this
// to a SINGLE eino node by:
// 1. Collecting the Loop's downstream descendants into a sub-graph
// (a *compose.Workflow[map[string]any, map[string]any]).
// 2. Prepending a synthetic "LoopInit" lambda that resolves the DSL's
// `loop_variables` and writes them into the per-run CanvasState
// under `state.Outputs[loopID][name]`, then passes the outer input
// through.
// 3. Translating the DSL's `loop_termination_condition` list into a
// `workflowx.LoopCondition[map[string]any]` closure that reads the
// same state slots via `state.GetVar` on every iteration.
//
// The actual installation into the outer graph is done by BuildWorkflow
// (canvas.go) via workflowx.AddLoopNode, which registers the resulting
// *WorkflowNode inside the outer *compose.Workflow.
package canvas
import (
"context"
"fmt"
"strings"
"ragflow/internal/agent/workflowx"
"github.com/cloudwego/eino/compose"
)
// loopExpansion holds the two artefacts produced by buildLoopExpansion
// and consumed by BuildWorkflow to install the loop node.
type loopExpansion struct {
Sub *compose.Workflow[map[string]any, map[string]any]
ShouldQuit workflowx.LoopCondition[map[string]any]
MaxIters int
Members map[string]bool // cpn_ids consumed by the sub-graph; caller skips these in the main pass.
}
// buildLoopExpansion constructs the sub-workflow + termination condition
// for the given Loop cpn. It does NOT touch the outer workflow — the
// caller is responsible for installing the result via
// workflowx.AddLoopNode and for skipping the members in the main
// BuildWorkflow pass.
//
// Parameters:
//
// c — the parent Canvas (DSL representation).
// loopID — the cpn_id of the Loop component being expanded.
//
// The returned `Members` is the set of cpn_ids that the expansion
// consumed as body nodes. BuildWorkflow must skip these when iterating
// `c.Components` in the main pass (they will be wired inside the
// sub-graph, not the outer graph).
func buildLoopExpansion(ctx context.Context, c *Canvas, loopID string) (*loopExpansion, error) {
if c == nil {
return nil, fmt.Errorf("canvas: nil canvas")
}
if loopID == "" {
return nil, fmt.Errorf("canvas: buildLoopExpansion: empty loopID")
}
if _, ok := c.Components[loopID]; !ok {
return nil, fmt.Errorf("canvas: buildLoopExpansion: unknown cpn %q", loopID)
}
loopComp := c.Components[loopID]
members := collectDescendants(c, loopID)
initValues, err := resolveInitialVariables(loopComp.Obj.Params)
if err != nil {
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
}
shouldQuit, err := translateLoopCondition(loopID, loopComp.Obj.Params)
if err != nil {
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
}
maxIters := readMaxLoopCount(loopComp.Obj.Params)
sub, err := buildSubWorkflow(ctx, c, members, loopID, initValues)
if err != nil {
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
}
return &loopExpansion{
Sub: sub,
ShouldQuit: shouldQuit,
MaxIters: maxIters,
Members: members,
}, nil
}
// collectDescendants returns the set of cpn_ids reachable from root via
// downstream edges, NOT including root itself. The BFS stops at the
// back-edge to root (i.e. a node whose Downstream contains root). This
// prevents infinite recursion on cyclic graphs.
func collectDescendants(c *Canvas, root string) map[string]bool {
visited := make(map[string]bool)
queue := []string{}
for _, child := range c.Components[root].Downstream {
if child == root {
continue
}
if !visited[child] {
visited[child] = true
queue = append(queue, child)
}
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, child := range c.Components[cur].Downstream {
if child == root || child == cur {
continue
}
if !visited[child] {
visited[child] = true
queue = append(queue, child)
}
}
}
return visited
}
// buildSubWorkflow constructs a fresh *compose.Workflow[map[string]any,
// map[string]any] containing one node per member cpn, plus a synthetic
// "LoopInit" entry node that seeds the loop variables into the per-run
// state. Edges within the sub-graph mirror the canvas's Downstream
// relations. The sub-workflow's START wires to LoopInit; the END wires
// to whichever member has no downstream within the sub-graph (the
// "tail" of the body).
//
// Body nodes are built through buildNodeBody so they share the same
// legacy-no-op / factory / placeholder routing as the outer graph,
// and receive the same statePre / statePost handlers so loop body
// outputs land in CanvasState.Outputs alongside outer-node outputs.
func buildSubWorkflow(
ctx context.Context,
c *Canvas,
members map[string]bool,
loopID string,
initValues map[string]initVarSpec,
) (*compose.Workflow[map[string]any, map[string]any], error) {
_ = ctx
sub := compose.NewWorkflow[map[string]any, map[string]any]()
nodes := make(map[string]*compose.WorkflowNode, len(members)+1)
// Synthetic entry: writes loop variables into the per-run state
// the FIRST TIME the sub-workflow runs, then returns the input
// map unchanged. Subsequent iterations skip the seeding so the
// body's mutations accumulate across iterations — otherwise a
// VariableAssigner that increments `counter` would be clobbered
// back to its initial value at the top of every iteration and
// the loop could never terminate on a condition that watches the
// counter.
//
// "First time" is detected by checking whether the loop's state
// bucket already holds the variable: a missing bucket entry
// (GetVar returns nil with no error) means the loop has not yet
// seeded; any non-nil value means the body already wrote it on
// a prior iteration. This is safe even for "zero-init" loop
// variables (number→0, string→"") because Go's typed zero
// values are non-nil when stored back through SetVar.
//
// input_mode dispatch (per agent/component/loop.py:60-77):
// "constant" → use the literal value from the DSL
// "variable" → dereference the value as a state ref via
// state.GetVar; store the resolved value
// (or nil if the ref is unresolvable — mirrors
// Python's "treat as literal" fallback)
// "" (zero) → use the type-derived zero value (resolved at
// build time by resolveLoopVarValue)
initNode := sub.AddLambdaNode(loopInitKey,
compose.InvokableLambda(func(ctx context.Context, in map[string]any) (map[string]any, error) {
state, _, err := GetStateFromContext[*CanvasState](ctx)
if err != nil || state == nil {
return in, nil
}
for k, spec := range initValues {
existing, _ := state.GetVar(loopID + "@" + k)
if existing != nil {
continue
}
v := spec.Value
if spec.InputMode == "variable" {
ref, _ := spec.Value.(string)
resolved, err := state.GetVar(ref)
if err != nil {
return nil, fmt.Errorf("canvas: loop %q init: variable %q ref %q: %w", loopID, k, ref, err)
}
v = resolved
}
state.SetVar(loopID, k, v)
}
return in, nil
}),
)
nodes[loopInitKey] = initNode
// Body nodes: each member becomes a real factory-built (or
// placeholder, when no factory is registered) component invoke
// wrapped by withStateBracket so it shares the same state
// snapshot / result-persistence contract as outer-graph nodes.
// We do NOT use eino's StatePreHandler / StatePostHandler here
// because the sub-workflow has no WithGenLocalState of its own:
// state flows in through ctx (runtime.WithState) attached by
// the caller, and is read back via runtime.GetStateFromContext
// inside withStateBracket. This is what lets a Loop body
// actually mutate CanvasState (e.g. VariableAssigner
// incrementing the loop counter) so the LoopCondition closure
// can observe the change on the next iteration.
for cpnID := range members {
name := c.Components[cpnID].Obj.ComponentName
if name == "" {
return nil, fmt.Errorf("canvas: loop %q member %q has empty component_name", loopID, cpnID)
}
body, err := buildNodeBody(cpnID, name, c.Components[cpnID].Obj.Params)
if err != nil {
return nil, err
}
nodes[cpnID] = sub.AddLambdaNode(cpnID,
compose.InvokableLambda[map[string]any, map[string]any](withStateBracket(body)),
compose.WithNodeName(cpnID),
)
}
// Wire edges. The synthetic init node connects to every body node
// that has no upstream within the sub-graph (the body's "entry"
// nodes). For diamond / merge topologies within the body, we use
// the same eino one-data-input rule as BuildWorkflow: the first
// upstream carries data, the rest are exec-only AddDependency.
for cpnID := range members {
upstreams := c.Components[cpnID].Upstream
first := true
for _, up := range upstreams {
if up == loopID {
// Upstream is the parent Loop; in the sub-graph the
// data source is the synthetic init node.
if first {
nodes[cpnID].AddInput(loopInitKey)
first = false
} else {
nodes[cpnID].AddDependency(loopInitKey)
}
continue
}
if !members[up] {
continue
}
if first {
nodes[cpnID].AddInput(up)
first = false
} else {
nodes[cpnID].AddDependency(up)
}
}
if first {
// No in-subgraph upstream: wire from init (this happens
// for body entries whose only upstream in the DSL is the
// Loop itself).
nodes[cpnID].AddInput(loopInitKey)
}
}
// Wire END: every member that has no downstream within the
// sub-graph is a sub-graph terminal; wire sub.End() to it.
hasDownstream := make(map[string]bool, len(members))
for cpnID := range members {
for _, down := range c.Components[cpnID].Downstream {
if members[down] {
hasDownstream[cpnID] = true
break
}
}
}
hasEnd := false
for cpnID := range members {
if hasDownstream[cpnID] {
continue
}
sub.End().AddInput(cpnID)
hasEnd = true
}
if !hasEnd {
// No body terminals — wire END to the init node so the
// sub-workflow at least echoes the input once.
sub.End().AddInput(loopInitKey)
}
// Wire START. The synthetic init node is the sub-workflow's
// entry; eino's Workflow requires every start node to be wired
// from compose.START explicitly. The init node takes the
// sub-workflow's input (the per-iteration `prev`) and seeds the
// loop variables into state.
initNode.AddInput(compose.START)
return sub, nil
}
// loopInitKey is the synthetic cpn_id used for the LoopInit entry node
// inside the sub-workflow. Using a reserved key avoids collisions with
// user-defined cpn_ids.
const loopInitKey = "__loop_init__"
// initVarSpec carries the per-variable info the init lambda needs to
// decide how to seed the loop variable into the per-run state.
//
// For input_mode == "variable", Value is the ref string to dereference
// at init time via state.GetVar; for "constant", Value is used as-is;
// for "" (zero-init), Value is the type-derived zero (resolved at build
// time by resolveLoopVarValue) and the init lambda stores it directly.
type initVarSpec struct {
Value any
InputMode string
}
// resolveInitialVariables applies the input_mode dispatch from
// agent/component/loop.py:60-77 to a list of loop_variable entries.
//
// input_mode == "variable" → returns the ref string in Value
// (the init lambda dereferences it at
// runtime via state.GetVar; resolution
// is deferred because this helper is
// state-free).
// input_mode == "constant" → Value is the literal value.
// otherwise (zero-init) → Value is the type-based zero value.
//
// The init lambda (buildSubWorkflow) iterates the returned map and
// writes each Value into the per-run state under
// `state.Outputs[loopID][name]`. The "variable" dereference happens
// there, in the lambda body, where the live CanvasState is available.
func resolveInitialVariables(params map[string]any) (map[string]initVarSpec, error) {
rawList, _ := params["loop_variables"].([]any)
out := make(map[string]initVarSpec, len(rawList))
for i, raw := range rawList {
item, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("loop_variable[%d]: not a map", i)
}
name, inputMode, value, typ, err := readLoopVarFields(item)
if err != nil {
return nil, err
}
v, err := resolveLoopVarValue(inputMode, value, typ)
if err != nil {
return nil, fmt.Errorf("loop_variable[%d] %q: %w", i, name, err)
}
out[name] = initVarSpec{Value: v, InputMode: inputMode}
}
return out, nil
}
func readLoopVarFields(item map[string]any) (name, inputMode string, value, typ any, err error) {
if item == nil {
return "", "", nil, nil, fmt.Errorf("nil loop_variable entry")
}
vRaw, hasVar := item["variable"]
imRaw, hasIM := item["input_mode"]
valRaw, hasVal := item["value"]
typeRaw, hasType := item["type"]
if !hasVar || vRaw == nil {
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'variable')")
}
if !hasIM || imRaw == nil {
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'input_mode')")
}
if !hasVal {
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'value')")
}
if !hasType || typeRaw == nil {
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'type')")
}
name, _ = vRaw.(string)
if name == "" {
name = fmt.Sprintf("%v", vRaw)
}
inputMode, _ = imRaw.(string)
return name, inputMode, valRaw, typeRaw, nil
}
func resolveLoopVarValue(inputMode string, value, typ any) (any, error) {
switch inputMode {
case "variable":
// The "variable" path is handled at init time inside
// buildSubWorkflow's init lambda, where the state is
// available. Here we just return the ref string.
return value, nil
case "constant":
return value, nil
}
return zeroValueForType(typ), nil
}
// zeroValueForType implements the type→zero mapping from
// agent/component/loop.py:65-76:
//
// number → 0
// string → ""
// boolean → false
// object* → map[string]any{}
// array* → []any{}
// else → ""
func zeroValueForType(typ any) any {
s, _ := typ.(string)
switch {
case s == "number":
return 0
case s == "string":
return ""
case s == "boolean":
return false
case strings.HasPrefix(s, "object"):
return map[string]any{}
case strings.HasPrefix(s, "array"):
return []any{}
}
return ""
}
// translateLoopCondition converts the DSL's loop_termination_condition
// list into a workflowx.LoopCondition[map[string]any] closure.
//
// The closure reads each condition's variable via
// `state.GetVar(loopID + "." + variable)` on every iteration, applies
// the operator, and combines results via the configured logical
// operator ("and" by default, "or" otherwise).
//
// The closure's per-iteration cost is one state lookup per condition —
// no allocations once the conditions slice is captured.
func translateLoopCondition(loopID string, params map[string]any) (workflowx.LoopCondition[map[string]any], error) {
rawList, _ := params["loop_termination_condition"].([]any)
conditions := make([]loopConditionSpec, 0, len(rawList))
for i, raw := range rawList {
m, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("loop_termination_condition[%d]: not a map", i)
}
variable, hasVar := m["variable"].(string)
operator, hasOp := m["operator"].(string)
if !hasVar || variable == "" {
return nil, fmt.Errorf("loop_termination_condition[%d] is incomplete (missing 'variable')", i)
}
if !hasOp || operator == "" {
return nil, fmt.Errorf("loop_termination_condition[%d] is incomplete (missing 'operator')", i)
}
inputMode, _ := m["input_mode"].(string)
if inputMode == "" {
inputMode = "constant"
}
conditions = append(conditions, loopConditionSpec{
Variable: variable,
Operator: operator,
Value: m["value"],
InputMode: inputMode,
})
}
logicalOp, _ := params["logical_operator"].(string)
if logicalOp == "" {
logicalOp = "and"
}
if logicalOp != "and" && logicalOp != "or" {
return nil, fmt.Errorf("invalid logical_operator %q (want 'and' or 'or')", logicalOp)
}
return func(ctx context.Context, _ int, _, _ map[string]any) (bool, error) {
// The condition is evaluated at the end of each iteration.
// We need access to the per-run state to read loop variables
// and other DSL variables. The workflowx lambda passes the
// loop's outer context into this closure, so
// canvas.GetStateFromContext works.
state, _, err := GetStateFromContext[*CanvasState](ctx)
if err != nil || state == nil {
return false, fmt.Errorf("loop %q: condition eval: no canvas state in context", loopID)
}
if len(conditions) == 0 {
// No conditions means the loop only stops at max count
// — never quit on conditions. Mirrors Python fallback.
return false, nil
}
// Vacuous starting value: true for AND, false for OR.
combined := logicalOp == "and"
for _, spec := range conditions {
v, err := evalOneLoopCondition(state, loopID, spec)
if err != nil {
return false, err
}
if logicalOp == "or" {
combined = combined || v
} else {
combined = combined && v
}
}
return combined, nil
}, nil
}
type loopConditionSpec struct {
Variable string
Operator string
Value any
InputMode string // "constant" or "variable"
}
// evalOneLoopCondition resolves a single condition entry. Mirrors
// loopitem.py:128-142. Variable lookup is by full cpn_id path
// ("loopID.varName" for loop variables, or whatever ref the DSL
// supplies for state-level refs).
func evalOneLoopCondition(state *CanvasState, loopID string, spec loopConditionSpec) (bool, error) {
// Resolve the right-hand side value.
var rhs any
if spec.InputMode == "variable" {
ref, _ := spec.Value.(string)
v, err := state.GetVar(ref)
if err != nil {
return false, fmt.Errorf("loop %q: condition rhs ref %q: %w", loopID, ref, err)
}
rhs = v
} else if spec.InputMode != "constant" {
return false, fmt.Errorf("loop %q: invalid input mode %q", loopID, spec.InputMode)
} else {
rhs = spec.Value
}
// Resolve the variable being tested. The DSL stores either a bare
// variable name (loop variable) or a full cpn_id@param ref. For
// loop variables written by the init lambda, the bucket key is
// "loopID" so the ref is "loopID@name". For arbitrary state refs,
// the DSL passes the full path.
ref := spec.Variable
if !strings.Contains(ref, ".") && !strings.Contains(ref, "@") {
// Bare name — assume it's a loop variable.
ref = loopID + "@" + ref
}
got, err := state.GetVar(ref)
if err != nil {
return false, fmt.Errorf("loop %q: condition lhs ref %q: %w", loopID, ref, err)
}
return evaluateCondition(got, spec.Operator, rhs)
}
// evaluateCondition is the type-dispatched operator logic that mirrors
// loopitem.py:48-122. The operator set is the union of operators used
// across all type branches — at runtime only the branches matching
// the dynamic type of `var` are reachable.
func evaluateCondition(varVal any, op string, value any) (bool, error) {
switch v := varVal.(type) {
case nil:
if op == "empty" {
return true, nil
}
return false, nil
case string:
return evalStringOp(v, op, value)
case bool:
return evalBoolOp(v, op, value)
case int:
return evalNumberOp(float64(v), op, value)
case int32:
return evalNumberOp(float64(v), op, value)
case int64:
return evalNumberOp(float64(v), op, value)
case float32:
return evalNumberOp(float64(v), op, value)
case float64:
return evalNumberOp(v, op, value)
case map[string]any:
return evalDictOp(v, op, value)
case []any:
return evalListOp(v, op, value)
}
return false, fmt.Errorf("invalid operator: %s (variable type %T unsupported)", op, varVal)
}
func evalStringOp(s, op string, value any) (bool, error) {
switch op {
case "contains":
vs, _ := value.(string)
return strings.Contains(s, vs), nil
case "not contains":
vs, _ := value.(string)
return !strings.Contains(s, vs), nil
case "start with":
vs, _ := value.(string)
return strings.HasPrefix(s, vs), nil
case "end with":
vs, _ := value.(string)
return strings.HasSuffix(s, vs), nil
case "is":
return s == value, nil
case "is not":
return s != value, nil
case "empty":
return s == "", nil
case "not empty":
return s != "", nil
}
return false, fmt.Errorf("invalid operator: %s (string variable)", op)
}
func evalBoolOp(b bool, op string, value any) (bool, error) {
switch op {
case "is":
vb, _ := value.(bool)
return b == vb, nil
case "is not":
vb, _ := value.(bool)
return b != vb, nil
case "empty":
// mirrors `var is None` for booleans
return b == false && value == nil, nil
case "not empty":
return b == true || value != nil, nil
}
return false, fmt.Errorf("invalid operator: %s (bool variable)", op)
}
func evalNumberOp(n float64, op string, value any) (bool, error) {
cmp, ok := toFloat(value)
if !ok && !isNilOp(op) {
return false, fmt.Errorf("invalid operator: %s (number variable, non-numeric value)", op)
}
switch op {
case "=":
return n == cmp, nil
case "≠":
return n != cmp, nil
case ">":
return n > cmp, nil
case "<":
return n < cmp, nil
case "≥":
return n >= cmp, nil
case "≤":
return n <= cmp, nil
case "empty":
return value == nil, nil
case "not empty":
return value != nil, nil
}
return false, fmt.Errorf("invalid operator: %s (number variable)", op)
}
func evalDictOp(m map[string]any, op string, _ any) (bool, error) {
switch op {
case "empty":
return len(m) == 0, nil
case "not empty":
return len(m) > 0, nil
}
return false, fmt.Errorf("invalid operator: %s (dict variable)", op)
}
func evalListOp(lst []any, op string, value any) (bool, error) {
switch op {
case "contains":
return listContains(lst, value), nil
case "not contains":
return !listContains(lst, value), nil
case "is":
return listEqual(lst, value), nil
case "is not":
return !listEqual(lst, value), nil
case "empty":
return len(lst) == 0, nil
case "not empty":
return len(lst) > 0, nil
}
return false, fmt.Errorf("invalid operator: %s (list variable)", op)
}
func listContains(lst []any, value any) bool {
for _, x := range lst {
if x == value {
return true
}
}
return false
}
func listEqual(lst []any, value any) bool {
other, ok := value.([]any)
if !ok {
return false
}
if len(lst) != len(other) {
return false
}
for i := range lst {
if lst[i] != other[i] {
return false
}
}
return true
}
func toFloat(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int32:
return float64(x), true
case int64:
return float64(x), true
}
return 0, false
}
func isNilOp(op string) bool {
return op == "empty" || op == "not empty"
}
// readMaxLoopCount returns the configured `maximum_loop_count` for the
// Loop. 0 means "infinite" (no cap, only condition-driven termination).
func readMaxLoopCount(params map[string]any) int {
v, ok := params["maximum_loop_count"]
if !ok {
return 0
}
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case int32:
return int(x)
case float64:
return int(x)
case float32:
return int(x)
}
return 0
}