feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)

Replaces the Python agent canvas runtime with a Go implementation that
runs inside `cmd/server_main`.

The canvas compiles into an eino Workflow that pauses on wait-for-user
via native Interrupt/Resume (no sentinel flag) and resumes from a
Redis-backed CheckPointStore.

All 21 Python agent components and ~35 tools are ported with functional
parity.

Sandbox providers now read their JSON config from the admin-panel
system_settings table with env fallback.

234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate
added); drops the v2 DSL E2E step and the gap-analysis plan (both
redundant after the port ships).

## Type of change

- [x] Refactoring
- [x] New feature
- [x] Bug fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zhichang Yu
2026-06-17 13:24:03 +08:00
committed by GitHub
parent 2290bb0023
commit e45659868a
231 changed files with 33807 additions and 6114 deletions

View File

@@ -39,6 +39,8 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"ragflow/internal/agent/audio"
"ragflow/internal/agent/canvas"
"ragflow/internal/agent/runtime"
"ragflow/internal/dao"
"ragflow/internal/engine"
@@ -240,7 +242,29 @@ func startServer(config *server.Config) {
mcpHandler := handler.NewMCPHandler(mcpService)
skillSearchHandler := handler.NewSkillSearchHandler(docEngine)
providerHandler := handler.NewProviderHandler(userService, modelProviderService)
agentHandler := handler.NewAgentHandler(service.NewAgentService(), fileService)
// Install the agent service's Redis-backed run infrastructure
// (CheckPointStore / StateSerializer / RunTracker). When Redis
// is unreachable (degraded boot, stand-alone mode, no-redis CI)
// the constructors return errors and we fall through to the
// in-memory / no-tracking path: the agent service treats nil
// options as the in-memory test path, so graceful degradation
// is a 1-line if-not-nil pass-through — no separate "boot" mode
// required.
agentOpts := buildAgentRunOptions()
agentHandler := handler.NewAgentHandler(service.NewAgentServiceWithOptions(
agentOpts.checkpointStore,
agentOpts.stateSerializer,
agentOpts.runTracker,
), fileService)
// Wire the TTS synthesizer to the per-tenant model-provider
// dispatch. SynthesizeRequest is routed through
// ModelProviderService.AudioSpeech, which fans out to the
// tenant's configured TTS model driver. When the model
// provider is unconfigured, the synthesizer falls back to a
// no-op echo (the audio package contract), so this is always
// safe to call.
configureTTSSynthesizer(modelProviderService)
searchBotLLM := &handler.SearchBotRealLLM{Svc: modelProviderService}
searchBotHandler := handler.NewSearchBotHandler(
searchService,
@@ -266,16 +290,16 @@ func startServer(config *server.Config) {
docEngine,
)
// Phase 6 per-tenant canvas-runtime override. The selector is backed by
// the existing Redis client and the global logger. The handler is
// ALWAYS constructed, even when Redis is briefly unavailable at startup,
// so the POST /api/v1/admin/canvas-runtime/:tenant_id endpoint stays
// registered and returns the explicit ErrSelectorNotConfigured (HTTP 500)
// path until Redis recovers. The previous behaviour — skipping handler
// construction when rdb == nil silently removed the route until the
// next process restart, so a transient Redis blip at boot stranded
// canary operators with a 404 they could not diagnose from the client
// side. Review follow-up: keep the route hot.
// Per-tenant canvas-runtime override selector, backed by the
// existing Redis client and the global logger. The handler is
// ALWAYS constructed, even when Redis is briefly unavailable at
// startup, so the POST /api/v1/admin/canvas-runtime/:tenant_id
// endpoint stays registered and returns the explicit
// ErrSelectorNotConfigured (HTTP 500) path until Redis recovers.
// Skipping handler construction when rdb == nil silently removed
// the route until the next process restart, so a transient
// Redis blip at boot stranded canary operators with a 404 they
// could not diagnose from the client side. Keep the route hot.
var adminRuntimeSelector *runtime.Selector
if rdb := redis.Get().GetClient(); rdb != nil {
adminRuntimeSelector = runtime.NewSelector(rdb, common.Logger)
@@ -371,3 +395,72 @@ func startServer(config *server.Config) {
common.Fatal("Server forced to shutdown", zap.Error(err))
}
}
// agentRunOptions bundles the three optional injection slots the
// agent service accepts via NewAgentServiceWithOptions: the Redis-
// backed CheckPointStore, StateSerializer, and RunTracker. The
// fields stay nil when the underlying constructors fail (Redis
// unreachable, etc.); the agent service treats nil as "in-memory
// / no-tracking" so the server continues to serve traffic without
// requiring Redis to be up.
type agentRunOptions struct {
checkpointStore canvas.CheckPointStore
stateSerializer canvas.StateSerializer
runTracker *canvas.RunTracker
}
// buildAgentRunOptions installs the Redis-backed run infrastructure
// when Redis is available. The Redis client is the one already
// initialised at the top of main; the TTL is a conservative 24h for
// both the checkpoint store and the run tracker. On any error
// (Redis down at boot, constructor panic, nil-Redis fallback) we
// log and return a zero-value struct — the agent service falls back
// to the in-memory path transparently.
func buildAgentRunOptions() agentRunOptions {
var out agentRunOptions
if !redis.IsEnabled() || redis.Get() == nil {
common.Info("agent: redis client not initialised; agent run infra in in-memory mode (no checkpoints, no run tracker)")
return out
}
cp := canvas.NewRedisCheckPointStore(24 * time.Hour)
out.checkpointStore = cp
// stateSerializer is intentionally left nil. eino's default
// InternalSerializer (used when no compose.WithSerializer is
// passed at compile time) already knows how to round-trip
// runtime.CanvasState because the runtime package registers
// it via compose.RegisterSerializableType[CanvasState] in
// init(). Overriding with RAGFlow's plain-JSON
// CanvasStateSerializer (json.Marshal/Unmarshal) produces
// bytes the InternalSerializer cannot decode on the resume
// pass — the UserFillUp two-node pattern surfaces this as
// "load checkpoint from store fail: cannot unmarshal object
// into Go struct field checkpoint.Channels of type
// compose.channel". Rely on eino's default instead.
rt := canvas.NewRunTracker(24 * time.Hour)
out.runTracker = rt
common.Info("agent: redis-backed run infra installed (24h TTL on checkpoint store + run tracker; eino default serializer)")
return out
}
// configureTTSSynthesizer installs the audio.ModelProviderFunc
// that dispatches Synthesize requests through the project's
// ModelProviderService. The model provider's AudioSpeech method
// (internal/service/model_service.go) resolves the per-tenant TTS
// model driver, sends the request upstream, and returns
// synthesized audio bytes.
//
// The audio package's NewTTSDispatchFunc helper converts the
// audio.SynthesizeRequest shape into the model's dispatch shape
// (audioContent = req.Text, voice/lang → TTSConfig.Params,
// ModelName from req.Engine). When the model provider is
// unconfigured (nil dispatcher) the helper returns nil, which
// reverts the audio package to its default stub.
func configureTTSSynthesizer(modelProviderService *service.ModelProviderService) {
if modelProviderService == nil {
common.Info("agent: model provider service not initialised; TTS in no-op echo mode")
audio.SetModelProviderSynthesizer(nil)
return
}
audio.SetModelProviderSynthesizer(audio.NewTTSDispatchFunc(modelProviderService))
common.Info("agent: TTS model-provider dispatch installed (audio.Synthesize → ModelProviderService.AudioSpeech)")
}