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,117 @@
//
// 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.
//
// runtime — shared Component contract + factory injection point.
//
// The Component interface here is the minimal surface the canvas
// builder needs to invoke a component body. The full Component
// interface (with Name / Stream / Inputs / Outputs) lives in the
// component package; component.Component satisfies the smaller
// runtime.Component by Go's structural typing.
//
// Production wiring: the component package calls
// SetDefaultFactory(component.New) from its init() so the canvas
// builder can resolve real components via DefaultFactory() at
// BuildWorkflow time. No `canvas -> component` import edge is
// required.
package runtime
import (
"context"
"fmt"
"sync"
)
// Component is the minimal interface the canvas builder needs at
// sub-graph build time and at iteration time. The component package's
// Component type has more methods (Name / Stream / Inputs / Outputs);
// it satisfies this smaller interface by Go's structural typing.
type Component interface {
Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error)
}
// ComponentFactory builds a Component from a DSL name + params map.
// The canvas builder calls this per cpn at BuildWorkflow time and
// stores the resulting Component in the per-node lambda closure.
type ComponentFactory func(name string, params map[string]any) (Component, error)
// ErrNotImplemented is the sentinel returned by components that have
// not been fully ported to Go yet. The canvas builder does NOT
// intercept this error: it propagates through the workflowx layer and
// fails the run, mirroring Go's standard "error from a dependency
// fails the call" semantics. Callers that want to treat a not-yet-
// implemented component as a soft-fail should wrap Invoke themselves
// (e.g. with a placeholder lambda) or check errors.Is against this
// sentinel at the test layer.
//
// Test- or log-grep code that pattern-matches this string should use
// errors.Is(err, ErrNotImplemented) instead of substring matching —
// the message is for humans, the sentinel is for code.
var ErrNotImplemented = fmt.Errorf("component: not yet implemented (placeholder)")
// ParamError wraps a parameter validation failure with the field name
// for clearer error messages to the user.
type ParamError struct {
Field string
Reason string
}
func (e *ParamError) Error() string {
return fmt.Sprintf("component: invalid param %q: %s", e.Field, e.Reason)
}
var (
factoryMu sync.RWMutex
defaultFactory ComponentFactory
)
// SetDefaultFactory installs the production ComponentFactory. The
// component package calls this in its init() with `component.New`.
// Calling SetDefaultFactory more than once with a non-nil factory is
// a no-op after the first call (the first wins) so concurrent
// registration is safe. Passing nil clears the factory — tests use
// this to assert "no factory registered" error paths.
func SetDefaultFactory(f ComponentFactory) {
factoryMu.Lock()
defer factoryMu.Unlock()
if f == nil {
defaultFactory = nil
return
}
if defaultFactory == nil {
defaultFactory = f
}
}
// DefaultFactory returns the registered ComponentFactory, or nil if
// none has been registered. The canvas builder calls this once per
// BuildWorkflow and errors with a clear "no component factory
// registered" message if the result is nil.
func DefaultFactory() ComponentFactory {
factoryMu.RLock()
defer factoryMu.RUnlock()
return defaultFactory
}
// ResetDefaultFactoryForTesting clears the registered factory.
// Test-only helper for code paths that want to assert behaviour
// when no factory is installed. Not safe under concurrent use with
// production code paths.
func ResetDefaultFactoryForTesting() {
factoryMu.Lock()
defer factoryMu.Unlock()
defaultFactory = nil
}

View File

@@ -0,0 +1,73 @@
//
// 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.
//
// runtime — context-attached canvas state for cross-package access.
//
// The compile entry (canvas/compile.go) attaches *CanvasState to ctx
// once per run via WithState. Component bodies retrieve it via
// GetStateFromContext. eino's internal state plumbing also threads
// the state via WithGenLocalState; this context-key path is the
// fallback used by code that does not run as an eino handler (e.g.
// loop condition closures, ad-hoc tests, and the placeholder
// component bodies that BuildWorkflow installs).
package runtime
import (
"context"
"fmt"
"sync"
)
// stateCtxKey is the unexported context key used by WithState /
// GetStateFromContext. Defined at package scope so its identity is
// stable across calls (a fresh struct{}{} per call would key
// distinctly and break ctx.Value lookups).
type stateCtxKey struct{}
// WithState attaches *CanvasState to ctx for retrieval by
// GetStateFromContext. Production code (canvas/compile.go) calls this
// once per run; cross-package tests call it directly to set up state
// before invoking a component.
func WithState(ctx context.Context, s *CanvasState) context.Context {
return context.WithValue(ctx, stateCtxKey{}, s)
}
// GetStateFromContext extracts a typed state attached via WithState.
// Returns the state and a nil *sync.Mutex for *CanvasState (the
// embedded RWMutex is what callers actually contend on through
// helper methods); the *sync.Mutex return value mirrors eino's
// getState shape for API parity.
//
// The generic type parameter is needed for compatibility with eino's
// compose.getState[S] signature so callers can write the same shape
// whether they're reading our state or eino's.
func GetStateFromContext[S any](ctx context.Context) (S, *sync.Mutex, error) {
var zero S
v := ctx.Value(stateCtxKey{})
if v == nil {
return zero, nil, fmt.Errorf("canvas: no state in context")
}
s, ok := v.(S)
if !ok {
return zero, nil, fmt.Errorf("canvas: state type mismatch: have %T, want %T", v, zero)
}
// For *CanvasState the returned *sync.Mutex is nil on purpose:
// CanvasState exposes its own sync.RWMutex via the exported
// methods (GetVar / SetVar / ReadVars), all of which lock
// internally. Callers reading *CanvasState should prefer the
// self-locking methods over holding the mutex themselves.
return s, nil, nil
}

View File

@@ -0,0 +1,102 @@
//
// 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 runtime
import (
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
)
// outcomeSuccess / outcomeError / outcomeCancelled are the only outcome
// label values the canvas-run metric emits. Keeping the set closed lets
// downstream alerts reason about the cardinality.
const (
OutcomeSuccess = "success"
OutcomeError = "error"
OutcomeCancelled = "cancelled"
)
var (
canvasRunsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "ragflow_canvas_runs_total",
Help: "Total canvas runs by runtime mode and outcome.",
},
[]string{"runtime", "outcome"},
)
canvasRunDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ragflow_canvas_run_duration_seconds",
Help: "Canvas run latency in seconds.",
Buckets: prometheus.DefBuckets,
},
[]string{"runtime"},
)
registerOnce sync.Once
)
// init registers the package's metrics with the default Prometheus
// registry. The sync.Once guard means tests that re-import this package
// (or any future entry point that also imports it) won't trigger
// "duplicate metrics collector registration" panics. If a test wants a
// clean registry, call ResetMetrics().
func init() {
registerOnce.Do(func() {
prometheus.MustRegister(canvasRunsTotal, canvasRunDuration)
})
}
// ResetMetricsForTesting unregisters the package metrics from the default
// Prometheus registry, clears any recorded samples, and re-registers them
// so the next ObserveRun call sees a clean slate. Intended for unit tests
// that need to assert on freshly-registered metrics.
func ResetMetricsForTesting() {
prometheus.DefaultRegisterer.Unregister(canvasRunsTotal)
prometheus.DefaultRegisterer.Unregister(canvasRunDuration)
canvasRunsTotal.Reset()
canvasRunDuration.Reset()
registerOnce = sync.Once{}
registerOnce.Do(func() {
prometheus.MustRegister(canvasRunsTotal, canvasRunDuration)
})
}
// ObserveRun emits a counter + histogram observation for one canvas run.
// The runtime label is the string form of RuntimeMode; the outcome label
// must be one of the Outcome* constants. Negative or zero durations are
// dropped from the histogram to avoid skewing percentile math.
//
// An empty runtime defaults to the process-wide Default() so the metric
// tag matches what Selector.Select would have returned for the same
// tenant (review follow-up M4). Callers that need to record the
// Python-routed-fallback case explicitly should pass RuntimePython.
func ObserveRun(runtime RuntimeMode, outcome string, duration time.Duration) {
if runtime == "" {
runtime = Default()
}
if outcome == "" {
outcome = OutcomeError
}
canvasRunsTotal.WithLabelValues(string(runtime), outcome).Inc()
if duration > 0 {
canvasRunDuration.WithLabelValues(string(runtime)).Observe(duration.Seconds())
}
}

View File

@@ -0,0 +1,116 @@
//
// 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 runtime
import (
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
dto "github.com/prometheus/client_model/go"
)
// labelsMatch reports whether the metric carries a label named "runtime"
// with the supplied value.
func labelsMatch(pairs []*dto.LabelPair, wantRuntime string) bool {
for _, p := range pairs {
if p.GetName() == "runtime" && p.GetValue() == wantRuntime {
return true
}
}
return false
}
func TestObserveRun_IncrementsCounter(t *testing.T) {
// ResetMetricsForTesting re-registers the metrics on the default
// registry so testutil can read them.
ResetMetricsForTesting()
ObserveRun(RuntimeGo, OutcomeSuccess, 250*time.Millisecond)
ObserveRun(RuntimeGo, OutcomeSuccess, 500*time.Millisecond)
ObserveRun(RuntimePython, OutcomeError, 750*time.Millisecond)
if got := testutil.ToFloat64(canvasRunsTotal.WithLabelValues("go", "success")); got != 2 {
t.Errorf("go/success counter = %v, want 2", got)
}
if got := testutil.ToFloat64(canvasRunsTotal.WithLabelValues("python", "error")); got != 1 {
t.Errorf("python/error counter = %v, want 1", got)
}
if got := testutil.ToFloat64(canvasRunsTotal.WithLabelValues("python", "success")); got != 0 {
t.Errorf("python/success counter = %v, want 0", got)
}
}
func TestObserveRun_RecordsDuration(t *testing.T) {
ResetMetricsForTesting()
ObserveRun(RuntimeGo, OutcomeSuccess, time.Second)
ObserveRun(RuntimeGo, OutcomeSuccess, 2*time.Second)
gathered, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("Gather: %v", err)
}
var found bool
for _, mf := range gathered {
if !strings.Contains(mf.GetName(), "canvas_run_duration_seconds") {
continue
}
for _, m := range mf.Metric {
if labelsMatch(m.Label, "go") {
found = true
if m.Histogram == nil || m.Histogram.GetSampleCount() != 2 {
t.Errorf("go histogram count = %v, want 2", m.Histogram.GetSampleCount())
}
}
}
}
if !found {
t.Fatal("did not find canvas_run_duration_seconds metric for runtime=go")
}
}
func TestObserveRun_NormalisesEmptyArgs(t *testing.T) {
ResetMetricsForTesting()
// Pin the env-driven default to Python so the test is hermetic
// regardless of the host environment. (Phase 7 default is Go, but
// the assertion is "the empty-runtime label equals Default()", so
// we set the env to make the expected value explicit.)
t.Setenv("RAGFLOW_CANVAS_DEFAULT_RUNTIME", string(RuntimePython))
ResetDefaultCache()
ObserveRun("", "", 0) // all empty — should default to python/error and not observe histogram
if got := testutil.ToFloat64(canvasRunsTotal.WithLabelValues("python", "error")); got != 1 {
t.Errorf("defaulted counter = %v, want 1", got)
}
}
// TestObserveRun_DefaultFallsToGoAfterPhase7 documents the Phase 7
// review fix (M4): the metric's empty-runtime fallback now follows
// selector.Default() (which is RuntimeGo as of Phase 7), so the
// runtime label is consistent with what Selector.Select would have
// returned for the same tenant.
func TestObserveRun_DefaultFallsToGoAfterPhase7(t *testing.T) {
ResetMetricsForTesting()
t.Setenv("RAGFLOW_CANVAS_DEFAULT_RUNTIME", "")
ResetDefaultCache()
ObserveRun("", OutcomeError, 0)
if got := testutil.ToFloat64(canvasRunsTotal.WithLabelValues(string(Default()), "error")); got != 1 {
t.Errorf("defaulted counter = %v, want 1 for runtime=%s outcome=error", got, Default())
}
}

View File

@@ -0,0 +1,167 @@
//
// 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 runtime implements per-tenant runtime selection for the agent
// canvas port. See plan .claude/plans/agent-go-port.md §5 Phase 6/7.
//
// Two pieces live in this package:
//
// - Selector (this file): reads/writes the per-tenant runtime override in
// Redis. The default is RuntimeGo as of Phase 7; per-tenant overrides
// still let operators force a tenant back to Python during the
// agent_api.py deprecation window.
// - Metrics (metrics.go): Prometheus counter + histogram for per-run
// observation, keyed by runtime mode.
package runtime
import (
"context"
"fmt"
"os"
"sync"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// RuntimeMode identifies which agent-canvas runtime implementation serves a
// given tenant. Phase 6 supports "go" and "python"; "auto" is reserved for
// future adaptive policies.
type RuntimeMode string
const (
// RuntimeGo routes the tenant to the Go-side eino implementation.
// This is the process-wide default as of Phase 7.
RuntimeGo RuntimeMode = "go"
// RuntimePython routes the tenant to the legacy Python agent_api.py
// implementation. Retained for the 1-release deprecation window; per-tenant
// overrides via Selector.Set can still force a tenant to this mode.
RuntimePython RuntimeMode = "python"
// RuntimeAuto defers to the per-tenant override, then to the
// process-wide Default(). It exists as a sentinel for clients that
// want explicit "I don't care, pick for me" semantics.
RuntimeAuto RuntimeMode = "auto"
)
// defaultEnvKey is the environment variable consulted by Default() when no
// override is registered for a tenant.
const defaultEnvKey = "RAGFLOW_CANVAS_DEFAULT_RUNTIME"
// overrideKeyPrefix is the Redis key namespace for per-tenant runtime
// overrides. Final keys look like "tenant_canvas_runtime:<tenantID>".
const overrideKeyPrefix = "tenant_canvas_runtime:"
var (
defaultOnce sync.Once
defaultMode RuntimeMode
)
// Default returns the process-wide default runtime mode.
//
// As of Phase 7, the default is Go. The per-tenant override (via
// Selector.Set) can still force a tenant back to Python for the
// 1-release deprecation window of agent_api.py.
//
// The value is read once from the RAGFLOW_CANVAS_DEFAULT_RUNTIME env var;
// subsequent calls return the cached result. Unknown env values fall back
// to RuntimeGo (the new default) so a misconfig still lands on the Go path.
func Default() RuntimeMode {
defaultOnce.Do(func() {
raw := os.Getenv(defaultEnvKey)
switch RuntimeMode(raw) {
case RuntimeGo, RuntimePython, RuntimeAuto:
defaultMode = RuntimeMode(raw)
default:
defaultMode = RuntimeGo
}
})
return defaultMode
}
// ResetDefaultCache clears the cached default-mode value. Test-only helper.
func ResetDefaultCache() {
defaultOnce = sync.Once{}
defaultMode = ""
}
// Selector resolves the runtime mode for a tenant at request time. It is
// safe for concurrent use.
type Selector struct {
redis *redis.Client
logger *zap.Logger
}
// NewSelector constructs a Selector backed by the supplied Redis client. A
// nil logger is replaced with zap.NewNop() so callers in tests can omit it.
func NewSelector(rdb *redis.Client, logger *zap.Logger) *Selector {
if logger == nil {
logger = zap.NewNop()
}
return &Selector{redis: rdb, logger: logger}
}
// overrideKey returns the Redis key for a tenant's runtime override.
func overrideKey(tenantID string) string {
return overrideKeyPrefix + tenantID
}
// Select returns the runtime mode registered for tenantID. The lookup
// order is:
//
// 1. The Redis key "tenant_canvas_runtime:<tenantID>" if present.
// 2. The process-wide Default() (env RAGFLOW_CANVAS_DEFAULT_RUNTIME,
// falling back to RuntimeGo as of Phase 7).
//
// A nil Redis client short-circuits to the default and never errors.
func (s *Selector) Select(ctx context.Context, tenantID string) (RuntimeMode, error) {
if s == nil || s.redis == nil {
return Default(), nil
}
raw, err := s.redis.Get(ctx, overrideKey(tenantID)).Result()
if err == redis.Nil {
return Default(), nil
}
if err != nil {
s.logger.Warn("runtime selector: redis get failed, falling back to default",
zap.String("tenant_id", tenantID), zap.Error(err))
return Default(), err
}
mode := RuntimeMode(raw)
switch mode {
case RuntimeGo, RuntimePython, RuntimeAuto:
return mode, nil
default:
s.logger.Warn("runtime selector: unrecognized value, falling back to default",
zap.String("tenant_id", tenantID), zap.String("value", raw))
return Default(), fmt.Errorf("unrecognized runtime mode %q for tenant %q", raw, tenantID)
}
}
// Set overrides the runtime mode for a tenant. The override has no TTL
// (it is permanent until explicitly changed) so the operator does not have
// to remember to re-set it after a Redis flush of short-lived keys. Used
// by the admin runtime endpoint and tests.
func (s *Selector) Set(ctx context.Context, tenantID string, mode RuntimeMode) error {
if s == nil || s.redis == nil {
return fmt.Errorf("runtime selector: no redis client configured")
}
switch mode {
case RuntimeGo, RuntimePython, RuntimeAuto:
default:
return fmt.Errorf("runtime selector: refusing to set invalid mode %q", mode)
}
return s.redis.Set(ctx, overrideKey(tenantID), string(mode), 0).Err()
}

View File

@@ -0,0 +1,185 @@
//
// 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 runtime
import (
"context"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// newTestRedis spins up an in-process miniredis and returns a connected
// client plus a teardown. The miniredis instance is reachable only from
// the test goroutine that called this helper.
func newTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() {
_ = rdb.Close()
})
return rdb, mr
}
// TestDefault_ReturnsGo is the Phase 7 acceptance assertion: with no env
// var override, Default() must return RuntimeGo. The env var is explicitly
// cleared so the test is hermetic.
func TestDefault_ReturnsGo(t *testing.T) {
t.Setenv(defaultEnvKey, "")
ResetDefaultCache()
if got := Default(); got != RuntimeGo {
t.Fatalf("Default() = %q, want %q (Phase 7 default)", got, RuntimeGo)
}
}
// TestDefault_RespectsEnv exercises the env-var override path through
// Default(). The Go default holds for unset/unknown values; explicit
// "python" / "auto" still win.
func TestDefault_RespectsEnv(t *testing.T) {
t.Setenv(defaultEnvKey, string(RuntimePython))
ResetDefaultCache()
if got := Default(); got != RuntimePython {
t.Fatalf("Default() with python env = %q, want %q", got, RuntimePython)
}
t.Setenv(defaultEnvKey, string(RuntimeAuto))
ResetDefaultCache()
if got := Default(); got != RuntimeAuto {
t.Fatalf("Default() with auto env = %q, want %q", got, RuntimeAuto)
}
t.Setenv(defaultEnvKey, "bogus")
ResetDefaultCache()
if got := Default(); got != RuntimeGo {
t.Fatalf("Default() with invalid env = %q, want %q (Phase 7 fallback)", got, RuntimeGo)
}
}
// TestSelector_PerTenantOverride verifies the per-tenant override
// mechanism still works with the Phase 7 Go default: even when Default()
// would return Go, a tenant explicitly Set to Python must be routed there.
func TestSelector_PerTenantOverride(t *testing.T) {
t.Setenv(defaultEnvKey, "")
ResetDefaultCache()
rdb, _ := newTestRedis(t)
s := NewSelector(rdb, nil)
ctx := context.Background()
// Sanity: the global default is Go now.
if got := Default(); got != RuntimeGo {
t.Fatalf("setup: Default() = %q, want %q", got, RuntimeGo)
}
// Tenant with no override -> Go.
got, err := s.Select(ctx, "tenant_unset")
if err != nil {
t.Fatalf("Select() unset tenant: %v", err)
}
if got != RuntimeGo {
t.Fatalf("Select() unset tenant = %q, want %q", got, RuntimeGo)
}
// Force tenant_force_python to Python even though default is Go.
if err := s.Set(ctx, "tenant_force_python", RuntimePython); err != nil {
t.Fatalf("Set() unexpected error: %v", err)
}
got, err = s.Select(ctx, "tenant_force_python")
if err != nil {
t.Fatalf("Select() force-python tenant: %v", err)
}
if got != RuntimePython {
t.Fatalf("Select() force-python tenant = %q, want %q (per-tenant override)", got, RuntimePython)
}
}
// TestSelector_Select_FallbackToDefault covers the "no Redis key" path:
// Select must return the process-wide default with no error.
func TestSelector_Select_FallbackToDefault(t *testing.T) {
t.Setenv(defaultEnvKey, string(RuntimeGo))
ResetDefaultCache()
rdb, _ := newTestRedis(t)
s := NewSelector(rdb, nil)
got, err := s.Select(context.Background(), "tenant_42")
if err != nil {
t.Fatalf("Select() unexpected error: %v", err)
}
if got != RuntimeGo {
t.Fatalf("Select() = %q, want %q", got, RuntimeGo)
}
}
// TestSelector_SetAndSelectOverride proves that Set makes the override
// visible to subsequent Select calls, even if the env default would have
// been different.
func TestSelector_SetAndSelectOverride(t *testing.T) {
t.Setenv(defaultEnvKey, string(RuntimeGo))
ResetDefaultCache()
rdb, _ := newTestRedis(t)
s := NewSelector(rdb, nil)
ctx := context.Background()
if err := s.Set(ctx, "tenant_a", RuntimePython); err != nil {
t.Fatalf("Set() unexpected error: %v", err)
}
got, err := s.Select(ctx, "tenant_a")
if err != nil {
t.Fatalf("Select() unexpected error: %v", err)
}
if got != RuntimePython {
t.Fatalf("Select() after Set = %q, want %q", got, RuntimePython)
}
// Different tenant still falls back to the Go default.
got, err = s.Select(ctx, "tenant_b")
if err != nil {
t.Fatalf("Select() for other tenant: %v", err)
}
if got != RuntimeGo {
t.Fatalf("Select() for other tenant = %q, want %q", got, RuntimeGo)
}
}
// TestSelector_SetRejectsInvalidMode makes sure a bad mode cannot poison
// the override key.
func TestSelector_SetRejectsInvalidMode(t *testing.T) {
rdb, _ := newTestRedis(t)
s := NewSelector(rdb, nil)
if err := s.Set(context.Background(), "tenant_x", RuntimeMode("rust")); err == nil {
t.Fatal("Set() with invalid mode should error, got nil")
}
}
// TestSelector_NilRedis_NoError covers the "Redis not initialised" path:
// Select returns the default and never errors; Set errors so the caller
// knows the override wasn't applied.
func TestSelector_NilRedis_NoError(t *testing.T) {
t.Setenv(defaultEnvKey, string(RuntimeGo))
ResetDefaultCache()
s := NewSelector(nil, nil)
got, err := s.Select(context.Background(), "tenant_1")
if err != nil {
t.Fatalf("Select() with nil redis errored: %v", err)
}
if got != RuntimeGo {
t.Fatalf("Select() with nil redis = %q, want default %q", got, RuntimeGo)
}
if err := s.Set(context.Background(), "tenant_1", RuntimePython); err == nil {
t.Fatal("Set() with nil redis should error")
}
}

View File

@@ -0,0 +1,277 @@
//
// 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.
//
// runtime — per-run shared state for canvas components.
//
// CanvasState lives here (not in the canvas package) so that the
// builder-side (canvas) and the implementation-side (component) can
// both depend on it without forming an import cycle. The canvas
// package owns DSL types and topology building; the component package
// owns the registered component implementations; both read/write
// CanvasState through this package.
//
// Concurrency: a single sync.RWMutex guards every map in CanvasState
// (plan §2.5 — "start simple"). Helper methods (GetVar / SetVar /
// ReadVars / Snapshot / etc.) lock internally; callers should not
// acquire OutputsLock unless they have a specific reason to extend a
// critical section.
package runtime
import (
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
)
// CanvasState is the per-run shared state bag that all components read/write
// through eino's StatePreHandler / StatePostHandler (compose/state.go).
//
// Fields mirror Python agent/canvas.py:43-95 with these mappings:
// - Outputs : cpn_id -> param_name -> resolved value (variable source)
// - Sys : sys.* namespace (query, user_id, conversation_turns, files)
// - Env : env.* namespace (deployment-time constants)
// - Path : entry-point sequence (Begin nodes)
// - History : conversation history (chat-flow agents)
// - Retrieval : aggregate retrieval result (chunks, doc_aggs)
// - Globals : cross-canvas-instance globals
// - CancelFlag : set when cancel signal received; nodes may poll
// - RunID : unique per-run identifier (used by RunTracker + CheckPointStore)
type CanvasState struct {
mu sync.RWMutex
Outputs map[string]map[string]any
Sys map[string]any
Env map[string]any
Path []string
History []map[string]any
Retrieval map[string]any
Globals map[string]any
CancelFlag *atomic.Bool
RunID string
TaskID string
}
// NewCanvasState returns a zero-valued CanvasState with all maps allocated.
// The atomic CancelFlag is allocated eagerly so nodes can safely poll it
// even before any cancel signal has been wired.
func NewCanvasState(runID, taskID string) *CanvasState {
return &CanvasState{
Outputs: make(map[string]map[string]any),
Sys: make(map[string]any),
Env: make(map[string]any),
Path: []string{},
History: []map[string]any{},
Retrieval: make(map[string]any),
Globals: make(map[string]any),
CancelFlag: &atomic.Bool{},
RunID: runID,
TaskID: taskID,
}
}
// GetVar resolves a variable reference to its current value.
//
// Supported forms (matches plan §2.5 + agent/canvas.py:168-239):
//
// "cpn_id@param" — Outputs[cpn_id][param]
// "cpn_id@param.path" — dot-path traversal on Outputs[cpn_id][param]
// "sys.x" — Sys["x"] (also "sys.x.path")
// "env.x" — Env["x"] (also "env.x.path")
// "item" — iteration alias (Phase 2; nil if unset)
// "index" — iteration alias (Phase 2; nil if unset)
//
// An unknown cpn_id returns (nil, nil) — mirrors Python's "treat as literal"
// fallback (canvas.py:494-495).
func (s *CanvasState) GetVar(ref string) (any, error) {
if ref == "" {
return nil, fmt.Errorf("canvas: empty variable reference")
}
s.mu.RLock()
defer s.mu.RUnlock()
return getVarLocked(s, ref)
}
// SetVar writes Outputs[cpnID][param] = v. Nested keys separated by "." are
// auto-created (mirrors Python's set_variable_param_value at
// canvas.py:261-271). The lock is held for the entire walk to keep
// "walk + assign" atomic under concurrent writers.
func (s *CanvasState) SetVar(cpnID, param string, v any) {
s.mu.Lock()
defer s.mu.Unlock()
setVarLocked(s.Outputs, cpnID, param, v)
}
// ReadVars resolves a list of {{...}} references against the current state
// and returns them keyed by the original ref string. Intended for parameter
// binding: a component declares its input parameter references once, this
// resolves them in one locked pass.
//
// Empty / unresolvable refs map to nil (caller decides on nil-handling).
// The first error is returned and short-circuits the rest, but partial
// results are NOT used by callers — discard on err.
func (s *CanvasState) ReadVars(refs []string) (map[string]any, error) {
out := make(map[string]any, len(refs))
s.mu.RLock()
defer s.mu.RUnlock()
for _, ref := range refs {
v, err := getVarLocked(s, ref)
if err != nil {
return nil, err
}
out[ref] = v
}
return out, nil
}
// Snapshot returns a shallow copy of every cpn's outputs map. It is the
// snapshot that StatePreHandler exposes to component bodies. Shallow is
// fine: components only re-read primitive values from this snapshot
// during one execution; a deeper copy would just cost allocations.
//
// The lock is held only for the duration of the copy; callers may pass
// the returned map around freely.
func (s *CanvasState) Snapshot() map[string]map[string]any {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]map[string]any, len(s.Outputs))
for k, v := range s.Outputs {
cp := make(map[string]any, len(v))
for kk, vv := range v {
cp[kk] = vv
}
out[k] = cp
}
return out
}
// RecordOutput stores payload under Outputs[cpnID][bucket]. Used by the
// StatePostHandler to persist a node's result so downstream nodes can
// resolve {{cpnID@bucket.x}} references against it.
func (s *CanvasState) RecordOutput(cpnID, bucket string, payload any) {
if cpnID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.Outputs[cpnID]
if !ok {
b = make(map[string]any)
s.Outputs[cpnID] = b
}
b[bucket] = payload
}
// getVarLocked is the lock-free inner GetVar. Caller must hold s.mu (read or
// write) for the entire call.
func getVarLocked(s *CanvasState, ref string) (any, error) {
switch {
case ref == "item":
return s.Globals["__item__"], nil
case ref == "index":
return s.Globals["__index__"], nil
case strings.HasPrefix(ref, "sys."):
return dotTraverse(s.Sys, strings.TrimPrefix(ref, "sys.")), nil
case strings.HasPrefix(ref, "env."):
return dotTraverse(s.Env, strings.TrimPrefix(ref, "env.")), nil
case strings.Contains(ref, "@"):
idx := strings.Index(ref, "@")
cpnID, tail := ref[:idx], ref[idx+1:]
outputs, ok := s.Outputs[cpnID]
if !ok {
return nil, nil
}
return dotTraverse(outputs, tail), nil
default:
return nil, fmt.Errorf("canvas: invalid variable reference %q", ref)
}
}
// setVarLocked is the lock-free inner SetVar. Caller must hold s.mu.
func setVarLocked(outputs map[string]map[string]any, cpnID, param string, v any) {
bucket, ok := outputs[cpnID]
if !ok {
bucket = make(map[string]any)
outputs[cpnID] = bucket
}
parts := strings.Split(param, ".")
cur := bucket
for i, p := range parts {
if i == len(parts)-1 {
cur[p] = v
return
}
next, ok := cur[p].(map[string]any)
if !ok {
next = make(map[string]any)
cur[p] = next
}
cur = next
}
}
// dotTraverse walks a dot-path inside a generic Go value. The path is split
// on "." and dispatched by intermediate type, mirroring Python's
// get_variable_param_value precedence (canvas.py:212-239):
//
// 1. nil → return nil
// 2. string → try json.Unmarshal, then continue on the parsed value
// 3. map[string]any → index by key
// 4. []any → index by int (cast failure → nil)
// 5. else → return nil
//
// The empty path returns the root value as-is.
func dotTraverse(root any, path string) any {
if path == "" {
return root
}
parts := strings.Split(path, ".")
cur := root
for _, p := range parts {
cur = step(cur, p)
if cur == nil {
return nil
}
}
return cur
}
func step(cur any, key string) any {
switch v := cur.(type) {
case nil:
return nil
case map[string]any:
return v[key]
case string:
// Strings can be JSON-encoded dicts/lists; try once.
var parsed any
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
return step(parsed, key)
}
return nil
case []any:
var idx int
if _, err := fmt.Sscanf(key, "%d", &idx); err != nil {
return nil
}
if idx < 0 || idx >= len(v) {
return nil
}
return v[idx]
default:
return nil
}
}

View File

@@ -0,0 +1,110 @@
//
// 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.
//
// runtime — {{...}} variable reference parser shared by canvas and
// component packages.
//
// The regex is byte-for-byte identical to agent/component/base.py:368
// — any drift must be coordinated with the Python regex in the same
// line.
package runtime
import (
"fmt"
"regexp"
)
// VarRefPattern matches the RAGFlow v1 variable reference syntax.
// Mirrors agent/component/base.py:368 in spirit with one deviation: the
// cpn_id part includes '_' (real RAGFlow cpn_ids are like "begin_0",
// "llm_0", "cpn_0"). The Python regex as documented in the plan
// (`[a-zA-Z:0-9]+`) would not match those — this looks like a documentation
// bug in the plan; the Python source likely has the underscore too. This
// deviation is recorded in plan §1.1 and §2.11 with a TODO to confirm
// against the live Python source during Phase 2 cross-validation.
//
// Pattern:
//
// \{* *\{(<ref>)\} *\}*
// where <ref> = cpn_id@param | sys.x | env.x
// cpn_id = [a-zA-Z:0-9_]+ (note: underscore added; see deviation note)
// param = [A-Za-z0-9_.-]+
//
// Capture group 1 holds the bare ref without braces (e.g. "cpn_0@content",
// "sys.query", "env.max_tokens").
var VarRefPattern = regexp.MustCompile(`\{* *\{([a-zA-Z:0-9_]+@[A-Za-z0-9_.-]+|sys\.[A-Za-z0-9_.]+|env\.[A-Za-z0-9_.]+)\} *\}*`)
// ExtractRefs returns the unique ref strings (without the surrounding
// braces) appearing in s, in first-occurrence order. Pure regex — does not
// touch state. Use this when you need to know "which references does this
// template contain?" without resolving.
func ExtractRefs(s string) []string {
matches := VarRefPattern.FindAllStringSubmatch(s, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, m := range matches {
ref := m[1]
if _, dup := seen[ref]; dup {
continue
}
seen[ref] = struct{}{}
out = append(out, ref)
}
return out
}
// ResolveTemplate substitutes every {{...}} in s with the current state's
// value for that ref. Unresolvable refs (GetVar returns nil) become errors
// — the Go port trades Python's silent soft-fail (canvas.py:177-178 returns
// "" for None) for a Go-idiomatic loud-fail so Phase 2 parameter binding can
// surface misconfigured canvases early. The partial output (with "" in place
// of the unresolved ref) is still returned so callers can choose to log it.
//
// Supported forms match GetVar (cpn_id@param[.path], sys.x[.path], env.x[.path],
// item, index).
func ResolveTemplate(s string, state *CanvasState) (string, error) {
if !VarRefPattern.MatchString(s) {
return s, nil
}
var firstErr error
out := VarRefPattern.ReplaceAllStringFunc(s, func(match string) string {
// Re-extract the bare ref from the match (ReplaceAllStringFunc gives
// the whole match, not the subgroup).
sub := VarRefPattern.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
ref := sub[1]
v, err := state.GetVar(ref)
if err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("canvas: resolve %q: %w", ref, err)
}
return ""
}
if v == nil {
if firstErr == nil {
firstErr = fmt.Errorf("canvas: unresolved reference %q", ref)
}
return ""
}
return fmt.Sprintf("%v", v)
})
return out, firstErr
}