mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-12 03:33:33 +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:
54
internal/router/admin_routes.go
Normal file
54
internal/router/admin_routes.go
Normal 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)
|
||||
}
|
||||
103
internal/router/admin_routes_test.go
Normal file
103
internal/router/admin_routes_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
78
internal/router/agent_routes.go
Normal file
78
internal/router/agent_routes.go
Normal 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)
|
||||
}
|
||||
80
internal/router/agent_routes_test.go
Normal file
80
internal/router/agent_routes_test.go
Normal 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.
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user