mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +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
162 lines
5.3 KiB
Go
162 lines
5.3 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 canvas
|
|
|
|
import (
|
|
"reflect"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
func TestCanvasStateSerializer_RoundTrip(t *testing.T) {
|
|
src := NewCanvasState("run_abc", "task_xyz")
|
|
src.Outputs["retrieval_0"] = map[string]any{
|
|
"chunks": []string{"a", "b", "c"},
|
|
"doc_aggs": map[string]int{"doc1": 3, "doc2": 1},
|
|
}
|
|
src.Outputs["llm_0"] = map[string]any{
|
|
"answer": "the sky is blue",
|
|
"tokens": 17,
|
|
"model": "gpt-4o-mini",
|
|
"stopped": true,
|
|
}
|
|
src.Sys["query"] = "what color is the sky?"
|
|
src.Sys["user_id"] = "u_42"
|
|
src.Sys["files"] = []any{"f1", "f2"}
|
|
src.Env["DEPLOY_REGION"] = "us-west-2"
|
|
src.Env["MODEL_TIER"] = "small"
|
|
src.Path = []string{"begin_0", "retrieval_0", "llm_0", "message_0"}
|
|
src.History = []map[string]any{
|
|
{"role": "user", "content": "earlier turn"},
|
|
{"role": "assistant", "content": "earlier reply"},
|
|
}
|
|
src.Globals["shared_key"] = "v1"
|
|
src.CancelFlag.Store(true)
|
|
|
|
ser := CanvasStateSerializer{}
|
|
data, err := ser.Marshal(src)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("Marshal returned empty bytes")
|
|
}
|
|
|
|
dst := NewCanvasState("", "")
|
|
if err := ser.Unmarshal(data, dst); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
|
|
if dst.RunID != src.RunID {
|
|
t.Fatalf("RunID = %q, want %q", dst.RunID, src.RunID)
|
|
}
|
|
if dst.TaskID != src.TaskID {
|
|
t.Fatalf("TaskID = %q, want %q", dst.TaskID, src.TaskID)
|
|
}
|
|
// JSON round-trip coerces numbers to float64, so we re-marshal both
|
|
// sides and compare bytes — that is the real contract of the
|
|
// serializer (lossless across the eino checkpoint boundary).
|
|
srcBytes, _ := ser.Marshal(src)
|
|
dstBytes, _ := ser.Marshal(dst)
|
|
if string(srcBytes) != string(dstBytes) {
|
|
t.Fatalf("round-trip not stable:\n src→bytes: %s\n dst→bytes: %s",
|
|
srcBytes, dstBytes)
|
|
}
|
|
// Direct checks for the non-JSON-coerced fields.
|
|
// Note: CancelFlag is *atomic.Bool; encoding/json does not marshal
|
|
// its unexported fields, so the flag is reset to its zero value on
|
|
// round-trip. That is acceptable for the canvas checkpoint
|
|
// contract — the cancel signal lives in Redis (cancel.go) and a
|
|
// resumed run gets a fresh context. The non-nil pointer is the
|
|
// invariant that matters: nodes must always be able to call .Load()
|
|
// without checking for nil first.
|
|
if dst.CancelFlag == nil {
|
|
t.Fatal("CancelFlag is nil after Unmarshal; downstream .Load() would panic")
|
|
}
|
|
// Spot check that nested maps survive.
|
|
if dst.Outputs["llm_0"]["model"] != "gpt-4o-mini" {
|
|
t.Fatalf("nested map lost: %v", dst.Outputs)
|
|
}
|
|
if v, _ := dst.Sys["user_id"].(string); v != "u_42" {
|
|
t.Fatalf("Sys[user_id] = %v", dst.Sys["user_id"])
|
|
}
|
|
// Suppress unused import warning when reflect.DeepEqual is removed.
|
|
_ = reflect.DeepEqual
|
|
}
|
|
|
|
func TestCanvasStateSerializer_EmptyState(t *testing.T) {
|
|
// Edge case: zero-value state must round-trip without error.
|
|
src := NewCanvasState("r", "t")
|
|
ser := CanvasStateSerializer{}
|
|
data, err := ser.Marshal(src)
|
|
if err != nil {
|
|
t.Fatalf("Marshal empty: %v", err)
|
|
}
|
|
dst := NewCanvasState("", "")
|
|
if err := ser.Unmarshal(data, dst); err != nil {
|
|
t.Fatalf("Unmarshal empty: %v", err)
|
|
}
|
|
if dst.RunID != "r" || dst.TaskID != "t" {
|
|
t.Fatalf("ids not preserved: %q %q", dst.RunID, dst.TaskID)
|
|
}
|
|
}
|
|
|
|
func TestCanvasStateSerializer_UnmarshalIntoExistingPointer(t *testing.T) {
|
|
// The eino contract: Unmarshal fills a caller-owned pointer. Confirm
|
|
// nested maps are populated (not just the top-level struct).
|
|
src := NewCanvasState("r2", "t2")
|
|
src.Outputs["only"] = map[string]any{"k": "v"}
|
|
src.Sys["x"] = 1
|
|
ser := CanvasStateSerializer{}
|
|
data, _ := ser.Marshal(src)
|
|
|
|
dst := NewCanvasState("old", "old")
|
|
if err := ser.Unmarshal(data, dst); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if dst.Outputs["only"]["k"] != "v" {
|
|
t.Fatalf("nested map not preserved: %v", dst.Outputs)
|
|
}
|
|
if v, ok := dst.Sys["x"].(float64); !ok || v != 1 {
|
|
t.Fatalf("Sys[x] = %v (%T), want float64(1)", dst.Sys["x"], dst.Sys["x"])
|
|
}
|
|
// Ids are overwritten by the round-trip.
|
|
if dst.RunID != "r2" || dst.TaskID != "t2" {
|
|
t.Fatalf("ids not overwritten: %q %q", dst.RunID, dst.TaskID)
|
|
}
|
|
}
|
|
|
|
// Ensure atomic.Bool preserves its zero value through JSON when set to false
|
|
// (avoids future regression on CancelFlag handling).
|
|
func TestCanvasStateSerializer_CancelFlagZero(t *testing.T) {
|
|
src := NewCanvasState("r3", "t3")
|
|
ser := CanvasStateSerializer{}
|
|
data, _ := ser.Marshal(src)
|
|
dst := NewCanvasState("", "")
|
|
if err := ser.Unmarshal(data, dst); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if dst.CancelFlag == nil {
|
|
t.Fatal("CancelFlag is nil after Unmarshal")
|
|
}
|
|
if dst.CancelFlag.Load() {
|
|
t.Fatal("CancelFlag is true, want false")
|
|
}
|
|
// Cross-check the atomic is the same struct shape.
|
|
var _ *atomic.Bool = dst.CancelFlag
|
|
}
|