mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-04 09:39:32 +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
193 lines
7.1 KiB
Go
193 lines
7.1 KiB
Go
// Package dsl — loader test for the production v1 DSL examples.
|
|
//
|
|
// These fixtures are mirrored from agent/test/dsl_examples/*.json so
|
|
// the Go port has a self-contained set of representative v1 payloads
|
|
// to exercise against. The Python side ships the originals (and may
|
|
// grow them); this directory is the Go side's frozen copy for unit
|
|
// tests so the Go port is buildable on its own.
|
|
//
|
|
// The test walks every fixture through the four DSL entry points
|
|
// (DetectVersion, LoadV1, Load auto-detect, DecodeReader) and asserts
|
|
// the converted v2 Canvas is structurally sound in each case. The
|
|
// four entry points differ only in how the bytes reach the
|
|
// parser/detector — by covering them all from one table we catch
|
|
// any drift in detection rules or converter behaviour without
|
|
// fragmenting coverage into per-entry-point subtests.
|
|
//
|
|
// Each example covers a distinct topology:
|
|
//
|
|
// retrieval_and_generate — Begin → Retrieval → LLM → Message
|
|
// tavily_and_generate — Begin → TavilySearch → LLM → Message
|
|
// categorize_and_agent_with_tavily — Begin → Categorize → {Agent(Tavily) | Message}
|
|
// retrieval_categorize_and_generate — Begin → Categorize → {Retrieval → Agent → Message | Message}
|
|
// iteration — Begin → Agent → Iteration → {IterationItem → TavilySearch → Agent}
|
|
// exesql — Begin → Answer ↔ ExeSQL (cycle, kept in fixtures as a stress test)
|
|
// headhunter_zh — multi-Categorize, multi-Generate, Answer+Message loop
|
|
package dsl
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// v1Examples lists every fixture under testdata/v1_examples. Adding
|
|
// a new file to the directory automatically extends coverage.
|
|
var v1Examples = []string{
|
|
"categorize_and_agent_with_tavily.json",
|
|
"exesql.json",
|
|
"headhunter_zh.json",
|
|
"iteration.json",
|
|
"retrieval_and_generate.json",
|
|
"retrieval_categorize_and_generate.json",
|
|
"tavily_and_generate.json",
|
|
}
|
|
|
|
// readV1Example reads a v1 fixture from testdata/v1_examples. Tests
|
|
// call t.Skip if the file is missing so the package still builds in
|
|
// environments where the fixtures have not been vendored.
|
|
func readV1Example(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
path := filepath.Join("testdata", "v1_examples", name)
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Skipf("v1 fixture %s not readable: %v", path, err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// TestV1Examples is the single canary for the v1 fixture set. It
|
|
// drives every fixture through the four DSL entry points and asserts
|
|
// the resulting v2 Canvas is structurally sound in each case. Failing
|
|
// here means either the directory contents drifted, a new fixture
|
|
// doesn't match the v1 envelope, or the converter lost something on
|
|
// its way to v2.
|
|
func TestV1Examples(t *testing.T) {
|
|
// Directory canary: the on-disk list must match v1Examples. This
|
|
// guards against the suite silently passing because new files
|
|
// were added without updating v1Examples, or vice versa.
|
|
entries, err := os.ReadDir(filepath.Join("testdata", "v1_examples"))
|
|
if err != nil {
|
|
t.Fatalf("testdata/v1_examples not accessible: %v", err)
|
|
}
|
|
got := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
|
|
got = append(got, e.Name())
|
|
}
|
|
}
|
|
sort.Strings(got)
|
|
want := append([]string(nil), v1Examples...)
|
|
sort.Strings(want)
|
|
if len(got) != len(want) {
|
|
t.Fatalf("v1_examples count = %d (%v), want %d (%v)", len(got), got, len(want), want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("v1_examples[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
for _, name := range v1Examples {
|
|
t.Run(name, func(t *testing.T) {
|
|
raw := readV1Example(t, name)
|
|
|
|
// DetectVersion must report V1.
|
|
v, err := DetectVersion(raw)
|
|
if err != nil {
|
|
t.Fatalf("DetectVersion: %v", err)
|
|
}
|
|
if v != V1 {
|
|
t.Fatalf("DetectVersion = %s, want v1", v)
|
|
}
|
|
|
|
// LoadV1 (explicit v1) and Load (auto-detect) must both
|
|
// produce a valid v2 Canvas. Comparing the two
|
|
// additionally proves the auto-detect path routed to
|
|
// LoadV1 (the canvas shapes must match).
|
|
explicit, err := LoadV1(raw)
|
|
if err != nil {
|
|
t.Fatalf("LoadV1: %v", err)
|
|
}
|
|
assertConvertedCanvasOK(t, name, explicit)
|
|
|
|
auto, err := Load(raw)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
assertConvertedCanvasOK(t, name, auto)
|
|
if len(auto.Components) != len(explicit.Components) {
|
|
t.Errorf("Load and LoadV1 disagree on component count: %d vs %d",
|
|
len(auto.Components), len(explicit.Components))
|
|
}
|
|
|
|
// DecodeReader (io.Reader path) must produce the same
|
|
// v2 Canvas up to component count.
|
|
decoded, err := DecodeReader(strings.NewReader(string(raw)))
|
|
if err != nil {
|
|
t.Fatalf("DecodeReader: %v", err)
|
|
}
|
|
if decoded.Version != CurrentVersion {
|
|
t.Errorf("DecodeReader Version = %d, want %d", decoded.Version, CurrentVersion)
|
|
}
|
|
if len(decoded.Components) != len(explicit.Components) {
|
|
t.Errorf("DecodeReader produced %d components, LoadV1 produced %d",
|
|
len(decoded.Components), len(explicit.Components))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// assertConvertedCanvasOK verifies a v1-converted Canvas is a valid v2
|
|
// graph: non-empty components, non-empty names, no colon in ids, and
|
|
// every Downstream ref resolves. This is the load-time check that
|
|
// protects against drift in either the fixture or the converter.
|
|
func assertConvertedCanvasOK(t *testing.T, name string, c *Canvas) {
|
|
t.Helper()
|
|
if c.Version != CurrentVersion {
|
|
t.Errorf("[%s] Version = %d, want %d", name, c.Version, CurrentVersion)
|
|
}
|
|
if len(c.Components) == 0 {
|
|
t.Fatalf("[%s] Components is empty", name)
|
|
}
|
|
byName := map[string]int{}
|
|
for id, cpn := range c.Components {
|
|
if strings.Contains(id, ":") {
|
|
t.Errorf("[%s] v2 id %q still contains ':'", name, id)
|
|
}
|
|
if id != cpn.ID && cpn.ID != "" {
|
|
t.Errorf("[%s] key %q != Component.ID %q", name, id, cpn.ID)
|
|
}
|
|
if cpn.Name == "" {
|
|
t.Errorf("[%s] component %q has empty Name", name, id)
|
|
}
|
|
for _, ds := range cpn.Downstream {
|
|
if _, ok := c.Components[ds]; !ok {
|
|
t.Errorf("[%s] component %q downstream %q does not exist", name, id, ds)
|
|
}
|
|
}
|
|
byName[cpn.Name]++
|
|
}
|
|
// Begin must be present in every fixture (they all start from a
|
|
// user query). Catches the case where someone renames Begin in a
|
|
// fixture and silently breaks every orchestrator that looks it up.
|
|
if byName["Begin"] == 0 {
|
|
t.Errorf("[%s] no Begin component found in converted canvas (have %v)", name, byName)
|
|
}
|
|
// We deliberately do NOT require at least one terminal node
|
|
// here: the v1 fixtures include Answer↔ExeSQL and Answer↔Message
|
|
// cycles (e.g. exesql.json, headhunter_zh.json) where every
|
|
// component has a non-empty downstream by design — the cycle
|
|
// represents the agent waiting for the next user turn. Cycle
|
|
// detection is a runtime concern, not a load-time one, and lives
|
|
// in the scheduler.
|
|
//
|
|
// Run the v2 Validate as the final gate. This catches anything
|
|
// the per-component loop above did not (e.g. Version mismatch).
|
|
if err := c.Validate(); err != nil {
|
|
t.Errorf("[%s] Validate: %v", name, err)
|
|
}
|
|
}
|