Files
ragflow/internal/agent/canvas/node_body.go
Zhichang Yu 3fa15c0e2f 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
2026-06-12 22:58:28 +08:00

191 lines
6.9 KiB
Go

//
// 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.
//
// node_body.go — per-node lambda body construction.
//
// Both the outer graph (scheduler.go) and the Loop sub-graph
// (loop_subgraph.go) install lambda nodes that:
//
// 1. tag their output with __cpn_id__ so statePost can persist the
// result into Outputs[cpnID]["result"];
// 2. either invoke a real factory-built component or fall back to a
// no-op echo body.
//
// Centralising the construction here keeps both call sites consistent
// and makes the legacy-no-op / factory / placeholder routing logic the
// single source of truth.
package canvas
import (
"context"
"fmt"
"ragflow/internal/agent/runtime"
)
// nodeBodyFn is the plain function shape compose.InvokableLambda accepts.
// We avoid a named type alias because compose.InvokableLambda's generic
// inference only accepts the underlying func literal type, not a named
// alias on top of it.
type nodeBodyFn = func(ctx context.Context, in map[string]any) (map[string]any, error)
// buildNodeBody returns the lambda body for a single canvas node.
//
// Routing rules:
//
// 1. isLegacyNoOp(name) → legacyNoOpBody (echo + __legacy_noop__ tag).
// DSL v1 sentinels like "ExitLoop" land here.
// 2. runtime.DefaultFactory() is non-nil → call the factory once to
// construct a runtime.Component, then return a body that delegates
// to that component's Invoke. A factory error surfaces here with
// the cpn_id wrapped for diagnostics.
// 3. otherwise → placeholderBody. This is the canvas-package-only
// fallback used when no factory has been registered (most commonly
// in canvas-only unit tests that do not import the component
// package). Production runs always have a factory installed via
// component.init() → runtime.SetDefaultFactory(component.New).
//
// The returned body always tags the output map with __cpn_id__ so the
// shared statePost handler can persist the result into the per-cpn
// Outputs bucket.
func buildNodeBody(cpnID, name string, params map[string]any) (nodeBodyFn, error) {
if isLegacyNoOp(name) {
return legacyNoOpBody(cpnID), nil
}
if factory := runtime.DefaultFactory(); factory != nil {
comp, err := factory(name, params)
if err != nil {
return nil, fmt.Errorf("canvas: component %q (%s): factory: %w", cpnID, name, err)
}
if comp == nil {
return nil, fmt.Errorf("canvas: component %q (%s): factory returned nil component", cpnID, name)
}
return realComponentBody(cpnID, comp), nil
}
// Fallback: no factory registered. This path is only exercised by
// canvas-only unit tests; production wiring always installs a
// factory via component.init().
if !isKnownPrimitive(name) {
return nil, fmt.Errorf("canvas: component %q has unknown component_name %q (typo? not in the Phase 1 primitive allowlist, not in legacyNoOpNames)", cpnID, name)
}
return placeholderBody(cpnID), nil
}
// legacyNoOpBody returns the body installed for DSL v1 sentinel
// components (legacyNoOpNames). It echoes the input and tags
// __legacy_noop__ so downstream debuggers can tell the node fired but
// did nothing.
func legacyNoOpBody(cpnID string) nodeBodyFn {
return func(_ context.Context, in map[string]any) (map[string]any, error) {
out := make(map[string]any, len(in)+2)
for k, v := range in {
out[k] = v
}
out["__cpn_id__"] = cpnID
out["__legacy_noop__"] = true
return out, nil
}
}
// realComponentBody returns a body that delegates to the supplied
// runtime.Component. The component is constructed once at build time
// (in buildNodeBody) and re-invoked per iteration.
//
// The output map is tagged with __cpn_id__ before return so statePost
// can attribute the result; if the component already populated that
// key it is overwritten with the canvas-controlled value to keep
// attribution authoritative.
func realComponentBody(cpnID string, comp runtime.Component) nodeBodyFn {
return func(ctx context.Context, in map[string]any) (map[string]any, error) {
out, err := comp.Invoke(ctx, in)
if err != nil {
return nil, fmt.Errorf("canvas: component %q invoke: %w", cpnID, err)
}
if out == nil {
out = make(map[string]any, 1)
}
out["__cpn_id__"] = cpnID
return out, nil
}
}
// placeholderBody is the canvas-only fallback used when no factory
// has been registered. It echoes the input map untouched (except for
// the __cpn_id__ tag) so canvas unit tests can exercise topology
// wiring without depending on any real component implementation.
func placeholderBody(cpnID string) nodeBodyFn {
return func(ctx context.Context, in map[string]any) (map[string]any, error) {
out, err := placeholderLambda(ctx, in)
if err != nil {
return nil, err
}
out["__cpn_id__"] = cpnID
return out, nil
}
}
// withStateBracket wraps body so that it performs the same pre/post
// state work as the outer-graph's eino StatePreHandler / StatePostHandler
// pair, but reads the state from the request context (attached via
// runtime.WithState) instead of an eino-managed graph-local state.
//
// This is the path used by the Loop sub-graph: its nodes do not have
// access to the outer graph's WithGenLocalState, but they do inherit
// the context-attached *CanvasState that the outer graph (or the
// invoking caller) installed. Wrapping the body lets sub-graph nodes
// participate in the same state snapshot / result-persistence
// contract as outer nodes.
//
// If no state is attached to ctx (e.g. a sub-graph test that runs
// the body directly), the wrapper degrades to a plain invocation:
// the body still runs, its output is still tagged with __cpn_id__,
// but no state snapshot is injected and no result is persisted.
func withStateBracket(body nodeBodyFn) nodeBodyFn {
return func(ctx context.Context, in map[string]any) (map[string]any, error) {
state, _, _ := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
if state != nil {
if in == nil {
in = map[string]any{}
}
snapshot := state.Snapshot()
wrapped := make(map[string]any, len(in)+1)
for k, v := range in {
wrapped[k] = v
}
wrapped["state"] = snapshot
in = wrapped
}
out, err := body(ctx, in)
if err != nil {
return nil, err
}
if state == nil || out == nil {
return out, nil
}
cpnID, _ := out["__cpn_id__"].(string)
if cpnID == "" {
return out, nil
}
for k, v := range out {
if k == "__cpn_id__" || k == "state" || k == "__legacy_noop__" {
continue
}
state.SetVar(cpnID, k, v)
}
return out, nil
}
}