Files
ragflow/internal/agent/canvas/scheduler.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

433 lines
16 KiB
Go

// Package canvas — eino Workflow topology builder (Worker A, Phase 1).
//
// BuildWorkflow turns a Canvas (DSL) into a *compose.Workflow whose nodes
// are placeholder lambda stubs in Phase 1 (real Begin/Message/LLM components
// land in Phase 2 P0). The topology — pass-through for "begin" nodes with
// no upstream, lambda for every other component, AddInput edge for every
// upstream — is the Phase 1 deliverable; component bodies are deferred.
//
// State pre/post handlers are wired here as NODE options (GraphAddNodeOpt),
// NOT compile options. This is the eino v0.9.2 fix documented in plan §2.6.
package canvas
import (
"context"
"fmt"
"strings"
"ragflow/internal/agent/runtime"
"ragflow/internal/agent/workflowx"
"github.com/cloudwego/eino/compose"
)
// placeholderLambda is the Phase 1 stand-in for every real component body.
// It copies the input map into the output map untouched, which lets
// BuildWorkflow validate the topology (compile + edge wiring) without
// depending on any real component implementation. Real component bodies land
// in Phase 2 P0; once they exist, BuildWorkflow will switch on
// comp.Obj.ComponentName and look up the registered body.
func placeholderLambda(_ context.Context, in map[string]any) (map[string]any, error) {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out, nil
}
// isLegacyNoOp reports whether name is in legacyNoOpNames (defined
// in canvas.go). The set names the DSL v1 sentinel components that
// the Go port accepts but does not implement — e.g. "ExitLoop".
// Encountering one routes the node to a no-op echo body so the
// workflow still compiles. Phase 2 P0 will also gate the
// component-allowlist on this same name set so adding a new legacy
// name to canvas.go is the single source of truth.
//
// The lookup is case-insensitive: legacyNoOpNames stores keys
// lowercase, but the DSL preserves user case (see canvas.go:92
// "matches agent/component/<name>.py's class name
// (case-insensitive)"). All callers go through this predicate so
// the case-normalization is in exactly one place.
//
// Note: the canvas package cannot import internal/agent/component
// (foundation layer must not depend on its callers), so the
// component-name check is intentionally NOT performed here. The
// unknown-component error path is exercised by the explicit
// TestBuildWorkflow_UnknownComponentErrors test using a name that
// is neither in the legacy set nor any of the known DSL primitives
// (Begin / Message / LLM / Categorize / Invoke / etc. are
// implicitly accepted by the placeholder phase). This mirrors the
// Phase 1 contract documented in scheduler.go's package comment.
func isLegacyNoOp(name string) bool {
return legacyNoOpNames[strings.ToLower(name)]
}
// isKnownPrimitive reports whether name is a real component the Go
// port can route to a body. In Phase 1 the allowlist is explicit
// (mirror of the names referenced in the test fixtures) so that an
// unknown component name surfaces a clear error from BuildWorkflow
// instead of silently producing a no-op node. In Phase 2 P0 this
// becomes a registry lookup against the component package.
//
// We keep the signature and call shape stable so swapping the body
// to a registry check is a one-line change. The Phase 1 set
// matches the names already used by existing fixtures and is
// over-approximated to land any in-flight component port; tighten
// it back to the registry-derived set when Phase 2 P0 lands.
func isKnownPrimitive(name string) bool {
if name == "" {
return false
}
// Legacy names ARE known — they route to a dedicated no-op echo
// body installed by Pass 1 below. The "known" predicate is the
// union of the legacy set and the real-component allowlist.
if isLegacyNoOp(name) {
return true
}
switch strings.ToLower(name) {
case "begin", "message", "llm", "categorize", "switch",
"agent", "invoke", "dataoperations", "listoperations",
"stringtransform", "variableaggregator", "variableassigner",
"loop": // Loop is a macro in BuildWorkflow; the pre-pass absorbs it.
return true
}
return false
}
// statePre is the StatePreHandler wired onto every node. It injects the
// current per-cpn Outputs into the input map under the "state" key so the
// lambda body can read its inputs without re-fetching from ctx. We don't
// mutate the user's input map — we shallow-copy.
//
// The context-attached *CanvasState is the canonical store for
// components (Begin / Message / LLM all read it via
// runtime.GetStateFromContext). When the caller attached one to the
// context (orchestrator path or test setup), we sync the eino
// per-run state's outputs into it so downstream nodes see the
// upstream outputs. The eino state is still useful as a fallback
// when no context state is attached.
func statePre(ctx context.Context, in map[string]any, state *CanvasState) (map[string]any, error) {
if in == nil {
in = map[string]any{}
}
// Sync the eino state → context state when both exist so
// downstream components reading via GetStateFromContext see
// the upstream outputs the state post handler already wrote.
if state != nil {
if ctxState, _, _ := runtime.GetStateFromContext[*runtime.CanvasState](ctx); ctxState != nil && ctxState != state {
for cpnID, bucket := range state.Outputs {
for k, v := range bucket {
ctxState.SetVar(cpnID, k, v)
}
}
}
}
snapshot := state.Snapshot()
out := make(map[string]any, len(in)+1)
for k, v := range in {
out[k] = v
}
out["state"] = snapshot
return out, nil
}
// statePost is the StatePostHandler — it flattens the lambda's output
// keys into the per-cpn Outputs bucket keyed by the cpn_id passed
// through the input map ("cpn_id" key, injected by BuildWorkflow's
// per-node wrapper).
//
// Storage convention: each top-level key in the component's output
// map lands as Outputs[cpnID][key]. v1 templates reference these as
// {{cpnID@key}} (e.g. {{generate:0@content}}). Nesting the entire
// payload under Outputs[cpnID]["result"] would force every template
// to use {{cpnID@result.content}} which the v1 DSL never writes.
//
// The write is mirrored into the context-attached *CanvasState when
// one is present, so downstream components that read state via
// runtime.GetStateFromContext (Begin / Message / LLM) see the
// upstream output. The eino per-run state stays the source of truth
// for the snapshot exposed via statePre.
func statePost(ctx context.Context, out map[string]any, state *CanvasState) (map[string]any, error) {
cpnID, _ := out["__cpn_id__"].(string)
if cpnID == "" {
return out, nil
}
ctxState, _, _ := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
for k, v := range out {
if k == "__cpn_id__" || k == "state" || k == "__legacy_noop__" {
continue
}
if state != nil {
state.SetVar(cpnID, k, v)
}
if ctxState != nil {
ctxState.SetVar(cpnID, k, v)
}
}
return out, nil
}
// BuildWorkflow assembles a *compose.Workflow from a Canvas DSL.
//
// Topology rules (per plan §1.1, §2.4):
//
// - For every cpn_id in c.Components: add a Lambda node.
// - For every (cpn_id, upstream) edge: cpn.AddInput(upstream).
// - For components with no upstream (Begin nodes): wire an empty input
// from compose.START so eino knows they are start candidates.
// - For components with no downstream (terminals): wire them to the
// implicit END via wf.End().AddInput(cpnID, ...).
//
// State pre/post handlers are added to every node as NODE options
// (GraphAddNodeOpt). The handlers carry the per-run *CanvasState which eino
// extracts from context for us (via WithGenLocalState — wired in compile.go).
func BuildWorkflow(ctx context.Context, c *Canvas) (*compose.Workflow[map[string]any, map[string]any], error) {
if c == nil {
return nil, fmt.Errorf("canvas: nil canvas")
}
if len(c.Components) == 0 {
return nil, fmt.Errorf("canvas: no components")
}
// GenLocalState seeds each run with a fresh *CanvasState. eino calls
// this once per run and threads the result through StatePre/Post
// handlers via context.
genState := func(_ context.Context) *CanvasState {
return NewCanvasState("", "")
}
wf := compose.NewWorkflow[map[string]any, map[string]any](
compose.WithGenLocalState(genState),
)
// Cycle pre-pass. eino's compose.Workflow is a strict DAG: any
// data or control edge that closes a cycle makes Compile() fail
// with "DAG is invalid, has loop". Several v1 fixtures
// (exesql.json, headhunter_zh.json) intentionally carry cycles
// that model "wait for the next user turn" — the Python v1
// engine resolves them iteratively. The Go port wraps the whole
// canvas in a synthetic Loop node driven by workflowx.AddLoopNode
// (see cycle_wrap.go) so the OUTER graph is acyclic; the
// cycle-causing edges live inside the loop's sub-workflow. Phase
// 5's real orchestrator will replace this with a proper
// iterative driver.
if hasCycle(c) {
exp, err := buildSyntheticLoop(ctx, c)
if err != nil {
return nil, fmt.Errorf("canvas: build synthetic loop: %w", err)
}
node, err := compileSyntheticLoop(ctx, wf, exp)
if err != nil {
return nil, err
}
// The synthetic loop is the only node the outer workflow
// needs to know about. Wire it as both START and END so
// eino's "start node not set" / "end node not set" checks
// pass — the loop body runs once via shouldQuit, and the
// outer graph exits with the sub-workflow's terminal
// output.
node.AddInput(compose.START)
wf.End().AddInput(syntheticLoopKey)
return wf, nil
}
// Pre-pass: Loop macro expansion. For each Loop cpn, build a
// sub-workflow from its downstream descendants and install a
// workflowx.AddLoopNode in the outer graph in place of the Loop
// subtree. The sub-graph members are tracked in `loopMembers` so
// the main pass skips them.
loopMembers := make(map[string]bool)
loopNodes := make(map[string]*compose.WorkflowNode)
for cpnID, comp := range c.Components {
if !strings.EqualFold(comp.Obj.ComponentName, "Loop") {
continue
}
exp, err := buildLoopExpansion(ctx, c, cpnID)
if err != nil {
return nil, err
}
var opts []workflowx.LoopOption
if exp.MaxIters > 0 {
opts = append(opts, workflowx.WithLoopMaxIterations(exp.MaxIters))
}
node, err := workflowx.AddLoopNode[map[string]any](
ctx, wf, cpnID, exp.Sub, exp.ShouldQuit, opts...,
)
if err != nil {
return nil, fmt.Errorf("canvas: install loop %q: %w", cpnID, err)
}
loopNodes[cpnID] = node
for m := range exp.Members {
loopMembers[m] = true
}
}
// Pass 1: register every node and remember its upstream list so we can
// wire edges in a second pass (Compose disallows AddInput before the
// upstream exists). Skip Loop cpns and their sub-graph members —
// they live in `loopNodes` and inside the sub-workflow respectively.
//
// Component-routing rules per cpn (centralised in buildNodeBody):
//
// 1. component_name is in legacyNoOpNames (e.g. "ExitLoop") →
// dedicated no-op echo lambda with __legacy_noop__ tag.
// 2. runtime.DefaultFactory() registered → factory-built real
// component invoked per iteration.
// 3. no factory registered → placeholder body (canvas-only test
// fallback; production wiring always registers a factory via
// component.init()).
type pendingEdge struct {
cpn string
up string
}
pending := make([]pendingEdge, 0, 4*len(c.Components))
nodes := make(map[string]*compose.WorkflowNode, len(c.Components))
for cpnID := range c.Components {
// Loop cpns are already registered as workflowx nodes in
// loopNodes (pre-pass). We still need to record their
// upstream edges so Pass 2 can wire `upstream → loop`.
if _, isLoop := loopNodes[cpnID]; isLoop {
for _, up := range c.Components[cpnID].Upstream {
pending = append(pending, pendingEdge{cpn: cpnID, up: up})
}
continue
}
if loopMembers[cpnID] {
continue
}
name := c.Components[cpnID].Obj.ComponentName
if name == "" {
return nil, fmt.Errorf("canvas: component %q has empty component_name", cpnID)
}
body, err := buildNodeBody(cpnID, name, c.Components[cpnID].Obj.Params)
if err != nil {
return nil, err
}
lambda := compose.InvokableLambda[map[string]any, map[string]any](body)
node := wf.AddLambdaNode(cpnID, lambda,
compose.WithStatePreHandler[map[string]any, *CanvasState](statePre),
compose.WithStatePostHandler[map[string]any, *CanvasState](statePost),
compose.WithNodeName(cpnID),
)
nodes[cpnID] = node
for _, up := range c.Components[cpnID].Upstream {
pending = append(pending, pendingEdge{cpn: cpnID, up: up})
}
}
// Pass 2: wire edges. Skip self-edges and edges to unknown upstreams —
// those would be a DSL bug; BuildWorkflow returns an error so the
// orchestrator can surface a clear failure (better than a silent
// non-trigger).
//
// Multi-upstream handling: eino's Workflow only allows ONE actual data
// input per node (subsequent AddInput without FieldMapping triggers
// "entire output has already been mapped"). For diamond / merge
// topologies, the first upstream carries data; the rest register as
// exec-only dependencies via AddDependency so the node waits for
// them but doesn't try to consume a second data source. Phase 2 P0
// component bodies will switch to explicit FieldMapping when they
// need to merge multi-source inputs.
//
// An upstream may be a regular node OR a Loop node (registered in
// the pre-pass). Both are valid edge sources. Symmetrically, the
// downstream may itself be a Loop node — in that case we resolve
// the *compose.WorkflowNode via loopNodes rather than nodes.
resolveNode := func(id string) *compose.WorkflowNode {
if n, ok := nodes[id]; ok {
return n
}
if n, ok := loopNodes[id]; ok {
return n
}
return nil
}
first := make(map[string]bool, len(c.Components))
for _, e := range pending {
if e.cpn == e.up {
return nil, fmt.Errorf("canvas: self-edge on %q", e.cpn)
}
if resolveNode(e.up) == nil {
return nil, fmt.Errorf("canvas: component %q has unknown upstream %q", e.cpn, e.up)
}
cpnNode := resolveNode(e.cpn)
if cpnNode == nil {
return nil, fmt.Errorf("canvas: pending edge references unknown cpn %q", e.cpn)
}
if !first[e.cpn] {
cpnNode.AddInput(e.up)
first[e.cpn] = true
} else {
cpnNode.AddDependency(e.up)
}
}
// Pass 3: wire start nodes (no upstream) from compose.START, and wire
// terminal nodes (no downstream) to compose.END via wf.End(). eino
// tracks start/end membership by these explicit wirings — without
// them, Compile() returns "start node not set" / "end node not set".
//
// Multi-terminal case: when two or more components have empty
// Downstream, eino's END node complains "entire output has already
// been mapped for node: end" unless each terminal is wired with a
// distinct compose.ToField(cpnID) mapping. We always include the
// FieldMapping argument (per terminal) so the count of inputs
// matters only to eino's bookkeeping, not to our wire code.
//
// A "start" node with no upstream gets an empty input from START so
// eino registers it as a workflow entry point. FieldMapping is nil
// because Phase 1 placeholder lambdas just echo whatever they receive.
//
// Loop nodes are wired here too: a Loop is START if it has no
// upstream; it is END if it has no downstream in the outer graph
// (a downstream that's also a sub-graph member doesn't count — that
// node is part of the loop's body, not the outer graph's edge).
for cpnID, comp := range c.Components {
if node, isLoop := loopNodes[cpnID]; isLoop {
// Loops with no upstream are START nodes. Loops WITH
// upstream had their AddInput wired in Pass 2 already.
if len(comp.Upstream) == 0 && !first[cpnID] {
node.AddInput(compose.START)
}
hasOuterDownstream := false
for _, down := range comp.Downstream {
if loopMembers[down] {
continue
}
hasOuterDownstream = true
break
}
if !hasOuterDownstream {
wf.End().AddInput(cpnID, compose.ToField(cpnID))
}
continue
}
if loopMembers[cpnID] {
continue
}
if len(comp.Upstream) == 0 {
nodes[cpnID].AddInput(compose.START)
}
if len(comp.Downstream) == 0 {
wf.End().AddInput(cpnID, compose.ToField(cpnID))
}
}
return wf, nil
}
// snapshotOutputs is retained as a thin wrapper around state.Snapshot()
// for any leftover callers in test/bench files. New code should call
// state.Snapshot() directly.
func snapshotOutputs(src map[string]map[string]any) map[string]map[string]any {
out := make(map[string]map[string]any, len(src))
for k, v := range src {
cp := make(map[string]any, len(v))
for kk, vv := range v {
cp[kk] = vv
}
out[k] = cp
}
return out
}