mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
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
513 lines
15 KiB
Go
513 lines
15 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.
|
|
//
|
|
|
|
// 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)
|
|
}
|