mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-09 00:47:59 +08:00
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
This commit is contained in:
394
internal/agent/canvas/loop_semantics_test.go
Normal file
394
internal/agent/canvas/loop_semantics_test.go
Normal file
@@ -0,0 +1,394 @@
|
||||
//
|
||||
// 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_semantics_test.go — end-to-end Loop semantics tests.
|
||||
//
|
||||
// Unlike loop_subgraph_test.go (which unit-tests helpers in isolation
|
||||
// with no factory registered), this file imports
|
||||
// internal/agent/component as a side-effect to install the real
|
||||
// component factory via runtime.SetDefaultFactory. The tests then
|
||||
// compile and run a full Begin → Loop → ... DSL and assert that the
|
||||
// loop body actually mutates CanvasState across iterations, that
|
||||
// termination conditions fire on the real state values, and that
|
||||
// factory errors surface with cpn-scoped diagnostics.
|
||||
//
|
||||
// The blank import below is what wires component.New into the
|
||||
// canvas builder's runtime.DefaultFactory() lookup; without it,
|
||||
// BuildWorkflow would fall back to its placeholder echo body and the
|
||||
// loop would never observe the counter increment.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
// Blank-import to trigger component package init(), which calls
|
||||
// runtime.SetDefaultFactory(component.New). Without this, the
|
||||
// canvas builder uses its placeholder body and these tests cannot
|
||||
// exercise real component invocation.
|
||||
_ "ragflow/internal/agent/component"
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/agent/workflowx"
|
||||
)
|
||||
|
||||
// runLoopCanvas is the common harness for the e2e loop tests. It
|
||||
// compiles dsl, attaches state to a fresh ctx, invokes the workflow,
|
||||
// and returns the run error. Callers inspect state after the run to
|
||||
// assert per-iteration writes landed.
|
||||
func runLoopCanvas(t *testing.T, dsl *Canvas) (*CanvasState, error) {
|
||||
t.Helper()
|
||||
cc, err := Compile(context.Background(), dsl)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile: %v", err)
|
||||
}
|
||||
state := NewCanvasState("run-loop", "task-loop")
|
||||
ctx := withState(context.Background(), state)
|
||||
_, runErr := cc.Workflow.Invoke(ctx, map[string]any{"query": "go"})
|
||||
return state, runErr
|
||||
}
|
||||
|
||||
// counterLoopDSL builds a Begin → Loop DSL with one VariableAssigner
|
||||
// body node that adds the supplied step to a counter loop variable
|
||||
// each iteration. The loop terminates when counter >= threshold.
|
||||
func counterLoopDSL(step int, threshold int, maxCount int) *Canvas {
|
||||
return &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"loop"},
|
||||
},
|
||||
"loop": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"input_mode": "constant",
|
||||
"value": 0,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": threshold,
|
||||
"input_mode": "constant",
|
||||
},
|
||||
},
|
||||
"logical_operator": "and",
|
||||
"maximum_loop_count": maxCount,
|
||||
},
|
||||
},
|
||||
Upstream: []string{"begin"},
|
||||
Downstream: []string{"bump"},
|
||||
},
|
||||
"bump": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": []any{
|
||||
map[string]any{
|
||||
"variable": "loop@counter",
|
||||
"operator": "+=",
|
||||
"parameter": step,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"loop"},
|
||||
},
|
||||
},
|
||||
Path: []string{"begin", "loop"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_DoWhileCounter is the keystone test: it proves that the
|
||||
// real VariableAssigner component runs inside the loop body, mutates
|
||||
// the shared CanvasState, and that the termination condition fires
|
||||
// on the mutated value. If the loop body were still a placeholder
|
||||
// echo lambda the counter would stay at 0 and the loop would run to
|
||||
// maximum_loop_count or hit defaultMaxIterations.
|
||||
func TestLoop_DoWhileCounter(t *testing.T) {
|
||||
state, err := runLoopCanvas(t, counterLoopDSL(1, 3, 50))
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
v, err := state.GetVar("loop@counter")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
got, ok := v.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("counter: want float64 (VariableAssigner += produces float64), got %T: %v", v, v)
|
||||
}
|
||||
// The loop performs do-while semantics: it runs the body, THEN
|
||||
// checks the condition. Starting at counter=0, the body
|
||||
// increments to 1, 2, 3 — the condition (counter >= 3) becomes
|
||||
// true after the third iteration, so the final value is 3.
|
||||
if got != 3 {
|
||||
t.Errorf("counter: got %v, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_MaxCount proves that maximum_loop_count caps iterations
|
||||
// when the termination condition never fires. The condition asks for
|
||||
// counter >= 100 but maximum_loop_count is 5; the loop must stop at
|
||||
// counter=5 (5 successful body runs).
|
||||
func TestLoop_MaxCount(t *testing.T) {
|
||||
state, err := runLoopCanvas(t, counterLoopDSL(1, 100, 5))
|
||||
// workflowx surfaces a MaxIterationsExceeded error when the cap
|
||||
// is hit. Both the error path AND the partial state must be
|
||||
// observable to the caller — the state writes that succeeded
|
||||
// before the cap should still be present.
|
||||
if err == nil {
|
||||
t.Fatalf("expected ErrLoopMaxIterationsExceeded, got nil")
|
||||
}
|
||||
if !errors.Is(err, workflowx.ErrLoopMaxIterationsExceeded) {
|
||||
t.Fatalf("want ErrLoopMaxIterationsExceeded, got: %v", err)
|
||||
}
|
||||
v, err := state.GetVar("loop@counter")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
got, ok := v.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("counter: want float64, got %T: %v", v, v)
|
||||
}
|
||||
if got != 5 {
|
||||
t.Errorf("counter at cap: got %v, want 5 (maximum_loop_count)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_FactoryErrorSurfaces proves that a factory rejection of a
|
||||
// loop body member produces a cpn-scoped error from BuildWorkflow
|
||||
// (not a silent placeholder fallback or an opaque error from the
|
||||
// workflowx layer).
|
||||
//
|
||||
// VariableAssigner's factory rejects a non-list `variables` param
|
||||
// (see variable_assigner.go's Update). We trigger that by supplying
|
||||
// a string instead of a list.
|
||||
func TestLoop_FactoryErrorSurfaces(t *testing.T) {
|
||||
dsl := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"loop"},
|
||||
},
|
||||
"loop": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{},
|
||||
"loop_termination_condition": []any{},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"begin"},
|
||||
Downstream: []string{"bad"},
|
||||
},
|
||||
"bad": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": "not-a-list", // factory rejects this
|
||||
},
|
||||
},
|
||||
Upstream: []string{"loop"},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := Compile(context.Background(), dsl)
|
||||
if err == nil {
|
||||
t.Fatal("expected factory error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "bad") {
|
||||
t.Errorf("error should name the cpn_id 'bad'; got: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "VariableAssigner") {
|
||||
t.Errorf("error should name the component type 'VariableAssigner'; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_LegacyExitLoopStaysNoOp confirms that the DSL v1 sentinel
|
||||
// "ExitLoop" continues to compile as a no-op even when a factory is
|
||||
// registered (the legacy-no-op path takes precedence over factory
|
||||
// lookup). This is the protection against a future "ExitLoop" being
|
||||
// accidentally registered as a real component and changing behaviour
|
||||
// for v1 DSLs.
|
||||
func TestLoop_LegacyExitLoopStaysNoOp(t *testing.T) {
|
||||
dsl := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"exit"},
|
||||
},
|
||||
"exit": {
|
||||
Obj: CanvasComponentObj{ComponentName: "ExitLoop"},
|
||||
Upstream: []string{"begin"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := Compile(context.Background(), dsl); err != nil {
|
||||
t.Fatalf("Compile with legacy ExitLoop (factory registered): %v", err)
|
||||
}
|
||||
// Also verify the factory IS registered — otherwise this test
|
||||
// would be no different from the canvas-only TestBuildWorkflow_LegacyExitLoop.
|
||||
if runtime.DefaultFactory() == nil {
|
||||
t.Fatal("factory must be registered for this test to be meaningful")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_FactoryRegisteredInThisBinary is a sanity guard: if a
|
||||
// future refactor breaks the blank import in this file, the other
|
||||
// e2e tests would silently fall back to placeholder bodies and
|
||||
// pass for the wrong reason. This test fails loudly if the factory
|
||||
// is not installed.
|
||||
func TestLoop_FactoryRegisteredInThisBinary(t *testing.T) {
|
||||
if runtime.DefaultFactory() == nil {
|
||||
t.Fatal("runtime.DefaultFactory() is nil; the blank import of internal/agent/component is missing or broken")
|
||||
}
|
||||
}
|
||||
|
||||
// variableModeLoopDSL builds a Begin → VariableAssigner(seed) → Loop →
|
||||
// VariableAssigner(bump) DSL where the loop's counter is seeded from
|
||||
// the seed component's output via input_mode="variable". The loop
|
||||
// terminates when counter >= threshold; the bump node increments
|
||||
// counter by step each iteration.
|
||||
//
|
||||
// This is the regression test for the "input_mode=variable" loop
|
||||
// variable init bug: the init lambda must dereference the value
|
||||
// against the live CanvasState (state.GetVar) at init time, not
|
||||
// store the raw ref string. If the dereference is missing, counter
|
||||
// is seeded with the literal string "seed@initial" and the body's
|
||||
// `+=` operator fails with PARAMETER_NOT_NUMBER on the first
|
||||
// iteration — the loop terminates after a single body run with
|
||||
// counter=0 (or errors out).
|
||||
//
|
||||
// The seed uses VariableAssigner's `set` operator with an int
|
||||
// parameter (not `overwrite` with a {{literal}} — `overwrite` looks
|
||||
// the parameter up as a state ref, so a bare number would error with
|
||||
// PARAMETER_UNRESOLVED). `set` falls through to return the raw param
|
||||
// for non-string types, which is what we want here.
|
||||
func variableModeLoopDSL(threshold, step int) *Canvas {
|
||||
return &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"seed"},
|
||||
},
|
||||
"seed": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": []any{
|
||||
map[string]any{
|
||||
"variable": "seed@initial",
|
||||
"operator": "set",
|
||||
"parameter": 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"begin"},
|
||||
Downstream: []string{"loop"},
|
||||
},
|
||||
"loop": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"input_mode": "variable", // dereference against state
|
||||
"value": "seed@initial",
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": threshold,
|
||||
"input_mode": "constant",
|
||||
},
|
||||
},
|
||||
"logical_operator": "and",
|
||||
"maximum_loop_count": 50,
|
||||
},
|
||||
},
|
||||
Upstream: []string{"seed"},
|
||||
Downstream: []string{"bump"},
|
||||
},
|
||||
"bump": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": []any{
|
||||
map[string]any{
|
||||
"variable": "loop@counter",
|
||||
"operator": "+=",
|
||||
"parameter": step,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"loop"},
|
||||
},
|
||||
},
|
||||
Path: []string{"begin", "loop"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_VariableModeInitDereferencesRef proves that the loop init
|
||||
// lambda actually dereferences input_mode="variable" refs against the
|
||||
// live CanvasState. Seed writes 5 to Outputs["seed"]["initial"]; the
|
||||
// loop's counter is initialised from "seed@initial" (a ref), so the
|
||||
// expected starting counter is 5. The bump node increments by 1 and
|
||||
// the loop terminates when counter >= 8. With correct resolution,
|
||||
// counter walks 5 → 6 → 7 → 8 (3 successful body runs) and stops.
|
||||
//
|
||||
// If the init lambda fails to dereference, counter is seeded with the
|
||||
// literal string "seed@initial" and `+= 1` fails on the first
|
||||
// iteration; the test would observe a counter of 0 (or a
|
||||
// PARAMETER_NOT_NUMBER error surfacing from bump).
|
||||
func TestLoop_VariableModeInitDereferencesRef(t *testing.T) {
|
||||
state, err := runLoopCanvas(t, variableModeLoopDSL(8, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
v, err := state.GetVar("loop@counter")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
got, ok := v.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("counter: want float64 (VariableAssigner += produces float64), got %T: %v — input_mode=variable init did not dereference the ref; the seed was written as the literal string %q instead of the resolved value", v, v, "seed@initial")
|
||||
}
|
||||
// 5 (resolved from seed@initial) + 1 + 1 + 1 = 8 (do-while: body
|
||||
// runs, THEN condition is checked). Threshold is 8, so the
|
||||
// condition fires after the 3rd body run, leaving counter=8.
|
||||
if got != 8 {
|
||||
t.Errorf("counter: got %v, want 8 (input_mode=variable should seed from seed@initial=5, then 3 increments to reach threshold)", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user