mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-16 12:47:19 +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:
117
internal/agent/runtime/component.go
Normal file
117
internal/agent/runtime/component.go
Normal 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
|
||||
}
|
||||
73
internal/agent/runtime/context.go
Normal file
73
internal/agent/runtime/context.go
Normal 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
|
||||
}
|
||||
102
internal/agent/runtime/metrics.go
Normal file
102
internal/agent/runtime/metrics.go
Normal 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())
|
||||
}
|
||||
}
|
||||
116
internal/agent/runtime/metrics_test.go
Normal file
116
internal/agent/runtime/metrics_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
167
internal/agent/runtime/selector.go
Normal file
167
internal/agent/runtime/selector.go
Normal 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()
|
||||
}
|
||||
185
internal/agent/runtime/selector_test.go
Normal file
185
internal/agent/runtime/selector_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
277
internal/agent/runtime/state.go
Normal file
277
internal/agent/runtime/state.go
Normal 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
|
||||
}
|
||||
}
|
||||
110
internal/agent/runtime/template.go
Normal file
110
internal/agent/runtime/template.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user