Files
ragflow/internal/agent/component/variable_assigner.go

513 lines
15 KiB
Go
Raw Normal View History

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
//
// 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 component — VariableAssigner (T3, plan §2.11.3 row 20).
//
// VariableAssigner applies an ordered list of (variable, operator,
// parameter) tuples to the shared *CanvasState. Each tuple's operator
// reads the current variable value, computes a new one, and (unless the
// operator returns an "ERROR:..." sentinel) writes the new value back to
// the state bucket the variable ref points at.
//
// The eleven operators mirror agent/component/variable_assigner.py:
//
// overwrite, clear, set, append, extend, remove_first, remove_last,
// "+= -= *= /="
//
// Variable refs may target cpn outputs ("cpn_0@x"), the sys namespace
// ("sys.x"), the env namespace ("env.x"), or iteration aliases
// ("item" / "index"). Cpn-typed refs are split on the first "@" into
// (cpnID, param) and written via SetVar; sys/env/item/index are written
// to their respective CanvasState maps directly.
//
// On "ERROR:..." returns the operator result is exposed at
// outputs["errors"] and the state bucket is left unchanged.
package component
import (
"context"
"fmt"
"strings"
"ragflow/internal/agent/runtime"
)
const componentNameVariableAssigner = "VariableAssigner"
// variableAssignerParam is the static configuration. variables is a
// list of {variable, operator, parameter} dicts.
type variableAssignerParam struct {
Variables []map[string]any `json:"variables"`
}
// Update copies a fresh param map into the receiver. `variables` may
// arrive as []any (engine-decoded from JSON) or []map[string]any
// (test/direct construction); both shapes are accepted.
func (p *variableAssignerParam) Update(conf map[string]any) error {
if conf == nil {
p.Variables = nil
return nil
}
raw, ok := conf["variables"]
if !ok {
p.Variables = nil
return nil
}
var list []any
switch x := raw.(type) {
case []any:
list = x
case []map[string]any:
list = make([]any, 0, len(x))
for _, v := range x {
list = append(list, v)
}
default:
return &ParamError{Field: "variables", Reason: "must be a list"}
}
out := make([]map[string]any, 0, len(list))
for i, item := range list {
m, ok := item.(map[string]any)
if !ok {
return &ParamError{Field: fmt.Sprintf("variables[%d]", i), Reason: "must be a map"}
}
out = append(out, m)
}
p.Variables = out
return nil
}
// Check is a no-op for VariableAssigner; the Python base class also
// returns True unconditionally.
func (p *variableAssignerParam) Check() error { return nil }
// AsDict returns the params as a plain map.
func (p *variableAssignerParam) AsDict() map[string]any {
out := map[string]any{"variables": make([]any, 0, len(p.Variables))}
for _, v := range p.Variables {
out["variables"] = append(out["variables"].([]any), v)
}
return out
}
// VariableAssignerComponent applies the configured (variable, operator,
// parameter) tuples to the canvas state.
type VariableAssignerComponent struct {
name string
param variableAssignerParam
}
// NewVariableAssignerComponent constructs a VariableAssigner from the
// DSL param map.
func NewVariableAssignerComponent(params map[string]any) (Component, error) {
p := &variableAssignerParam{}
if err := p.Update(params); err != nil {
return nil, fmt.Errorf("VariableAssigner: param update: %w", err)
}
if err := p.Check(); err != nil {
return nil, fmt.Errorf("VariableAssigner: param check: %w", err)
}
return &VariableAssignerComponent{
name: componentNameVariableAssigner,
param: *p,
}, nil
}
// Name returns the registered component name.
func (v *VariableAssignerComponent) Name() string { return v.name }
// Invoke walks the param.variables list, evaluates each tuple against
// the canvas state, and writes the result back unless the operator
// returned an "ERROR:..." sentinel. The list of refs that were
// assigned is returned at outputs["assignments"]; per-item errors (if
// any) are returned at outputs["errors"].
func (v *VariableAssignerComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
if err != nil {
return nil, fmt.Errorf("VariableAssigner: %w", err)
}
if state == nil {
return nil, fmt.Errorf("VariableAssigner: nil canvas state")
}
items := v.param.Variables
// Allow runtime override via inputs["variables"] (a list of tuples
// in the same shape as param.variables).
if override, ok := inputs["variables"].([]any); ok && len(override) > 0 {
items = items[:0]
for _, raw := range override {
if m, ok := raw.(map[string]any); ok {
items = append(items, m)
}
}
}
assignments := make([]string, 0, len(items))
var errors []string
for i, item := range items {
ref, _ := item["variable"].(string)
op, _ := item["operator"].(string)
param, _ := item["parameter"]
if ref == "" || op == "" {
return nil, &ParamError{
Field: fmt.Sprintf("variables[%d]", i),
Reason: "variable and operator must be non-empty",
}
}
oldVal, err := state.GetVar(ref)
if err != nil {
// bad ref shape — surface as an error rather than silently skip
return nil, fmt.Errorf("VariableAssigner: variables[%d] get %q: %w", i, ref, err)
}
newVal, opErr := operate(state, oldVal, op, param)
if opErr != "" {
errors = append(errors, fmt.Sprintf("variables[%d] %s: %s", i, ref, opErr))
continue
}
if err := writeVar(state, ref, newVal); err != nil {
return nil, fmt.Errorf("VariableAssigner: variables[%d] write %q: %w", i, ref, err)
}
assignments = append(assignments, ref)
}
out := map[string]any{"assignments": assignments}
if len(errors) > 0 {
out["errors"] = errors
}
return out, nil
}
// Stream mirrors Invoke; VariableAssigner is a single-shot apply.
func (v *VariableAssignerComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
out, err := v.Invoke(ctx, inputs)
if err != nil {
return nil, err
}
ch := make(chan map[string]any, 1)
ch <- out
close(ch)
return ch, nil
}
// Inputs returns the public parameter surface.
func (v *VariableAssignerComponent) Inputs() map[string]string {
return map[string]string{
"variables": "Optional runtime override: a list of {variable, operator, parameter} dicts.",
}
}
// Outputs returns the assigned refs and any per-item errors.
func (v *VariableAssignerComponent) Outputs() map[string]string {
return map[string]string{
"assignments": "List of refs that were successfully written back to state.",
"errors": "Per-item error messages; absent when all operators succeeded.",
}
}
// operate applies the operator. Returns ("", "ERROR:...") on failure;
// the caller treats the empty string opErr as success and the new value
// as the value to write back.
func operate(state *runtime.CanvasState, oldVal any, op string, param any) (any, string) {
switch op {
case "overwrite":
// overwrite: new = canvas.get_variable_value(parameter).
// parameter is itself a ref string (possibly wrapped in
// {{...}}); strip the wrapping before lookup so
// "{{cpn_1@y}}" resolves to cpn_1's "y" output value.
if s, ok := param.(string); ok {
bare := stripVarBraces(s)
v, err := state.GetVar(bare)
if err != nil {
return nil, "ERROR:PARAMETER_UNRESOLVED"
}
return v, ""
}
// If parameter is not a string, the Python code calls
// get_variable_value which expects a string. Pass through.
return param, ""
case "clear":
switch oldVal.(type) {
case nil:
return nil, ""
case []any:
return []any{}, ""
case string:
return "", ""
case map[string]any:
return map[string]any{}, ""
case bool:
return false, ""
case int, int64, float64, float32:
return 0, ""
}
return nil, ""
case "set":
switch oldVal.(type) {
case nil, string:
// Try to interpret parameter as a ref (or {{...}} template);
// fall back to the raw value when it doesn't look like one.
if s, ok := param.(string); ok && s != "" {
if v, err := state.GetVar(s); err == nil && v != nil {
return v, ""
}
// also try template resolution against state for {{...}}
if strings.Contains(s, "{{") {
if resolved, err := runtime.ResolveTemplate(s, state); err == nil {
return resolved, ""
}
}
}
return param, ""
default:
return param, ""
}
case "append":
p, _ := state.GetVar(asRefString(param))
// when param is a non-ref literal, p is "" — fall back to raw
_ = p
p = resolveParamValue(state, param)
if oldVal == nil {
oldVal = []any{}
}
lst, ok := oldVal.([]any)
if !ok {
return nil, "ERROR:VARIABLE_NOT_LIST"
}
if len(lst) > 0 {
if !compatibleElemType(lst[0], p) {
return nil, "ERROR:PARAMETER_NOT_LIST_ELEMENT_TYPE"
}
}
// append returns the original list mutated
lst = append(lst, p)
return lst, ""
case "extend":
p := resolveParamValue(state, param)
if oldVal == nil {
oldVal = []any{}
}
lst, ok := oldVal.([]any)
if !ok {
return nil, "ERROR:VARIABLE_NOT_LIST"
}
pl, ok := p.([]any)
if !ok {
return nil, "ERROR:PARAMETER_NOT_LIST"
}
if len(lst) > 0 && len(pl) > 0 {
if !compatibleElemType(lst[0], pl[0]) {
return nil, "ERROR:PARAMETER_NOT_LIST_ELEMENT_TYPE"
}
}
return append(lst, pl...), ""
case "remove_first":
lst, ok := oldVal.([]any)
if !ok {
return nil, "ERROR:VARIABLE_NOT_LIST"
}
if len(lst) == 0 {
return lst, ""
}
return lst[1:], ""
case "remove_last":
lst, ok := oldVal.([]any)
if !ok {
return nil, "ERROR:VARIABLE_NOT_LIST"
}
if len(lst) == 0 {
return lst, ""
}
return lst[:len(lst)-1], ""
case "+=":
pv := resolveParamValue(state, param)
if !isNumberish(oldVal) || !isNumberish(pv) {
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
}
return toFloat64(oldVal) + toFloat64(pv), ""
case "-=":
pv := resolveParamValue(state, param)
if !isNumberish(oldVal) || !isNumberish(pv) {
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
}
return toFloat64(oldVal) - toFloat64(pv), ""
case "*=":
pv := resolveParamValue(state, param)
if !isNumberish(oldVal) || !isNumberish(pv) {
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
}
return toFloat64(oldVal) * toFloat64(pv), ""
case "/=":
pv := resolveParamValue(state, param)
if !isNumberish(oldVal) || !isNumberish(pv) {
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
}
if toFloat64(pv) == 0 {
return nil, "ERROR:DIVIDE_BY_ZERO"
}
return toFloat64(oldVal) / toFloat64(pv), ""
}
return nil, "ERROR:UNKNOWN_OPERATOR"
}
// writeVar routes a ref to the correct CanvasState bucket.
//
// - "cpn_id@param..." → SetVar(cpnID, param...)
// - "sys.x" → Sys["x"] = v
// - "env.x" → Env["x"] = v
// - "item" → Globals["__item__"] = v
// - "index" → Globals["__index__"] = v
func writeVar(state *runtime.CanvasState, ref string, v any) error {
switch {
case ref == "item":
state.Globals["__item__"] = v
return nil
case ref == "index":
state.Globals["__index__"] = v
return nil
case strings.HasPrefix(ref, "sys."):
state.Sys[strings.TrimPrefix(ref, "sys.")] = v
return nil
case strings.HasPrefix(ref, "env."):
state.Env[strings.TrimPrefix(ref, "env.")] = v
return nil
}
idx := strings.Index(ref, "@")
if idx <= 0 {
return fmt.Errorf("invalid variable ref %q", ref)
}
cpnID, param := ref[:idx], ref[idx+1:]
state.SetVar(cpnID, param, v)
return nil
}
// asRefString returns param as a string ref if it is one, "" otherwise.
// Used to gate "should I look this up in state" decisions.
func asRefString(param any) string {
if s, ok := param.(string); ok {
return s
}
return ""
}
// stripVarBraces removes one or two layers of surrounding `{` `}` plus
// whitespace from s, matching the chained .strip("{").strip("}").strip(" ").
// strip("{").strip("}") at agent/canvas.py:196. The double-layer case
// handles "{{cpn_1@y}}" → "cpn_1@y" so canvas.GetVar can resolve it
// against the cpn output bucket.
func stripVarBraces(s string) string {
s = strings.TrimSpace(s)
for range 2 {
if len(s) >= 2 && s[0] == '{' && s[len(s)-1] == '}' {
s = strings.TrimSpace(s[1 : len(s)-1])
continue
}
break
}
return s
}
// resolveParamValue returns the value of param. Strings are looked up
// in state first (treating them as refs); anything else is passed
// through unchanged. The Python _canvas.get_variable_value does the
// same after stripping the surrounding {{ }}; non-string params
// (numbers, lists, dicts) are passed verbatim.
func resolveParamValue(state *runtime.CanvasState, param any) any {
if s, ok := param.(string); ok && s != "" {
// Try the parameter as a bare ref first (matches Python's
// canvas.get_variable_value which strips braces then splits on @).
bare := stripVarBraces(s)
if v, err := state.GetVar(bare); err == nil && v != nil {
return v
}
// Fall back to the original string for cases where the param
// contains template fragments that didn't fully resolve.
}
return param
}
// compatibleElemType mirrors the Python isinstance check used in
// _append / _extend. The Python code uses strict isinstance; the Go
// port relaxes this to "same Go kind" (int / float64 / string) so
// JSON-decoded numbers from LLM output compose correctly.
func compatibleElemType(a, b any) bool {
return goKind(a) == goKind(b)
}
func goKind(v any) string {
switch v.(type) {
case int, int64, int32:
return "int"
case float64, float32:
return "float"
case string:
return "string"
case bool:
return "bool"
case map[string]any:
return "map"
case []any:
return "list"
}
return "unknown"
}
// isNumberish returns true for numeric values (int, float, including
// JSON-decoded numbers). Booleans are explicitly excluded — Python's
// numbers.Number is a superclass of int/float/complex/Decimal but
// Python's isinstance(True, numbers.Number) is False; the spec matches
// that.
func isNumberish(v any) bool {
if _, ok := v.(bool); ok {
return false
}
switch v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return true
}
return false
}
// toFloat64 converts any numeric value to float64. Callers must guard
// with isNumberish first.
func toFloat64(v any) float64 {
switch x := v.(type) {
case int:
return float64(x)
case int64:
return float64(x)
case int32:
return float64(x)
case float64:
return x
case float32:
return float64(x)
}
return 0
}
func init() {
Register(componentNameVariableAssigner, NewVariableAssignerComponent)
}