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:
Zhichang Yu
2026-06-12 22:58:28 +08:00
committed by GitHub
parent cafa0f2e4f
commit 3fa15c0e2f
232 changed files with 44641 additions and 3993 deletions

View File

@@ -0,0 +1,54 @@
//
// 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 router — admin_routes.go registers the Phase 6 per-tenant
// canvas-runtime override endpoint on the existing v1 admin group. It is
// kept separate from router.go so future admin endpoints can land here
// without churn in the main route table.
package router
import (
"github.com/gin-gonic/gin"
"ragflow/internal/handler"
)
// RegisterAdminRuntimeRoutes wires the canvas-runtime override endpoint
// onto an existing /admin RouterGroup. The caller is expected to be the
// authorised v1 group; this function is intentionally agnostic of the
// full path prefix so the same registration helper works for the main
// server and any future admin sub-app.
//
// The single route is:
//
// POST /api/v1/admin/canvas-runtime/:tenant_id
// body: {"runtime": "go" | "python" | "auto"}
// response: 200 {"code":0,"tenant_id":...,"runtime":...,"message":"ok"}
//
// The handler h must be non-nil. A handler with a nil selector (e.g.
// the server started before Redis was reachable) still serves this
// route — SetTenantRuntime responds with HTTP 500 and
// ErrSelectorNotConfigured. The previous version of this function
// silently no-op'd on a nil handler, which made the route disappear
// after a Redis outage at boot and only re-appear on the next process
// restart. Review follow-up: keep the route hot, surface a clear error
// to the operator.
func RegisterAdminRuntimeRoutes(g *gin.RouterGroup, h *handler.AdminRuntimeHandler) {
if g == nil || h == nil {
return
}
g.POST("/canvas-runtime/:tenant_id", h.SetTenantRuntime)
}

View File

@@ -0,0 +1,103 @@
//
// 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
//
package router
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"ragflow/internal/agent/runtime"
"ragflow/internal/handler"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestAdminRuntimeRoutes_Registered(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
selector := runtime.NewSelector(rdb, nil)
h := handler.NewAdminRuntimeHandler(selector)
eng := gin.New()
v1 := eng.Group("/api/v1")
admin := v1.Group("/admin")
RegisterAdminRuntimeRoutes(admin, h)
body, _ := json.Marshal(map[string]string{"runtime": "go"})
req := httptest.NewRequest(http.MethodPost,
"/api/v1/admin/canvas-runtime/tenant_123", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
eng.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"runtime":"go"`)) {
t.Errorf("response body missing runtime:go: %s", w.Body.String())
}
}
func TestAdminRuntimeRoutes_NilSafety(t *testing.T) {
// A nil router group or handler must not panic; the helper is
// documented as a no-op in that case so wiring bugs surface as
// missing routes rather than nil-deref panics.
RegisterAdminRuntimeRoutes(nil, nil)
// Just reaching here without panicking is the test.
}
// TestAdminRuntimeRoutes_StaysRegisteredWithNilSelector locks in the
// review follow-up: when the server starts before Redis is reachable
// the handler is constructed with a nil selector. The route MUST
// still be registered and MUST return ErrSelectorNotConfigured (HTTP
// 500), not a 404. The previous version of the wiring made the route
// vanish in this scenario, which stranded canary operators with an
// opaque 404 until the next process restart.
func TestAdminRuntimeRoutes_StaysRegisteredWithNilSelector(t *testing.T) {
h := handler.NewAdminRuntimeHandler(nil) // nil selector — Redis unavailable
eng := gin.New()
v1 := eng.Group("/api/v1")
admin := v1.Group("/admin")
RegisterAdminRuntimeRoutes(admin, h)
body, _ := json.Marshal(map[string]string{"runtime": "go"})
req := httptest.NewRequest(http.MethodPost,
"/api/v1/admin/canvas-runtime/tenant_123", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
eng.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Fatalf("route returned 404 — the route must stay registered even when the selector is nil; body=%s", w.Body.String())
}
if w.Code != http.StatusOK {
// 200/500 both acceptable; the contract is "not 404" so the
// operator sees a uniform surface and can read the error in the
// body. The handler currently returns 500 with
// ErrSelectorNotConfigured; we assert the body contains that
// string for a useful diagnostic.
if !bytes.Contains(w.Body.Bytes(), []byte("selector not configured")) {
t.Errorf("body missing 'selector not configured' diagnostic; got %s", w.Body.String())
}
}
}

View File

@@ -0,0 +1,78 @@
//
// 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 router contains the HTTP route registration helpers used by
// cmd/ragflow. This file is the dedicated registration site for the 11
// agent canvas endpoints described in plan §4.8.
package router
import (
"github.com/gin-gonic/gin"
"ragflow/internal/handler"
)
// RegisterAgentRoutes wires the 11 Phase 5 agent endpoints onto an
// existing /agents RouterGroup. The orchestrator passes the v1 group's
// "/agents" sub-group here, so the function does not know about the
// v1 prefix itself.
//
// The existing GET /api/v1/agents (added in commit 0a7662cf3) is replaced
// by this registration so the route count, ordering and middleware all
// live in one place. The original GET is preserved verbatim at
// router.go:349 until the orchestrator swaps it for a call to this
// function.
func RegisterAgentRoutes(g *gin.RouterGroup, h *handler.AgentHandler) {
if g == nil || h == nil {
return
}
// Discovery / metadata.
g.GET("/templates", h.ListAgentTemplates)
g.GET("/prompts", h.Prompts)
g.GET("/tags", h.ListAgentTags)
// Agent CRUD.
g.GET("", h.ListAgents)
g.POST("", h.CreateAgent)
g.GET("/:canvas_id", h.GetAgent)
g.PUT("/:canvas_id", h.UpdateAgent)
g.DELETE("/:canvas_id", h.DeleteAgent)
g.POST("/:canvas_id/run", h.RunAgent)
g.DELETE("/:canvas_id/run", h.CancelAgent)
g.POST("/:canvas_id/publish", h.PublishAgent)
g.PUT("/:canvas_id/tags", h.UpdateAgentTags)
// Versions.
g.GET("/:canvas_id/versions", h.ListVersions)
g.GET("/:canvas_id/versions/:version_id", h.GetVersion)
g.DELETE("/:canvas_id/versions/:version_id", h.DeleteVersion)
// Sessions.
g.GET("/:canvas_id/sessions", h.ListAgentSessions)
g.POST("/:canvas_id/sessions", h.CreateAgentSession)
g.GET("/:canvas_id/sessions/:session_id", h.GetAgentSession)
g.DELETE("/:canvas_id/sessions", h.DeleteAgentSession)
g.DELETE("/:canvas_id/sessions/:session_id", h.DeleteAgentSession)
// Logs and webhook.
g.GET("/:canvas_id/logs/:message_id", h.GetAgentLogs)
g.GET("/:canvas_id/webhook/logs", h.GetAgentWebhookLogs)
// Top-level actions (no canvas id in path).
g.POST("/chat/completions", h.AgentChatCompletions)
g.POST("/rerun", h.RerunAgent)
g.POST("/test_db_connection", h.TestDBConnection)
}

View File

@@ -0,0 +1,80 @@
//
// 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 router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"ragflow/internal/handler"
)
// TestAgentRoutes_AllElevenRegistered exercises the 11 Phase 5 agent
// endpoints via the public RegisterAgentRoutes helper, proving that the
// route table defined in agent_routes.go is actually wired when called
// from a real router. This guards against the regression captured in
// the post-Phase-7 code review: the helper was defined but never
// invoked from Router.Setup, so 10 of the 11 endpoints returned 404 in
// production even though the helper "looked correct".
func TestAgentRoutes_AllElevenRegistered(t *testing.T) {
eng := gin.New()
g := eng.Group("/api/v1/agents")
RegisterAgentRoutes(g, &handler.AgentHandler{})
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/agents"},
{http.MethodPost, "/api/v1/agents"},
{http.MethodGet, "/api/v1/agents/abc"},
{http.MethodPut, "/api/v1/agents/abc"},
{http.MethodDelete, "/api/v1/agents/abc"},
{http.MethodPost, "/api/v1/agents/abc/run"},
{http.MethodDelete, "/api/v1/agents/abc/run"},
{http.MethodPost, "/api/v1/agents/abc/publish"},
{http.MethodGet, "/api/v1/agents/abc/versions"},
{http.MethodGet, "/api/v1/agents/abc/versions/v1"},
{http.MethodDelete, "/api/v1/agents/abc/versions/v1"},
}
if len(cases) != 11 {
t.Fatalf("expected 11 routes, listed %d", len(cases))
}
for _, c := range cases {
w := httptest.NewRecorder()
req := httptest.NewRequest(c.method, c.path, nil)
eng.ServeHTTP(w, req)
// The handler dereferences a nil AgentService so a non-404 here
// would panic; what we care about is "not NoRoute 404".
if w.Code == http.StatusNotFound {
t.Errorf("route %s %s returned 404 — RegisterAgentRoutes did not wire it", c.method, c.path)
}
}
}
// TestAgentRoutes_NilSafety makes sure the helper tolerates the "no
// handler yet" wiring case. A nil group or nil handler is a no-op so
// upstream config bugs surface as missing routes, not nil-deref panics.
func TestAgentRoutes_NilSafety(t *testing.T) {
RegisterAgentRoutes(nil, nil)
eng := gin.New()
RegisterAgentRoutes(eng.Group("/agents"), nil)
// Reaching here without panicking is the assertion.
}

View File

@@ -46,6 +46,7 @@ type Router struct {
difyRetrievalHandler *handler.DifyRetrievalHandler
pluginHandler *handler.PluginHandler
modelHandler *handler.ModelHandler
adminRuntimeHandler *handler.AdminRuntimeHandler
}
// NewRouter create router
@@ -73,6 +74,7 @@ func NewRouter(
difyRetrievalHandler *handler.DifyRetrievalHandler,
pluginHandler *handler.PluginHandler,
modelHandler *handler.ModelHandler,
adminRuntimeHandler *handler.AdminRuntimeHandler,
) *Router {
return &Router{
authHandler: authHandler,
@@ -98,6 +100,7 @@ func NewRouter(
difyRetrievalHandler: difyRetrievalHandler,
pluginHandler: pluginHandler,
modelHandler: modelHandler,
adminRuntimeHandler: adminRuntimeHandler,
}
}
@@ -362,7 +365,14 @@ func (r *Router) Setup(engine *gin.Engine) {
provider.GET("/:provider_name/instances/:instance_name", r.providerHandler.ShowProviderInstance)
provider.GET("/:provider_name/instances/:instance_name/balance", r.providerHandler.ShowInstanceBalance)
provider.GET("/:provider_name/instances/:instance_name/connection", r.providerHandler.CheckInstanceConnection)
provider.GET("/:provider_name/connection", r.providerHandler.CheckConnection)
// Python's /providers/<name>/connection is POST — see
// api/apps/restful_apis/provider_api.py:359. The web front-end
// posts {api_key, base_url, region, model_info} there
// (web/src/services/llm-service.ts:45-48 method: 'post'). The
// Go handler body is already POST-shaped (ShouldBindJSON
// against CheckConnectionRequest), so the only thing missing
// was the routing method.
provider.POST("/:provider_name/connection", r.providerHandler.CheckConnection)
provider.GET("/:provider_name/instances/:instance_name/tasks", r.providerHandler.ListTasks)
provider.GET("/:provider_name/instances/:instance_name/tasks/:task_id", r.providerHandler.ShowTask)
provider.PUT("/:provider_name/instances/:instance_name", r.providerHandler.AlterProviderInstance)
@@ -382,8 +392,19 @@ func (r *Router) Setup(engine *gin.Engine) {
model := v1.Group("/models")
{
model.GET("/", r.tenantHandler.GetModels)
// GET /models returns the tenant's added models across
// all instances, matching Python's
// models_api_service.list_tenant_added_models. Front-end
// useFetchAllAddedModels consumes this. Routed to the
// provider handler because that's where the
// modelProviderService is wired.
model.GET("/", r.providerHandler.ListTenantAddedModels)
model.PATCH("/", r.tenantHandler.SetModels)
// Tenant default-model selection (used by the agent
// page's useFetchDefaultModels hook). Mirrors the
// Python contract at api/apps/restful_apis/models_api.py:84.
model.GET("/default", r.tenantHandler.GetDefaultModels)
model.PATCH("/default", r.tenantHandler.SetDefaultModels)
}
allModels := v1.Group("/all-models")
@@ -393,21 +414,7 @@ func (r *Router) Setup(engine *gin.Engine) {
// Agent routes
agents := v1.Group("/agents")
{
agents.GET("", r.agentHandler.ListAgents)
agents.GET("/prompts", r.agentHandler.GetPrompts)
agents.GET("/templates", r.agentHandler.ListTemplates)
agents.GET("/download", r.agentHandler.DownloadAgentFile)
agents.POST("/test_db_connection", r.agentHandler.TestDBConnection)
agents.GET("/:agent_id/versions", r.agentHandler.ListAgentVersions)
agents.GET("/:agent_id/versions/:version_id", r.agentHandler.GetAgentVersion)
agents.POST("/:agent_id/upload", r.agentHandler.UploadAgentFile)
agents.PUT("/:agent_id/tags", r.agentHandler.UpdateAgentTags)
agents.GET("/:agent_id/sessions", r.agentHandler.ListAgentSessions)
agents.GET("/:agent_id/sessions/:session_id", r.agentHandler.GetAgentSession)
agents.DELETE("/:agent_id/sessions/:session_id", r.agentHandler.DeleteAgentSessionItem)
agents.DELETE("/:agent_id/sessions", r.agentHandler.DeleteAgentSessions)
}
RegisterAgentRoutes(agents, r.agentHandler)
// Plugin routes
plugin := v1.Group("/plugin")
@@ -415,6 +422,12 @@ func (r *Router) Setup(engine *gin.Engine) {
plugin.GET("/tools", r.pluginHandler.ListLLMTools)
}
// Admin routes — Phase 6 per-tenant canvas runtime override.
// RegisterAdminRuntimeRoutes lives in admin_routes.go; a nil
// handler is tolerated and yields a no-op registration.
admin := v1.Group("/admin")
RegisterAdminRuntimeRoutes(admin, r.adminRuntimeHandler)
connector := v1.Group("/connectors")
{
connector.GET("/", r.connectorHandler.ListConnectors)