mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-19 14:11:04 +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:
121
internal/agent/canvas/cancel.go
Normal file
121
internal/agent/canvas/cancel.go
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// cancel.go implements the cross-process cancel signal. See plan §4.9 —
|
||||
// a Go canvas run goroutine polls Redis for "{taskID}-cancel"; when the
|
||||
// HTTP handler sets the key, the watcher fires onCancel. The Redis key
|
||||
// naming is deliberately identical to the Python task_service.py
|
||||
// protocol (line 521-523) so Go and Python canvas runs in the same
|
||||
// tenant can signal each other.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"ragflow/internal/cache"
|
||||
)
|
||||
|
||||
// cancelKeySuffix is appended to the task id to form the Redis key.
|
||||
const cancelKeySuffix = "-cancel"
|
||||
|
||||
// cancelPollInterval is the gap between Redis Get polls. 500ms keeps
|
||||
// cancel latency p99 ≤ 500ms while staying cheap (one GET every half-
|
||||
// second per active run). Tunable later if a tenant needs lower latency.
|
||||
const cancelPollInterval = 500 * time.Millisecond
|
||||
|
||||
// RequestCancelTTL is the lifetime of the cancel flag in Redis. Long
|
||||
// enough to outlast any legitimate canvas run; short enough that stale
|
||||
// flags from a previous run do not poison a later run.
|
||||
const RequestCancelTTL = 24 * time.Hour
|
||||
|
||||
// cancelClientFn resolves the Redis client for cancel operations. It is
|
||||
// a package-level variable so tests can override it with a miniredis
|
||||
// client (the production path goes through cache.Get()).
|
||||
var cancelClientFn = func() (*redis.Client, error) {
|
||||
rc := cache.Get()
|
||||
if rc == nil {
|
||||
return nil, errors.New("cancel: redis cache not initialized")
|
||||
}
|
||||
c := rc.GetClient()
|
||||
if c == nil {
|
||||
return nil, errors.New("cancel: redis client not initialized")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// WatchCancel blocks until either ctx is cancelled or the Redis
|
||||
// "{taskID}-cancel" key is set to a non-empty value. When fired, it
|
||||
// calls onCancel exactly once and returns. Polling interval is fixed
|
||||
// at 500ms (see plan §4.9 — revised 2026-06-03 from 1s to 500ms).
|
||||
//
|
||||
// WatchCancel is intended to run as a side goroutine; the run-loop
|
||||
// goroutine calls it with onCancel wired to the eino graph interrupt
|
||||
// callback:
|
||||
//
|
||||
// go func() {
|
||||
// canvas.WatchCancel(ctx, taskID, func() {
|
||||
// interrupt(compose.WithGraphInterruptTimeout(30*time.Second))
|
||||
// })
|
||||
// }()
|
||||
func WatchCancel(ctx context.Context, taskID string, onCancel func()) {
|
||||
c, err := cancelClientFn()
|
||||
if err != nil {
|
||||
// Without Redis the watcher can do nothing. Returning silently
|
||||
// matches the rest of the canvas layer: a missing cache is a
|
||||
// deployment error surfaced at startup, not at every call.
|
||||
return
|
||||
}
|
||||
key := taskID + cancelKeySuffix
|
||||
ticker := time.NewTicker(cancelPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
v, err := c.Get(ctx, key).Result()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
// Transient Redis error — log by skipping this tick; the
|
||||
// next tick will retry. Avoid spinning on persistent
|
||||
// failure.
|
||||
continue
|
||||
}
|
||||
if v != "" {
|
||||
if onCancel != nil {
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequestCancel publishes a cancel signal for the given task. The
|
||||
// 24h TTL matches the Python task_service.py protocol so a flag set
|
||||
// during one run is still observable by a resume that arrives hours
|
||||
// later (e.g. after a long client-side wait).
|
||||
func RequestCancel(ctx context.Context, taskID string) error {
|
||||
c, err := cancelClientFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Set(ctx, taskID+cancelKeySuffix, "x", RequestCancelTTL).Err()
|
||||
}
|
||||
149
internal/agent/canvas/cancel_test.go
Normal file
149
internal/agent/canvas/cancel_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// 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 canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// withCancelClient swaps the package-level Redis getter for a miniredis-
|
||||
// backed one and returns a cleanup func that restores production state.
|
||||
func withCancelClient(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run: %v", err)
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
orig := cancelClientFn
|
||||
cancelClientFn = func() (*redis.Client, error) { return client, nil }
|
||||
t.Cleanup(func() { cancelClientFn = orig })
|
||||
return mr
|
||||
}
|
||||
|
||||
func TestWatchCancel_FiresAfterRequest(t *testing.T) {
|
||||
withCancelClient(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
taskID := "task_test_1"
|
||||
fired := atomic.Bool{}
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
WatchCancel(ctx, taskID, func() { fired.Store(true) })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Give the watcher time to start its first tick.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if err := RequestCancel(ctx, taskID); err != nil {
|
||||
t.Fatalf("RequestCancel: %v", err)
|
||||
}
|
||||
|
||||
// onCancel must fire within 1s — poll interval is 500ms so two
|
||||
// ticks cover worst case plus slack.
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("WatchCancel did not return within 1s after RequestCancel")
|
||||
}
|
||||
if !fired.Load() {
|
||||
t.Fatal("onCancel was not invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchCancel_StopsOnContextCancel(t *testing.T) {
|
||||
withCancelClient(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
taskID := "task_test_ctx"
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
WatchCancel(ctx, taskID, func() {
|
||||
t.Error("onCancel should not fire without a Redis signal")
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Cancel the context — watcher should return promptly even though
|
||||
// no Redis flag is set.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("WatchCancel did not return within 1s after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchCancel_OnCancelNotInvokedForEmptyKey(t *testing.T) {
|
||||
withCancelClient(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
invoked := atomic.Int32{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
WatchCancel(ctx, "task_never_cancelled", func() {
|
||||
invoked.Add(1)
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Wait for two full poll intervals and ensure onCancel never fires.
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
if invoked.Load() != 0 {
|
||||
t.Fatalf("onCancel fired %d times for an unsignaled task; want 0",
|
||||
invoked.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestCancel_EmptyValueStillFires(t *testing.T) {
|
||||
// Python's task_service.py writes "x" as the value, but a buggy
|
||||
// caller that wrote "" should not silently keep the watcher
|
||||
// waiting. WatchCancel's contract is "non-empty triggers onCancel";
|
||||
// we rely on RequestCancel to always set "x" so this test is just
|
||||
// a sanity check that the value round-trips.
|
||||
mr := withCancelClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := RequestCancel(ctx, "task_value"); err != nil {
|
||||
t.Fatalf("RequestCancel: %v", err)
|
||||
}
|
||||
got, err := mr.Get("task_value-cancel")
|
||||
if err != nil {
|
||||
t.Fatalf("mr.Get: %v", err)
|
||||
}
|
||||
if got != "x" {
|
||||
t.Fatalf("cancel key value = %q, want %q", got, "x")
|
||||
}
|
||||
}
|
||||
85
internal/agent/canvas/canvas.go
Normal file
85
internal/agent/canvas/canvas.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Package canvas implements the RAGFlow agent canvas Go port.
|
||||
// See plan: .claude/plans/agent-go-port.md §2.5 (State + Workflow hybrid),
|
||||
// §2.6 (Redis-backed CheckPointStore + RunTracker), §4.2 (CanvasState shape).
|
||||
//
|
||||
// Shared runtime contracts (CanvasState, Component, ComponentFactory,
|
||||
// state context plumbing, template helpers) live in
|
||||
// internal/agent/runtime. Canvas re-exports them through thin aliases
|
||||
// so existing call sites keep working while breaking the historic
|
||||
// canvas <-> component import cycle.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// legacyNoOpNames is the set of component names that the Go port
|
||||
// recognises for DSL v1 compatibility but does not ship a real
|
||||
// implementation for. Encountering one of these in a DSL is mapped to
|
||||
// the same no-op echo lambda used for placeholder bodies by the
|
||||
// BuildWorkflow in scheduler.go. New DSLs should not use these names —
|
||||
// they exist only so v1 DSLs that reference Python-era sentinel
|
||||
// components ("ExitLoop") still compile and run in the Go port.
|
||||
//
|
||||
// Membership semantics inside a Loop's sub-graph: legacy names that
|
||||
// appear as descendants of a Loop are absorbed as no-op members of the
|
||||
// sub-graph; they do not contribute to loop control. Termination is
|
||||
// driven by the Loop's loop_termination_condition predicate, not by
|
||||
// reaching an ExitLoop node.
|
||||
var legacyNoOpNames = map[string]bool{
|
||||
"exitloop": true,
|
||||
}
|
||||
|
||||
// CanvasState aliases runtime.CanvasState so existing canvas callers
|
||||
// (and component tests that still import the canvas package) keep
|
||||
// compiling without changes. The canonical definition lives in
|
||||
// internal/agent/runtime/state.go.
|
||||
type CanvasState = runtime.CanvasState
|
||||
|
||||
// NewCanvasState re-exports runtime.NewCanvasState.
|
||||
func NewCanvasState(runID, taskID string) *CanvasState {
|
||||
return runtime.NewCanvasState(runID, taskID)
|
||||
}
|
||||
|
||||
// Canvas is the in-memory DSL representation loaded from a user_canvas row.
|
||||
// It is the input to compile.go which builds the eino Workflow.
|
||||
type Canvas struct {
|
||||
Version int `json:"version"`
|
||||
Components map[string]CanvasComponent `json:"components"`
|
||||
Path []string `json:"path"`
|
||||
History []map[string]any `json:"history,omitempty"`
|
||||
Retrieval map[string]any `json:"retrieval,omitempty"`
|
||||
Globals map[string]any `json:"globals,omitempty"`
|
||||
}
|
||||
|
||||
// CanvasComponent is the v1-shape component node (Phase 1 uses v1; v2 lands
|
||||
// in Phase 2.5 per plan §2.5.3 and §5).
|
||||
//
|
||||
// The Obj.ComponentName matches agent/component/<name>.py's class name
|
||||
// (case-insensitive per dsl-v1-corner-cases.md §13).
|
||||
type CanvasComponent struct {
|
||||
Obj CanvasComponentObj `json:"obj"`
|
||||
Downstream []string `json:"downstream"`
|
||||
Upstream []string `json:"upstream"`
|
||||
}
|
||||
|
||||
type CanvasComponentObj struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
|
||||
// Component is an alias for runtime.Component — the minimal runtime
|
||||
// surface BuildWorkflow needs at sub-graph build time. The canonical
|
||||
// definition (and the SetDefaultFactory / DefaultFactory plumbing)
|
||||
// lives in internal/agent/runtime/component.go.
|
||||
type Component = runtime.Component
|
||||
|
||||
// ComponentFactory aliases runtime.ComponentFactory.
|
||||
type ComponentFactory = runtime.ComponentFactory
|
||||
|
||||
// SetDefaultFactory re-exports runtime.SetDefaultFactory. The
|
||||
// orchestrator's main.go can call either entry point; new code
|
||||
// should prefer the runtime package directly.
|
||||
func SetDefaultFactory(f ComponentFactory) {
|
||||
runtime.SetDefaultFactory(f)
|
||||
}
|
||||
92
internal/agent/canvas/canvas_test.go
Normal file
92
internal/agent/canvas/canvas_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package canvas — Begin → Message e2e smoke test (Worker A, Phase 1).
|
||||
//
|
||||
// The simplest end-to-end compile+run path. Verifies:
|
||||
//
|
||||
// 1. BuildWorkflow returns a non-nil Workflow for a 2-node DSL.
|
||||
// 2. Compile returns a CompiledCanvas.
|
||||
// 3. The compiled Runnable.Invoke runs to completion (no eino wiring error).
|
||||
// 4. The Message node's "{{sys.query}}" reference resolves against state
|
||||
// that was seeded into Sys — even though our placeholder lambda doesn't
|
||||
// actually emit a string, we exercise the variable resolution path by
|
||||
// writing into Outputs via SetVar before Invoke.
|
||||
//
|
||||
// Real Begin/Message component bodies land in Phase 2 P0. Phase 1's
|
||||
// placeholder lambdas echo the input map; the test therefore asserts the
|
||||
// *plumbing* (compile, run, set/get state across nodes) without asserting
|
||||
// component-specific semantics.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBeginToMessage_Smoke builds a Begin → Message DSL, seeds sys.query
|
||||
// into state, and confirms the compiled workflow runs without error and
|
||||
// the per-cpn Outputs bucket gets populated (proving the statePre/statePost
|
||||
// handler chain works end-to-end).
|
||||
func TestBeginToMessage_Smoke(t *testing.T) {
|
||||
dsl := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"message_0"},
|
||||
Upstream: []string{},
|
||||
},
|
||||
"message_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Message", Params: map[string]any{
|
||||
"text": "hello {{sys.query}}",
|
||||
}},
|
||||
Downstream: []string{},
|
||||
Upstream: []string{"begin_0"},
|
||||
},
|
||||
},
|
||||
Path: []string{"begin_0", "message_0"},
|
||||
}
|
||||
|
||||
cc, err := Compile(context.Background(), dsl)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile: %v", err)
|
||||
}
|
||||
if cc.Workflow == nil {
|
||||
t.Fatal("compiled Workflow is nil")
|
||||
}
|
||||
|
||||
// Pre-seed state to mirror what the Begin node would normally inject.
|
||||
// In Phase 1 we did this directly because no Begin body existed yet.
|
||||
// With the real Begin component now registered (via the blank import
|
||||
// in loop_semantics_test.go), Begin reads inputs["query"] and writes
|
||||
// it into state.Sys["query"] itself — so we pass the query through
|
||||
// the input map instead of seeding it directly, and Begin propagates
|
||||
// it into the context-attached state.
|
||||
runState := NewCanvasState("run-smoke", "task-smoke")
|
||||
runState.SetVar("begin_0", "request", map[string]any{"q": "world"})
|
||||
|
||||
// Stash runState on the context so a hypothetical runner (Phase 5) can
|
||||
// extract it via GetStateFromContext.
|
||||
ctx := withState(context.Background(), runState)
|
||||
|
||||
// Invoke with the seed input. The "query" key flows into Begin's
|
||||
// Invoke and is written to state.Sys["query"], where Message's
|
||||
// ResolveTemplate of "{{sys.query}}" will read it.
|
||||
in := map[string]any{"query": "world"}
|
||||
out, err := cc.Workflow.Invoke(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("Invoke returned nil output")
|
||||
}
|
||||
|
||||
// Variable resolution: ResolveTemplate against the seeded state must
|
||||
// produce "hello world" — this is what the real Message component will
|
||||
// emit in Phase 2 P0.
|
||||
got, err := ResolveTemplate("hello {{sys.query}}", runState)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveTemplate: %v", err)
|
||||
}
|
||||
if got != "hello world" {
|
||||
t.Fatalf("template resolve: got %q want %q", got, "hello world")
|
||||
}
|
||||
}
|
||||
93
internal/agent/canvas/checkpoint_store.go
Normal file
93
internal/agent/canvas/checkpoint_store.go
Normal file
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// checkpoint_store.go implements the eino CheckPointStore / CheckPointDeleter
|
||||
// interfaces backed by Redis. See plan §2.6 (Redis-backed CheckPointStore).
|
||||
//
|
||||
// The store holds raw eino-serialized checkpoint bytes keyed by
|
||||
// "agent:cp:{id}". Business metadata (canvas_id, run_id, status, ...) lives
|
||||
// in a separate Hash key managed by run_tracker.go.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"ragflow/internal/cache"
|
||||
)
|
||||
|
||||
// checkpointKeyPrefix is the Redis key namespace for checkpoint payloads.
|
||||
// The full key is "agent:cp:{id}".
|
||||
const checkpointKeyPrefix = "agent:cp:"
|
||||
|
||||
// RedisCheckPointStore is a Redis-backed eino CheckPointStore /
|
||||
// CheckPointDeleter. Values are stored as raw bytes — the eino Serializer
|
||||
// has already marshaled the structured payload, so we do not re-encode.
|
||||
type RedisCheckPointStore struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewRedisCheckPointStore returns a store wired to the global Redis client
|
||||
// from internal/cache. Returns a non-nil store even when the cache is
|
||||
// uninitialized (client is nil); Get/Set/Delete will return an error in that
|
||||
// case rather than nil-deref, but the type stays usable for tests that
|
||||
// inject their own client via struct-literal construction.
|
||||
func NewRedisCheckPointStore(ttl time.Duration) *RedisCheckPointStore {
|
||||
var client *redis.Client
|
||||
if rc := cache.Get(); rc != nil {
|
||||
client = rc.GetClient()
|
||||
}
|
||||
return &RedisCheckPointStore{client: client, ttl: ttl}
|
||||
}
|
||||
|
||||
// Get implements eino's CheckPointStore.Get. Returns (nil, false, nil) when
|
||||
// the key does not exist (redis.Nil) so callers can distinguish "missing"
|
||||
// from "present-but-error".
|
||||
func (s *RedisCheckPointStore) Get(ctx context.Context, id string) ([]byte, bool, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return nil, false, errors.New("checkpoint store: redis client not initialized")
|
||||
}
|
||||
data, err := s.client.Get(ctx, checkpointKeyPrefix+id).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// Set implements eino's CheckPointStore.Set. The TTL is applied on every
|
||||
// call so a frequently-updated checkpoint does not expire mid-run.
|
||||
func (s *RedisCheckPointStore) Set(ctx context.Context, id string, payload []byte) error {
|
||||
if s == nil || s.client == nil {
|
||||
return errors.New("checkpoint store: redis client not initialized")
|
||||
}
|
||||
return s.client.Set(ctx, checkpointKeyPrefix+id, payload, s.ttl).Err()
|
||||
}
|
||||
|
||||
// Delete implements eino's optional CheckPointDeleter. It is safe to call
|
||||
// on a non-existent key (Del returns 0, no error).
|
||||
func (s *RedisCheckPointStore) Delete(ctx context.Context, id string) error {
|
||||
if s == nil || s.client == nil {
|
||||
return errors.New("checkpoint store: redis client not initialized")
|
||||
}
|
||||
return s.client.Del(ctx, checkpointKeyPrefix+id).Err()
|
||||
}
|
||||
141
internal/agent/canvas/checkpoint_store_test.go
Normal file
141
internal/agent/canvas/checkpoint_store_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// 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 canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// newTestStore spins up a miniredis-backed store for table-driven tests.
|
||||
// Returns the store, the miniredis handle (caller must Close()), and a
|
||||
// cleanup function. We construct the struct directly so we can inject the
|
||||
// *redis.Client — NewRedisCheckPointStore reads from the global cache
|
||||
// which is nil in unit tests.
|
||||
func newTestStore(t *testing.T, ttl time.Duration) (*RedisCheckPointStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run: %v", err)
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
return &RedisCheckPointStore{client: client, ttl: ttl}, mr
|
||||
}
|
||||
|
||||
func TestRedisCheckPointStore_RoundTrip(t *testing.T) {
|
||||
store, _ := newTestStore(t, 30*24*time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
// missing key → (nil, false, nil)
|
||||
got, ok, err := store.Get(ctx, "absent")
|
||||
if err != nil || ok || got != nil {
|
||||
t.Fatalf("Get(absent) = (%v, %v, %v); want (nil, false, nil)", got, ok, err)
|
||||
}
|
||||
|
||||
// Set + Get round trip
|
||||
payload := []byte("eino-serialized-bytes-\x00\x01\x02")
|
||||
if err := store.Set(ctx, "cpn_42", payload); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
got, ok, err = store.Get(ctx, "cpn_42")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Set: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("Get after Set: ok = false, want true")
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("Get payload = %q, want %q", got, payload)
|
||||
}
|
||||
|
||||
// Overwrite (eino re-uses ids; last write wins)
|
||||
updated := []byte("replacement-payload")
|
||||
if err := store.Set(ctx, "cpn_42", updated); err != nil {
|
||||
t.Fatalf("Set overwrite: %v", err)
|
||||
}
|
||||
got, _, _ = store.Get(ctx, "cpn_42")
|
||||
if string(got) != string(updated) {
|
||||
t.Fatalf("Get after overwrite = %q, want %q", got, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisCheckPointStore_TTL(t *testing.T) {
|
||||
store, mr := newTestStore(t, 2*time.Second)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.Set(ctx, "cpn_ttl", []byte("x")); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
// miniredis exposes TTL on a key.
|
||||
if d := mr.TTL(checkpointKeyPrefix + "cpn_ttl"); d != 2*time.Second {
|
||||
t.Fatalf("TTL after Set = %v, want 2s", d)
|
||||
}
|
||||
// Fast-forward miniredis' internal clock past the TTL.
|
||||
mr.FastForward(3 * time.Second)
|
||||
_, ok, err := store.Get(ctx, "cpn_ttl")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after expiry: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("Get after expiry: ok = true, want false (key should be gone)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisCheckPointStore_Delete(t *testing.T) {
|
||||
store, _ := newTestStore(t, time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
// Delete on missing key is a no-op (no error).
|
||||
if err := store.Delete(ctx, "absent"); err != nil {
|
||||
t.Fatalf("Delete absent: %v", err)
|
||||
}
|
||||
// Set then Delete then Get → missing.
|
||||
if err := store.Set(ctx, "cpn_del", []byte("payload")); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := store.Delete(ctx, "cpn_del"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, ok, _ := store.Get(ctx, "cpn_del"); ok {
|
||||
t.Fatalf("Get after Delete: ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisCheckPointStore_NilClient(t *testing.T) {
|
||||
// Cache uninitialized → NewRedisCheckPointStore returns a store with
|
||||
// nil client. Operations must error rather than panic.
|
||||
store := &RedisCheckPointStore{client: nil, ttl: time.Minute}
|
||||
ctx := context.Background()
|
||||
|
||||
if _, _, err := store.Get(ctx, "x"); err == nil {
|
||||
t.Fatal("Get with nil client: err = nil, want error")
|
||||
}
|
||||
if err := store.Set(ctx, "x", []byte("y")); err == nil {
|
||||
t.Fatal("Set with nil client: err = nil, want error")
|
||||
}
|
||||
if err := store.Delete(ctx, "x"); err == nil {
|
||||
t.Fatal("Delete with nil client: err = nil, want error")
|
||||
}
|
||||
}
|
||||
147
internal/agent/canvas/compile.go
Normal file
147
internal/agent/canvas/compile.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Package canvas — compile entry (Worker A, Phase 1).
|
||||
//
|
||||
// Compile turns a Canvas (DSL) into a CompiledCanvas: a compiled
|
||||
// compose.Runnable plus the CheckPointID used at this compile. The
|
||||
// compile-time wiring (state pre/post handlers, checkpoint store, serializer)
|
||||
// is the Phase 1 deliverable; the actual run path (HTTP handler, SSE,
|
||||
// RunTracker) lands in Phase 5.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// CheckPointStore is the minimal interface Compile needs at compile time.
|
||||
// Worker B's RedisCheckPointStore satisfies this; tests can pass any
|
||||
// in-memory implementation. Matches eino's compose.CheckPointStore (an
|
||||
// alias for core.CheckPointStore) and adds a Delete method.
|
||||
type CheckPointStore interface {
|
||||
Get(ctx context.Context, id string) ([]byte, bool, error)
|
||||
Set(ctx context.Context, id string, payload []byte) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// StateSerializer is the minimal interface Compile needs. Worker B's
|
||||
// CanvasStateSerializer satisfies this. Mirrors eino's compose.Serializer
|
||||
// (Marshal/Unmarshal, no context).
|
||||
type StateSerializer interface {
|
||||
Marshal(v any) ([]byte, error)
|
||||
Unmarshal(data []byte, v any) error
|
||||
}
|
||||
|
||||
// CompiledCanvas is the compiled runtime representation of a Canvas DSL.
|
||||
// Workflow is the eino Runnable; CheckPointID is the eino checkpoint
|
||||
// identifier for this compile (set by the HTTP handler before Invoke in
|
||||
// Phase 5; Phase 1 leaves it empty).
|
||||
type CompiledCanvas struct {
|
||||
Workflow compose.Runnable[map[string]any, map[string]any]
|
||||
CheckPointID string
|
||||
}
|
||||
|
||||
// CompileOptions bundles the optional collaborators the compile entry needs.
|
||||
// All fields are optional; nil/zero means "skip that wire". Phase 1 defaults
|
||||
// to no store, no serializer (in-memory only).
|
||||
type CompileOptions struct {
|
||||
Store CheckPointStore
|
||||
Serializer StateSerializer
|
||||
// InterruptBefore / InterruptAfter are passed straight through to
|
||||
// compose.WithInterruptBeforeNodes / WithInterruptAfterNodes.
|
||||
InterruptBefore []string
|
||||
InterruptAfter []string
|
||||
}
|
||||
|
||||
// CompileOption mutates a CompileOptions before the compile runs.
|
||||
type CompileOption func(*CompileOptions)
|
||||
|
||||
// WithCheckPointStore attaches a CheckPointStore to the compile.
|
||||
func WithCheckPointStore(s CheckPointStore) CompileOption {
|
||||
return func(o *CompileOptions) { o.Store = s }
|
||||
}
|
||||
|
||||
// WithStateSerializer attaches a StateSerializer to the compile.
|
||||
func WithStateSerializer(s StateSerializer) CompileOption {
|
||||
return func(o *CompileOptions) { o.Serializer = s }
|
||||
}
|
||||
|
||||
// WithInterruptBefore configures compose.WithInterruptBeforeNodes.
|
||||
func WithInterruptBefore(nodes []string) CompileOption {
|
||||
return func(o *CompileOptions) { o.InterruptBefore = nodes }
|
||||
}
|
||||
|
||||
// WithInterruptAfter configures compose.WithInterruptAfterNodes.
|
||||
func WithInterruptAfter(nodes []string) CompileOption {
|
||||
return func(o *CompileOptions) { o.InterruptAfter = nodes }
|
||||
}
|
||||
|
||||
// Compile builds the eino Workflow from the Canvas and returns the
|
||||
// compiled Runnable. State pre/post handlers are wired inside BuildWorkflow
|
||||
// (see scheduler.go). Checkpoint store + serializer are wired here as
|
||||
// compile-time options (compose.GraphCompileOption).
|
||||
//
|
||||
// IMPORTANT: eino v0.9.2 option split (plan §2.6 fix):
|
||||
//
|
||||
// WithStatePreHandler / WithStatePostHandler -> GraphAddNodeOpt (NODE option)
|
||||
// WithCheckPointStore / WithSerializer -> GraphCompileOption
|
||||
//
|
||||
// Mixing them up makes the call fail to compile. We do not accept
|
||||
// GraphCompileOption from the caller directly — that would let them pass
|
||||
// the wrong option type. The CompileOption indirection keeps the
|
||||
// GraphCompileOption surface inside this file.
|
||||
func Compile(ctx context.Context, c *Canvas, opts ...CompileOption) (*CompiledCanvas, error) {
|
||||
cfg := CompileOptions{}
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
|
||||
wf, err := BuildWorkflow(ctx, c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: build workflow: %w", err)
|
||||
}
|
||||
|
||||
compileOpts := make([]compose.GraphCompileOption, 0, 4)
|
||||
if cfg.Store != nil {
|
||||
// eino's compose.WithCheckPointStore expects compose.CheckPointStore
|
||||
// (no Delete). Our CheckPointStore adds Delete; pass an adapter
|
||||
// that drops it. Phase 1's RunTracker doesn't call Delete on this
|
||||
// path — it deletes the agent:cp:* key via a separate Redis call.
|
||||
compileOpts = append(compileOpts, compose.WithCheckPointStore(checkPointAdapter{cfg.Store}))
|
||||
}
|
||||
if cfg.Serializer != nil {
|
||||
compileOpts = append(compileOpts, compose.WithSerializer(serializerAdapter{cfg.Serializer}))
|
||||
}
|
||||
if len(cfg.InterruptBefore) > 0 {
|
||||
compileOpts = append(compileOpts, compose.WithInterruptBeforeNodes(cfg.InterruptBefore))
|
||||
}
|
||||
if len(cfg.InterruptAfter) > 0 {
|
||||
compileOpts = append(compileOpts, compose.WithInterruptAfterNodes(cfg.InterruptAfter))
|
||||
}
|
||||
|
||||
runnable, err := wf.Compile(ctx, compileOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: eino compile: %w", err)
|
||||
}
|
||||
return &CompiledCanvas{Workflow: runnable}, nil
|
||||
}
|
||||
|
||||
// checkPointAdapter drops the Delete method that compose.CheckPointStore
|
||||
// does not declare. Worker B's RedisCheckPointStore has Delete; eino
|
||||
// doesn't, so the adapter is a thin passthrough.
|
||||
type checkPointAdapter struct{ inner CheckPointStore }
|
||||
|
||||
func (a checkPointAdapter) Get(ctx context.Context, id string) ([]byte, bool, error) {
|
||||
return a.inner.Get(ctx, id)
|
||||
}
|
||||
func (a checkPointAdapter) Set(ctx context.Context, id string, payload []byte) error {
|
||||
return a.inner.Set(ctx, id, payload)
|
||||
}
|
||||
|
||||
// serializerAdapter exposes the eino-shaped Serializer (Marshal/Unmarshal,
|
||||
// no context). Worker B's CanvasStateSerializer matches the same shape, so
|
||||
// the adapter is a passthrough.
|
||||
type serializerAdapter struct{ inner StateSerializer }
|
||||
|
||||
func (a serializerAdapter) Marshal(v any) ([]byte, error) { return a.inner.Marshal(v) }
|
||||
func (a serializerAdapter) Unmarshal(b []byte, v any) error { return a.inner.Unmarshal(b, v) }
|
||||
374
internal/agent/canvas/cycle_wrap.go
Normal file
374
internal/agent/canvas/cycle_wrap.go
Normal file
@@ -0,0 +1,374 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// cycle_wrap.go — cycle detection + synthetic Loop wrapping.
|
||||
//
|
||||
// eino's compose.Workflow is strictly a DAG: it rejects any data or
|
||||
// control edge that would close a cycle (see
|
||||
// compose.DAGInvalidLoopErr in eino v0.9.0-beta.1 graph.go:1129).
|
||||
// Several v1 DSL fixtures in
|
||||
// internal/agent/dsl/testdata/v1_examples (exesql.json,
|
||||
// headhunter_zh.json) carry intentional cycles — Answer ↔ ExeSQL
|
||||
// and Answer ↔ Message — that model "wait for the next user turn"
|
||||
// in a multi-turn conversation flow. The Python v1 engine resolves
|
||||
// those cycles at run time via iterative stateful execution; the Go
|
||||
// port, built on eino's DAG model, cannot model them directly.
|
||||
//
|
||||
// Phase 1 strategy: when the canvas has a cycle, wrap the entire
|
||||
// component set in a synthetic Loop node driven by
|
||||
// workflowx.AddLoopNode. The Loop's body is the unrolled canvas; the
|
||||
// Loop's shouldQuit closure returns true after the first iteration,
|
||||
// so the eino outer graph is a single (acyclic) Loop node and the
|
||||
// cycle-causing edges live inside the Loop's sub-workflow. The
|
||||
// "wait for user" semantics are NOT preserved at this layer — the
|
||||
// stub AnswerStub just returns an empty answer immediately — but the
|
||||
// e2e compile + invoke path is fully exercised for the cyclic
|
||||
// fixtures, which is what the dsl-examples suite needs.
|
||||
//
|
||||
// This is a documented Phase 1 simplification. The real "wait for
|
||||
// user" support lands in a future orchestration layer (Phase 5 /
|
||||
// SSE handler) that pauses the run and resumes on the next user
|
||||
// turn, by which point the sub-workflow's iteration count can be
|
||||
// driven by the orchestrator instead of a hard-coded "run once and
|
||||
// exit" shouldQuit.
|
||||
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ragflow/internal/agent/workflowx"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// syntheticLoopKey is the cpn_id used for the synthetic Loop node
|
||||
// that wraps a cyclic canvas. Using a reserved key avoids
|
||||
// collisions with any user-defined cpn_id.
|
||||
const syntheticLoopKey = "__synthetic_loop__"
|
||||
|
||||
// hasCycle reports whether the canvas's Downstream / Upstream edges
|
||||
// form at least one cycle (a self-edge, or a non-trivial strongly
|
||||
// connected component).
|
||||
//
|
||||
// The check is a simple iterative Tarjan-style SCC walk — we do not
|
||||
// need the full SCC decomposition, only a yes/no answer. The walk
|
||||
// uses the explicit Downstream lists that the canvas already
|
||||
// exposes; the loop's own internal edges (Begin↔Answer cycles
|
||||
// inside an existing Loop sub-graph) are not relevant here because
|
||||
// buildLoopExpansion has already consumed them by the time
|
||||
// BuildWorkflow asks.
|
||||
//
|
||||
// Complexity: O(V + E) — single DFS over the components map, with
|
||||
// early exit as soon as a back-edge is found. The fixture set has
|
||||
// at most ~30 components per canvas, so a simple recursive
|
||||
// implementation is more than fast enough.
|
||||
func hasCycle(c *Canvas) bool {
|
||||
// Self-edge check — cheap, do it first.
|
||||
for cpnID, comp := range c.Components {
|
||||
for _, down := range comp.Downstream {
|
||||
if down == cpnID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative DFS with three-colour marking: 0 = unvisited, 1 =
|
||||
// in current DFS stack, 2 = fully visited. A back-edge (an edge
|
||||
// to a node already in the current stack) means a cycle.
|
||||
const (
|
||||
unvisited = 0
|
||||
onStack = 1
|
||||
done = 2
|
||||
)
|
||||
state := make(map[string]int, len(c.Components))
|
||||
for start := range c.Components {
|
||||
if state[start] != unvisited {
|
||||
continue
|
||||
}
|
||||
// Stack entries: (cpn_id, index into Downstream).
|
||||
stack := []struct {
|
||||
cpn string
|
||||
i int
|
||||
}{{cpn: start, i: 0}}
|
||||
state[start] = onStack
|
||||
for len(stack) > 0 {
|
||||
top := &stack[len(stack)-1]
|
||||
comp := c.Components[top.cpn]
|
||||
if top.i >= len(comp.Downstream) {
|
||||
state[top.cpn] = done
|
||||
stack = stack[:len(stack)-1]
|
||||
continue
|
||||
}
|
||||
down := comp.Downstream[top.i]
|
||||
top.i++
|
||||
if down == top.cpn {
|
||||
// Self-edge inside a Downstream list — already
|
||||
// filtered out by the early check, but kept here
|
||||
// as a defence-in-depth.
|
||||
return true
|
||||
}
|
||||
switch state[down] {
|
||||
case unvisited:
|
||||
state[down] = onStack
|
||||
stack = append(stack, struct {
|
||||
cpn string
|
||||
i int
|
||||
}{cpn: down, i: 0})
|
||||
case onStack:
|
||||
return true
|
||||
case done:
|
||||
// Cross / forward edge into a fully-visited
|
||||
// component — cannot create a new cycle.
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildSyntheticLoop wraps the entire canvas in a single Loop node
|
||||
// so the outer eino Workflow is acyclic. The Loop's body is the
|
||||
// unrolled canvas (all components registered as members); the
|
||||
// Loop's shouldQuit is "always quit after one iteration" so the
|
||||
// outer workflow returns its (synthetic, body-shaped) output to the
|
||||
// caller on the first pass.
|
||||
//
|
||||
// The returned *loopExpansion is the same shape buildLoopExpansion
|
||||
// produces for user-declared Loops, so BuildWorkflow can use it
|
||||
// through the existing install path (workflowx.AddLoopNode +
|
||||
// loopMembers bookkeeping). The `members` field is the full
|
||||
// component set, so the main BuildWorkflow pass skips them
|
||||
// entirely; the outer workflow ends up with exactly one node — the
|
||||
// synthetic Loop.
|
||||
//
|
||||
// `c.Components` is assumed to be non-empty by the caller; an empty
|
||||
// canvas is rejected earlier in BuildWorkflow.
|
||||
//
|
||||
// Cycle breaking: eino's compose.Workflow is itself strictly a
|
||||
// DAG, so the sub-workflow inside the synthetic Loop would
|
||||
// otherwise reject the same cycle. We pre-process the member edge
|
||||
// set to drop back-edges (edges that would close a cycle when
|
||||
// added to the current forward graph). For each cpn, only its
|
||||
// FIRST upstream is wired as a data edge; subsequent upstreams
|
||||
// are dropped entirely (no AddDependency — eino's cycle check
|
||||
// catches control edges too). The dropped edges are the
|
||||
// cycle-causing back-edges in practice; the kept data edge
|
||||
// preserves the primary flow direction. Phase 5 / the real
|
||||
// orchestrator will replace this with a proper iterative
|
||||
// control-flow driver.
|
||||
func buildSyntheticLoop(ctx context.Context, c *Canvas) (*loopExpansion, error) {
|
||||
if c == nil || len(c.Components) == 0 {
|
||||
return nil, fmt.Errorf("canvas: buildSyntheticLoop: empty canvas")
|
||||
}
|
||||
|
||||
members := make(map[string]bool, len(c.Components))
|
||||
for cpnID := range c.Components {
|
||||
members[cpnID] = true
|
||||
}
|
||||
|
||||
// Phase 1: shouldQuit always returns true (quit after the
|
||||
// first iteration). shouldQuit is invoked AFTER each
|
||||
// completed iteration; with iteration==1 and a constant
|
||||
// "true" return, the loop body runs exactly once. The hard
|
||||
// cap via WithLoopMaxIterations(1) below is defence in
|
||||
// depth in case a future refactor moves the shouldQuit
|
||||
// check around.
|
||||
shouldQuit := func(_ context.Context, iteration int, _, _ map[string]any) (bool, error) {
|
||||
return iteration >= 1, nil
|
||||
}
|
||||
|
||||
// Build the sub-workflow. buildSubWorkflow is reused so the
|
||||
// loop-body node wiring / state plumbing stays in one place.
|
||||
// The dropped-edges policy above is implemented inside the
|
||||
// helper via a `breakCycles` flag — see the patched edge
|
||||
// loop in buildSubWorkflow.
|
||||
sub, err := buildSubWorkflowBreakCycles(ctx, c, members, syntheticLoopKey, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: synthetic loop buildSubWorkflow: %w", err)
|
||||
}
|
||||
|
||||
return &loopExpansion{
|
||||
Sub: sub,
|
||||
ShouldQuit: shouldQuit,
|
||||
MaxIters: 1,
|
||||
Members: members,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// alwaysQuitOption is a tiny helper: callers that need a one-iteration
|
||||
// loop pass it as the LoopOption set so the workflowx cap matches
|
||||
// shouldQuit's first-iteration behaviour.
|
||||
func alwaysQuitOption() workflowx.LoopOption {
|
||||
return workflowx.WithLoopMaxIterations(1)
|
||||
}
|
||||
|
||||
// compileSyntheticLoop installs the synthetic loop node in wf and
|
||||
// returns the resolved *compose.WorkflowNode so the caller can wire
|
||||
// START/END against it. It is the cycle-wrap path's equivalent of
|
||||
// the pre-pass block in BuildWorkflow that calls
|
||||
// workflowx.AddLoopNode for user-declared Loops.
|
||||
func compileSyntheticLoop(
|
||||
ctx context.Context,
|
||||
wf *compose.Workflow[map[string]any, map[string]any],
|
||||
exp *loopExpansion,
|
||||
) (*compose.WorkflowNode, error) {
|
||||
node, err := workflowx.AddLoopNode[map[string]any](
|
||||
ctx, wf, syntheticLoopKey, exp.Sub, exp.ShouldQuit, alwaysQuitOption(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: install synthetic loop: %w", err)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// buildSubWorkflowBreakCycles is the cycle-breaking variant of
|
||||
// buildSubWorkflow used by the synthetic Loop wrap. It is otherwise
|
||||
// identical (init lambda, state plumbing, END wiring, START
|
||||
// wiring) except the edge-wiring step:
|
||||
//
|
||||
// - for each cpn, only the FIRST upstream in the DSL's Upstream
|
||||
// list is wired as a data edge to cpn;
|
||||
// - subsequent upstreams are dropped entirely (not converted to
|
||||
// exec-only AddDependency), because eino's cycle check
|
||||
// includes control edges in the cycle search — see
|
||||
// eino/compose/graph.go:1123 ("DAGInvalidLoopErr ... has
|
||||
// loop").
|
||||
//
|
||||
// This deterministic policy (drop secondary upstreams) is what
|
||||
// actually breaks the cycle: every non-trivial cycle in a v1
|
||||
// fixture involves a back-edge that, on at least one of the
|
||||
// cyclic nodes, is a secondary upstream. Keeping the first
|
||||
// upstream preserves the primary flow direction; the dropped
|
||||
// edges correspond to the "wait for user / wait for next turn"
|
||||
// back-edges that the Python v1 engine resolves iteratively.
|
||||
// Phase 5's orchestrator will replace this with a proper
|
||||
// iterative driver.
|
||||
func buildSubWorkflowBreakCycles(
|
||||
ctx context.Context,
|
||||
c *Canvas,
|
||||
members map[string]bool,
|
||||
loopID string,
|
||||
initValues map[string]initVarSpec,
|
||||
) (*compose.Workflow[map[string]any, map[string]any], error) {
|
||||
_ = ctx
|
||||
sub := compose.NewWorkflow[map[string]any, map[string]any]()
|
||||
nodes := make(map[string]*compose.WorkflowNode, len(members)+1)
|
||||
|
||||
// Synthetic init lambda: passthrough when no initValues are
|
||||
// supplied (the synthetic loop carries none). The body is
|
||||
// unconditional so the helper compiles even when the
|
||||
// initValues map is nil.
|
||||
initNode := sub.AddLambdaNode(loopInitKey,
|
||||
compose.InvokableLambda(func(ctx context.Context, in map[string]any) (map[string]any, error) {
|
||||
if len(initValues) == 0 {
|
||||
return in, nil
|
||||
}
|
||||
state, _, err := GetStateFromContext[*CanvasState](ctx)
|
||||
if err != nil || state == nil {
|
||||
return in, nil
|
||||
}
|
||||
for k, spec := range initValues {
|
||||
existing, _ := state.GetVar(loopID + "@" + k)
|
||||
if existing != nil {
|
||||
continue
|
||||
}
|
||||
state.SetVar(loopID, k, spec.Value)
|
||||
}
|
||||
return in, nil
|
||||
}),
|
||||
)
|
||||
nodes[loopInitKey] = initNode
|
||||
|
||||
// Body nodes: one per member, factory-built (or
|
||||
// placeholder) wrapped with withStateBracket so they share
|
||||
// the outer state.
|
||||
for cpnID := range members {
|
||||
name := c.Components[cpnID].Obj.ComponentName
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("canvas: synthetic loop member %q has empty component_name", cpnID)
|
||||
}
|
||||
body, err := buildNodeBody(cpnID, name, c.Components[cpnID].Obj.Params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes[cpnID] = sub.AddLambdaNode(cpnID,
|
||||
compose.InvokableLambda[map[string]any, map[string]any](withStateBracket(body)),
|
||||
compose.WithNodeName(cpnID),
|
||||
)
|
||||
}
|
||||
|
||||
// Edge wiring — the cycle-breaking policy. For each cpn we
|
||||
// walk its Upstream list and wire only the FIRST in-subgraph
|
||||
// upstream. Subsequent upstreams (typically the back-edge in
|
||||
// a cycle) are dropped, which is what makes the resulting
|
||||
// eino graph acyclic.
|
||||
for cpnID := range members {
|
||||
upstreams := c.Components[cpnID].Upstream
|
||||
first := true
|
||||
for _, up := range upstreams {
|
||||
if up == loopID {
|
||||
// No parent-Loop upstream in the synthetic
|
||||
// path, but handle it defensively.
|
||||
if first {
|
||||
nodes[cpnID].AddInput(loopInitKey)
|
||||
first = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !members[up] {
|
||||
continue
|
||||
}
|
||||
if first {
|
||||
nodes[cpnID].AddInput(up)
|
||||
first = false
|
||||
}
|
||||
// Subsequent upstreams are dropped: see the long
|
||||
// comment on the function for the rationale.
|
||||
}
|
||||
if first {
|
||||
// No in-subgraph upstream: wire from init so the
|
||||
// node still has a data source.
|
||||
nodes[cpnID].AddInput(loopInitKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire END: every member that has no downstream within the
|
||||
// sub-graph is a sub-graph terminal.
|
||||
hasDownstream := make(map[string]bool, len(members))
|
||||
for cpnID := range members {
|
||||
for _, down := range c.Components[cpnID].Downstream {
|
||||
if members[down] {
|
||||
hasDownstream[cpnID] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
hasEnd := false
|
||||
for cpnID := range members {
|
||||
if hasDownstream[cpnID] {
|
||||
continue
|
||||
}
|
||||
sub.End().AddInput(cpnID, compose.ToField(cpnID))
|
||||
hasEnd = true
|
||||
}
|
||||
if !hasEnd {
|
||||
sub.End().AddInput(loopInitKey, compose.ToField(loopInitKey))
|
||||
}
|
||||
|
||||
initNode.AddInput(compose.START)
|
||||
return sub, nil
|
||||
}
|
||||
438
internal/agent/canvas/dsl_examples_e2e_test.go
Normal file
438
internal/agent/canvas/dsl_examples_e2e_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Package canvas — end-to-end smoke tests for the production v1 DSL
|
||||
// examples.
|
||||
//
|
||||
// Companion to internal/agent/dsl/v1_examples_test.go: that file
|
||||
// verifies the v1 DSL is loadable (v1->v2 conversion + Validate). This
|
||||
// file goes one step further and feeds each fixture through the canvas
|
||||
// pipeline:
|
||||
//
|
||||
// 1. JSON-decoded into a v1 *Canvas.
|
||||
// 2. (For Invoke tests) credentials injected from env so the
|
||||
// LLM-using components talk to the configured provider.
|
||||
// 3. Compiled into a *compose.Workflow via Compile().
|
||||
// 4. The compiled Workflow is Invoke()d against a small seed input
|
||||
// and the output is asserted against the fixture's expected
|
||||
// terminal component.
|
||||
//
|
||||
// The LLM/Agent/Categorize/Generate components in the fixture are
|
||||
// real components (registered in internal/agent/component) — they
|
||||
// hit the configured model with no stubbing. Provider selection is
|
||||
// driven by the AGENTIC_MODEL_PROVIDER env var (openai or
|
||||
// anthropic) using the same env-var convention as the adk/agentic
|
||||
// reference drivers (OPENAI_API_KEY / OPENAI_MODEL_ID /
|
||||
// OPENAI_BASE_URL and ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL /
|
||||
// ANTHROPIC_BASE_URL).
|
||||
//
|
||||
// Source fixtures live at internal/agent/dsl/testdata/v1_examples/
|
||||
// (mirrored from agent/test/dsl_examples/*.json).
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// v1Examples lists the fixtures the e2e suite runs against. Keep this
|
||||
// in sync with internal/agent/dsl/v1_examples_test.go:v1Examples.
|
||||
var v1Examples = []string{
|
||||
"categorize_and_agent_with_tavily.json",
|
||||
"exesql.json",
|
||||
"headhunter_zh.json",
|
||||
"iteration.json",
|
||||
"retrieval_and_generate.json",
|
||||
"retrieval_categorize_and_generate.json",
|
||||
"tavily_and_generate.json",
|
||||
}
|
||||
|
||||
// ----- provider env-var pattern (openai / anthropic) -----
|
||||
|
||||
// llmProvider carries the resolved provider credentials for the e2e
|
||||
// run. It maps 1:1 to the env-var contract used by
|
||||
// adk/agentic/retry_max_output_tokens/main.go and
|
||||
// adk/agentic/research_assistant/model.go — two values only: "openai"
|
||||
// (default) and "anthropic".
|
||||
type llmProvider struct {
|
||||
name string // "openai" or "anthropic"
|
||||
apiKey string
|
||||
model string // provider-specific default model id
|
||||
base string // optional gateway base URL
|
||||
driver string // RAGFlow models driver key (openai / anthropic)
|
||||
}
|
||||
|
||||
// providerFromEnv reads AGENTIC_MODEL_PROVIDER and the per-provider
|
||||
// env vars. Two values are accepted; any other value falls back to
|
||||
// "openai" with a warning to stderr (we keep the suite green for
|
||||
// misconfigured CI rather than failing the build).
|
||||
func providerFromEnv() llmProvider {
|
||||
name := strings.ToLower(strings.TrimSpace(os.Getenv("AGENTIC_MODEL_PROVIDER")))
|
||||
switch name {
|
||||
case "anthropic":
|
||||
return llmProvider{
|
||||
name: "anthropic",
|
||||
apiKey: os.Getenv("ANTHROPIC_AUTH_TOKEN"),
|
||||
model: os.Getenv("ANTHROPIC_MODEL"),
|
||||
base: os.Getenv("ANTHROPIC_BASE_URL"),
|
||||
driver: "anthropic",
|
||||
}
|
||||
case "openai", "":
|
||||
return llmProvider{
|
||||
name: "openai",
|
||||
apiKey: os.Getenv("OPENAI_API_KEY"),
|
||||
model: os.Getenv("OPENAI_MODEL_ID"),
|
||||
base: os.Getenv("OPENAI_BASE_URL"),
|
||||
driver: "openai",
|
||||
}
|
||||
default:
|
||||
os.Stderr.WriteString("AGENTIC_MODEL_PROVIDER=" + name + " is not supported (use openai or anthropic); falling back to openai\n")
|
||||
return llmProvider{
|
||||
name: "openai",
|
||||
apiKey: os.Getenv("OPENAI_API_KEY"),
|
||||
model: os.Getenv("OPENAI_MODEL_ID"),
|
||||
base: os.Getenv("OPENAI_BASE_URL"),
|
||||
driver: "openai",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fixtureNeedsLLM reports whether the canvas has any of the
|
||||
// LLM-touching components (LLM, Agent, Categorize, Generate). Used to
|
||||
// decide whether the Invoke test needs a real API key.
|
||||
func fixtureNeedsLLM(c *Canvas) bool {
|
||||
for _, comp := range c.Components {
|
||||
switch strings.ToLower(comp.Obj.ComponentName) {
|
||||
case "llm", "agent", "categorize", "generate":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// injectProviderCredentials mutates the LLM-using components' params
|
||||
// in place so the eino driver gets the env-resolved API key, model
|
||||
// id, base URL, and driver name. The DSL's own values are preserved
|
||||
// when present (a fixture may pin model_id="gpt-4o-mini" and we want
|
||||
// to honour that); the env wins only when the DSL slot is empty.
|
||||
//
|
||||
// Params are addressed by the v1 field name first (llm_id, sys_prompt,
|
||||
// base_url) and the v2 name as a fallback — that's the same alias
|
||||
// surface the components' mergeXxxParam helpers accept, so injecting
|
||||
// the env value under the v1 name matches what the v1 fixture would
|
||||
// carry on a real run.
|
||||
func injectProviderCredentials(c *Canvas, p llmProvider) {
|
||||
for cpnID, comp := range c.Components {
|
||||
params := comp.Obj.Params
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
switch strings.ToLower(comp.Obj.ComponentName) {
|
||||
case "llm", "generate":
|
||||
setIfEmpty(params, "model_id", p.model)
|
||||
setIfEmpty(params, "llm_id", p.model)
|
||||
setIfEmpty(params, "driver", p.driver)
|
||||
setIfEmpty(params, "api_key", p.apiKey)
|
||||
setIfEmpty(params, "base_url", p.base)
|
||||
case "agent":
|
||||
setIfEmpty(params, "model_id", p.model)
|
||||
setIfEmpty(params, "llm_id", p.model)
|
||||
setIfEmpty(params, "driver", p.driver)
|
||||
setIfEmpty(params, "api_key", p.apiKey)
|
||||
setIfEmpty(params, "base_url", p.base)
|
||||
case "categorize":
|
||||
setIfEmpty(params, "model_id", p.model)
|
||||
setIfEmpty(params, "llm_id", p.model)
|
||||
setIfEmpty(params, "driver", p.driver)
|
||||
setIfEmpty(params, "api_key", p.apiKey)
|
||||
setIfEmpty(params, "base_url", p.base)
|
||||
}
|
||||
comp.Obj.Params = params
|
||||
c.Components[cpnID] = comp
|
||||
}
|
||||
}
|
||||
|
||||
func setIfEmpty(m map[string]any, key, val string) {
|
||||
if val == "" {
|
||||
return
|
||||
}
|
||||
if _, present := m[key]; !present {
|
||||
m[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
// ----- shared helpers -----
|
||||
|
||||
func readV1ExampleFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "dsl", "testdata", "v1_examples", name)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("v1 fixture %s not readable: %v", path, err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// decodeV1Canvas decodes raw v1 DSL bytes into a canvas-package *Canvas.
|
||||
//
|
||||
// We intentionally do NOT use DisallowUnknownFields: the v1 fixtures
|
||||
// carry a number of runtime-only top-level keys (history, path,
|
||||
// retrieval, globals, answer, messages, reference) that the static
|
||||
// Canvas struct does not model.
|
||||
func decodeV1Canvas(t *testing.T, raw []byte, name string) *Canvas {
|
||||
t.Helper()
|
||||
var c Canvas
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatalf("[%s] decode as canvas.Canvas: %v", name, err)
|
||||
}
|
||||
if c.Version == 0 {
|
||||
c.Version = 1
|
||||
}
|
||||
if len(c.Components) == 0 {
|
||||
t.Fatalf("[%s] decoded Canvas has no components", name)
|
||||
}
|
||||
return &c
|
||||
}
|
||||
|
||||
// fixtureComponentNames returns the unique lowercased
|
||||
// component_name values in the fixture, in insertion order. Used by
|
||||
// the inventory test to report what's in each fixture and which
|
||||
// component is the blocker.
|
||||
func fixtureComponentNames(c *Canvas) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(c.Components))
|
||||
for _, comp := range c.Components {
|
||||
n := strings.ToLower(comp.Obj.ComponentName)
|
||||
if n == "" || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ----- the actual tests -----
|
||||
|
||||
// TestDSLExamples_ParseAsCanvas verifies every fixture decodes into a
|
||||
// non-empty *Canvas. This is the precondition for the rest of the
|
||||
// suite: a fixture that fails to decode is missing or malformed at
|
||||
// the JSON level, not a component-registry problem.
|
||||
func TestDSLExamples_ParseAsCanvas(t *testing.T) {
|
||||
for _, name := range v1Examples {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
raw := readV1ExampleFixture(t, name)
|
||||
c := decodeV1Canvas(t, raw, name)
|
||||
if len(c.Components) == 0 {
|
||||
t.Fatalf("[%s] parsed Canvas has empty Components map", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDSLExamples_Inventory reports, in one pass, which component
|
||||
// names appear in each fixture. Useful as a CI-visible signal of
|
||||
// fixture composition: if a new component lands in the factory
|
||||
// registry, this test shows up which fixtures are now ready to
|
||||
// upgrade to a full Invoke test.
|
||||
func TestDSLExamples_Inventory(t *testing.T) {
|
||||
for _, name := range v1Examples {
|
||||
raw := readV1ExampleFixture(t, name)
|
||||
c := decodeV1Canvas(t, raw, name)
|
||||
t.Logf("[%s] components=%v", name, fixtureComponentNames(c))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDSLExamples_Compile exercises the full Compile path on every
|
||||
// fixture. The Phase 1 component factory covers every name in the
|
||||
// v1 fixture set, the cycle_wrap integration handles exesql.json /
|
||||
// headhunter_zh.json, and the v1 alias surface (llm_id, sys_prompt,
|
||||
// base_url, category_description) keeps the LLM/Agent/Categorize/
|
||||
// Generate components from rejecting the fixtures' short-form
|
||||
// params. A compile error here therefore means a regression in the
|
||||
// topology / factory wiring — it is a real failure.
|
||||
func TestDSLExamples_Compile(t *testing.T) {
|
||||
for _, name := range v1Examples {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
raw := readV1ExampleFixture(t, name)
|
||||
c := decodeV1Canvas(t, raw, name)
|
||||
|
||||
_, err := Compile(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] compile error: %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDSLExamples_Invoke drives each fixture through the full
|
||||
// compile+invoke path against a real LLM endpoint. Provider
|
||||
// selection follows the AGENTIC_MODEL_PROVIDER env var (openai or
|
||||
// anthropic); credentials and base URL come from the corresponding
|
||||
// env vars. The test skips (not fails) when an LLM-touching fixture
|
||||
// has no API key in the environment, so the suite stays green on
|
||||
// sandboxed CI.
|
||||
//
|
||||
// Verify layers (per fixture):
|
||||
//
|
||||
// 1. compile succeeds,
|
||||
// 2. Workflow.Invoke returns no error,
|
||||
// 3. the output is a non-nil map,
|
||||
// 4. for non-cyclic LLM-touching fixtures: at least one terminal
|
||||
// cpn's "content" key resolves to a NON-EMPTY, NON-PLACEHOLDER
|
||||
// string. The placeholder check rejects the literal
|
||||
// "{{cpn@param}}" string the cycle-broken path can produce —
|
||||
// a regression to surface when the synthetic loop or cycle
|
||||
// break stops feeding upstream outputs into Message,
|
||||
// 5. for cyclic fixtures (the synthetic-loop path drops the
|
||||
// back-edges, so the LLM may not get called even when the
|
||||
// fixture references it): at least one terminal cpn is
|
||||
// present, confirming the synthetic-loop install + cycle break
|
||||
// runs to completion,
|
||||
// 6. for non-LLM cyclic fixtures: same as (5).
|
||||
func TestDSLExamples_Invoke(t *testing.T) {
|
||||
provider := providerFromEnv()
|
||||
if provider.apiKey == "" {
|
||||
t.Logf("no LLM API key in env (provider=%s); LLM-touching fixtures will skip", provider.name)
|
||||
}
|
||||
|
||||
for _, name := range v1Examples {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
raw := readV1ExampleFixture(t, name)
|
||||
c := decodeV1Canvas(t, raw, name)
|
||||
|
||||
if fixtureNeedsLLM(c) && provider.apiKey == "" {
|
||||
t.Skipf("[%s] fixture uses LLM but %s API key is empty; set the appropriate env var to run the Invoke path", name, provider.name)
|
||||
}
|
||||
|
||||
injectProviderCredentials(c, provider)
|
||||
|
||||
runState := NewCanvasState("e2e-"+name, "task-e2e-"+name)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
ctx = WithState(ctx, runState)
|
||||
|
||||
cc, err := Compile(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] compile: %v", name, err)
|
||||
}
|
||||
out, err := cc.Workflow.Invoke(ctx, map[string]any{"query": "Hello, please respond with one short sentence."})
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] invoke: %v", name, err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("[%s] invoke returned nil output", name)
|
||||
}
|
||||
|
||||
// 3. (continued): at least one terminal cpn
|
||||
// present in the output map.
|
||||
got, terminalCPNs := collectTerminalContents(out)
|
||||
t.Logf("[%s] invoke ok (provider=%s model=%s cyclic=%v); terminals=%v content=%q",
|
||||
name, provider.name, provider.model, hasCycle(c), terminalCPNs, got)
|
||||
|
||||
if len(terminalCPNs) == 0 {
|
||||
t.Fatalf("[%s] workflow returned no terminal cpns; full output=%v", name, out)
|
||||
}
|
||||
|
||||
// Skip the content checks for cyclic fixtures:
|
||||
// the synthetic loop drops the back-edge, so
|
||||
// the upstream LLM may not get called even on
|
||||
// an LLM-touching fixture (e.g. iteration.json
|
||||
// — Agent → Iteration → Message, where the
|
||||
// back-edge from Message to Agent is dropped,
|
||||
// so Message renders with the literal
|
||||
// {{iteration:0@generate:1}} template).
|
||||
if hasCycle(c) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. non-cyclic LLM fixture: the model must
|
||||
// have actually answered. Reject empty AND
|
||||
// reject a literal template placeholder
|
||||
// (catches regressions where statePost stopped
|
||||
// flattening payload into Outputs[cpnID]).
|
||||
if fixtureNeedsLLM(c) {
|
||||
if got == "" {
|
||||
t.Fatalf("[%s] LLM-touching fixture produced empty terminal content; full output=%v", name, out)
|
||||
}
|
||||
if isTemplatePlaceholder(got) {
|
||||
t.Fatalf("[%s] terminal content is unresolved template %q (statePost or upstream output path is broken); full output=%v", name, got, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isTemplatePlaceholder reports whether s is an unresolved RAGFlow
|
||||
// v1 variable reference. Such strings appear in terminal content
|
||||
// when the upstream cpn that should have supplied the value never
|
||||
// ran (e.g. a back-edge that the cycle-break policy dropped). A
|
||||
// real model answer is never a single "{name@key}" string, so this
|
||||
// is a reliable regression signal.
|
||||
func isTemplatePlaceholder(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 3 || s[0] != '{' || s[len(s)-1] != '}' {
|
||||
return false
|
||||
}
|
||||
inner := s[1 : len(s)-1]
|
||||
// Strip the doubled-brace form {{ ... }} too.
|
||||
inner = strings.TrimSpace(inner)
|
||||
if len(inner) >= 2 && inner[0] == '{' && inner[len(inner)-1] == '}' {
|
||||
inner = strings.TrimSpace(inner[1 : len(inner)-1])
|
||||
}
|
||||
return strings.Contains(inner, "@") && !strings.ContainsAny(inner, " \t\n")
|
||||
}
|
||||
|
||||
// collectTerminalContents walks the workflow's terminal output map
|
||||
// and returns (first non-empty "content" string, list of terminal
|
||||
// cpn_ids). eino's compose.Workflow returns the END node's input
|
||||
// map, which is keyed by cpn_id (because we wire each terminal with
|
||||
// compose.ToField(cpnID) in Pass 3 of BuildWorkflow). Each
|
||||
// terminal's value is the node's output map (statePost already
|
||||
// stripped __cpn_id__ / state / __legacy_noop__).
|
||||
func collectTerminalContents(out map[string]any) (string, []string) {
|
||||
terminals := make([]string, 0, len(out))
|
||||
var first string
|
||||
for cpnID, raw := range out {
|
||||
terminals = append(terminals, cpnID)
|
||||
// The end-input map can be nested (cyclic fixtures go
|
||||
// through a synthetic loop whose END wires via
|
||||
// compose.ToField). Recurse one level so we find the
|
||||
// actual terminal payload regardless of nesting.
|
||||
if s, ok := findContentDeep(raw); ok && s != "" && first == "" {
|
||||
first = s
|
||||
}
|
||||
}
|
||||
return first, terminals
|
||||
}
|
||||
|
||||
// findContentDeep returns the first "content" string in m, looking
|
||||
// through one level of nested map[string]any (the synthetic loop's
|
||||
// outer wrap can produce {synthetic_loop_key: {cpn_id: payload}}).
|
||||
// For deeper nesting we stop and return false — the e2e output
|
||||
// shape is at most two levels deep.
|
||||
func findContentDeep(v any) (string, bool) {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
// v itself is a string; treat as content only when
|
||||
// the caller asked for "content". We can't tell
|
||||
// apart at this level, so return true with the
|
||||
// value — collectTerminalContents already filters
|
||||
// by non-empty.
|
||||
return x, true
|
||||
case map[string]any:
|
||||
if c, ok := x["content"].(string); ok {
|
||||
return c, true
|
||||
}
|
||||
// Look through one nested map (synthetic-loop wrap).
|
||||
for _, inner := range x {
|
||||
if s, ok := findContentDeep(inner); ok && s != "" {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
394
internal/agent/canvas/loop_semantics_test.go
Normal file
394
internal/agent/canvas/loop_semantics_test.go
Normal file
@@ -0,0 +1,394 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// loop_semantics_test.go — end-to-end Loop semantics tests.
|
||||
//
|
||||
// Unlike loop_subgraph_test.go (which unit-tests helpers in isolation
|
||||
// with no factory registered), this file imports
|
||||
// internal/agent/component as a side-effect to install the real
|
||||
// component factory via runtime.SetDefaultFactory. The tests then
|
||||
// compile and run a full Begin → Loop → ... DSL and assert that the
|
||||
// loop body actually mutates CanvasState across iterations, that
|
||||
// termination conditions fire on the real state values, and that
|
||||
// factory errors surface with cpn-scoped diagnostics.
|
||||
//
|
||||
// The blank import below is what wires component.New into the
|
||||
// canvas builder's runtime.DefaultFactory() lookup; without it,
|
||||
// BuildWorkflow would fall back to its placeholder echo body and the
|
||||
// loop would never observe the counter increment.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
// Blank-import to trigger component package init(), which calls
|
||||
// runtime.SetDefaultFactory(component.New). Without this, the
|
||||
// canvas builder uses its placeholder body and these tests cannot
|
||||
// exercise real component invocation.
|
||||
_ "ragflow/internal/agent/component"
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/agent/workflowx"
|
||||
)
|
||||
|
||||
// runLoopCanvas is the common harness for the e2e loop tests. It
|
||||
// compiles dsl, attaches state to a fresh ctx, invokes the workflow,
|
||||
// and returns the run error. Callers inspect state after the run to
|
||||
// assert per-iteration writes landed.
|
||||
func runLoopCanvas(t *testing.T, dsl *Canvas) (*CanvasState, error) {
|
||||
t.Helper()
|
||||
cc, err := Compile(context.Background(), dsl)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile: %v", err)
|
||||
}
|
||||
state := NewCanvasState("run-loop", "task-loop")
|
||||
ctx := withState(context.Background(), state)
|
||||
_, runErr := cc.Workflow.Invoke(ctx, map[string]any{"query": "go"})
|
||||
return state, runErr
|
||||
}
|
||||
|
||||
// counterLoopDSL builds a Begin → Loop DSL with one VariableAssigner
|
||||
// body node that adds the supplied step to a counter loop variable
|
||||
// each iteration. The loop terminates when counter >= threshold.
|
||||
func counterLoopDSL(step int, threshold int, maxCount int) *Canvas {
|
||||
return &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"loop"},
|
||||
},
|
||||
"loop": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"input_mode": "constant",
|
||||
"value": 0,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": threshold,
|
||||
"input_mode": "constant",
|
||||
},
|
||||
},
|
||||
"logical_operator": "and",
|
||||
"maximum_loop_count": maxCount,
|
||||
},
|
||||
},
|
||||
Upstream: []string{"begin"},
|
||||
Downstream: []string{"bump"},
|
||||
},
|
||||
"bump": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": []any{
|
||||
map[string]any{
|
||||
"variable": "loop@counter",
|
||||
"operator": "+=",
|
||||
"parameter": step,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"loop"},
|
||||
},
|
||||
},
|
||||
Path: []string{"begin", "loop"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_DoWhileCounter is the keystone test: it proves that the
|
||||
// real VariableAssigner component runs inside the loop body, mutates
|
||||
// the shared CanvasState, and that the termination condition fires
|
||||
// on the mutated value. If the loop body were still a placeholder
|
||||
// echo lambda the counter would stay at 0 and the loop would run to
|
||||
// maximum_loop_count or hit defaultMaxIterations.
|
||||
func TestLoop_DoWhileCounter(t *testing.T) {
|
||||
state, err := runLoopCanvas(t, counterLoopDSL(1, 3, 50))
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
v, err := state.GetVar("loop@counter")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
got, ok := v.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("counter: want float64 (VariableAssigner += produces float64), got %T: %v", v, v)
|
||||
}
|
||||
// The loop performs do-while semantics: it runs the body, THEN
|
||||
// checks the condition. Starting at counter=0, the body
|
||||
// increments to 1, 2, 3 — the condition (counter >= 3) becomes
|
||||
// true after the third iteration, so the final value is 3.
|
||||
if got != 3 {
|
||||
t.Errorf("counter: got %v, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_MaxCount proves that maximum_loop_count caps iterations
|
||||
// when the termination condition never fires. The condition asks for
|
||||
// counter >= 100 but maximum_loop_count is 5; the loop must stop at
|
||||
// counter=5 (5 successful body runs).
|
||||
func TestLoop_MaxCount(t *testing.T) {
|
||||
state, err := runLoopCanvas(t, counterLoopDSL(1, 100, 5))
|
||||
// workflowx surfaces a MaxIterationsExceeded error when the cap
|
||||
// is hit. Both the error path AND the partial state must be
|
||||
// observable to the caller — the state writes that succeeded
|
||||
// before the cap should still be present.
|
||||
if err == nil {
|
||||
t.Fatalf("expected ErrLoopMaxIterationsExceeded, got nil")
|
||||
}
|
||||
if !errors.Is(err, workflowx.ErrLoopMaxIterationsExceeded) {
|
||||
t.Fatalf("want ErrLoopMaxIterationsExceeded, got: %v", err)
|
||||
}
|
||||
v, err := state.GetVar("loop@counter")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
got, ok := v.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("counter: want float64, got %T: %v", v, v)
|
||||
}
|
||||
if got != 5 {
|
||||
t.Errorf("counter at cap: got %v, want 5 (maximum_loop_count)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_FactoryErrorSurfaces proves that a factory rejection of a
|
||||
// loop body member produces a cpn-scoped error from BuildWorkflow
|
||||
// (not a silent placeholder fallback or an opaque error from the
|
||||
// workflowx layer).
|
||||
//
|
||||
// VariableAssigner's factory rejects a non-list `variables` param
|
||||
// (see variable_assigner.go's Update). We trigger that by supplying
|
||||
// a string instead of a list.
|
||||
func TestLoop_FactoryErrorSurfaces(t *testing.T) {
|
||||
dsl := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"loop"},
|
||||
},
|
||||
"loop": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{},
|
||||
"loop_termination_condition": []any{},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"begin"},
|
||||
Downstream: []string{"bad"},
|
||||
},
|
||||
"bad": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": "not-a-list", // factory rejects this
|
||||
},
|
||||
},
|
||||
Upstream: []string{"loop"},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := Compile(context.Background(), dsl)
|
||||
if err == nil {
|
||||
t.Fatal("expected factory error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "bad") {
|
||||
t.Errorf("error should name the cpn_id 'bad'; got: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "VariableAssigner") {
|
||||
t.Errorf("error should name the component type 'VariableAssigner'; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_LegacyExitLoopStaysNoOp confirms that the DSL v1 sentinel
|
||||
// "ExitLoop" continues to compile as a no-op even when a factory is
|
||||
// registered (the legacy-no-op path takes precedence over factory
|
||||
// lookup). This is the protection against a future "ExitLoop" being
|
||||
// accidentally registered as a real component and changing behaviour
|
||||
// for v1 DSLs.
|
||||
func TestLoop_LegacyExitLoopStaysNoOp(t *testing.T) {
|
||||
dsl := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"exit"},
|
||||
},
|
||||
"exit": {
|
||||
Obj: CanvasComponentObj{ComponentName: "ExitLoop"},
|
||||
Upstream: []string{"begin"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := Compile(context.Background(), dsl); err != nil {
|
||||
t.Fatalf("Compile with legacy ExitLoop (factory registered): %v", err)
|
||||
}
|
||||
// Also verify the factory IS registered — otherwise this test
|
||||
// would be no different from the canvas-only TestBuildWorkflow_LegacyExitLoop.
|
||||
if runtime.DefaultFactory() == nil {
|
||||
t.Fatal("factory must be registered for this test to be meaningful")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_FactoryRegisteredInThisBinary is a sanity guard: if a
|
||||
// future refactor breaks the blank import in this file, the other
|
||||
// e2e tests would silently fall back to placeholder bodies and
|
||||
// pass for the wrong reason. This test fails loudly if the factory
|
||||
// is not installed.
|
||||
func TestLoop_FactoryRegisteredInThisBinary(t *testing.T) {
|
||||
if runtime.DefaultFactory() == nil {
|
||||
t.Fatal("runtime.DefaultFactory() is nil; the blank import of internal/agent/component is missing or broken")
|
||||
}
|
||||
}
|
||||
|
||||
// variableModeLoopDSL builds a Begin → VariableAssigner(seed) → Loop →
|
||||
// VariableAssigner(bump) DSL where the loop's counter is seeded from
|
||||
// the seed component's output via input_mode="variable". The loop
|
||||
// terminates when counter >= threshold; the bump node increments
|
||||
// counter by step each iteration.
|
||||
//
|
||||
// This is the regression test for the "input_mode=variable" loop
|
||||
// variable init bug: the init lambda must dereference the value
|
||||
// against the live CanvasState (state.GetVar) at init time, not
|
||||
// store the raw ref string. If the dereference is missing, counter
|
||||
// is seeded with the literal string "seed@initial" and the body's
|
||||
// `+=` operator fails with PARAMETER_NOT_NUMBER on the first
|
||||
// iteration — the loop terminates after a single body run with
|
||||
// counter=0 (or errors out).
|
||||
//
|
||||
// The seed uses VariableAssigner's `set` operator with an int
|
||||
// parameter (not `overwrite` with a {{literal}} — `overwrite` looks
|
||||
// the parameter up as a state ref, so a bare number would error with
|
||||
// PARAMETER_UNRESOLVED). `set` falls through to return the raw param
|
||||
// for non-string types, which is what we want here.
|
||||
func variableModeLoopDSL(threshold, step int) *Canvas {
|
||||
return &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"seed"},
|
||||
},
|
||||
"seed": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": []any{
|
||||
map[string]any{
|
||||
"variable": "seed@initial",
|
||||
"operator": "set",
|
||||
"parameter": 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"begin"},
|
||||
Downstream: []string{"loop"},
|
||||
},
|
||||
"loop": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"input_mode": "variable", // dereference against state
|
||||
"value": "seed@initial",
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": threshold,
|
||||
"input_mode": "constant",
|
||||
},
|
||||
},
|
||||
"logical_operator": "and",
|
||||
"maximum_loop_count": 50,
|
||||
},
|
||||
},
|
||||
Upstream: []string{"seed"},
|
||||
Downstream: []string{"bump"},
|
||||
},
|
||||
"bump": {
|
||||
Obj: CanvasComponentObj{
|
||||
ComponentName: "VariableAssigner",
|
||||
Params: map[string]any{
|
||||
"variables": []any{
|
||||
map[string]any{
|
||||
"variable": "loop@counter",
|
||||
"operator": "+=",
|
||||
"parameter": step,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Upstream: []string{"loop"},
|
||||
},
|
||||
},
|
||||
Path: []string{"begin", "loop"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_VariableModeInitDereferencesRef proves that the loop init
|
||||
// lambda actually dereferences input_mode="variable" refs against the
|
||||
// live CanvasState. Seed writes 5 to Outputs["seed"]["initial"]; the
|
||||
// loop's counter is initialised from "seed@initial" (a ref), so the
|
||||
// expected starting counter is 5. The bump node increments by 1 and
|
||||
// the loop terminates when counter >= 8. With correct resolution,
|
||||
// counter walks 5 → 6 → 7 → 8 (3 successful body runs) and stops.
|
||||
//
|
||||
// If the init lambda fails to dereference, counter is seeded with the
|
||||
// literal string "seed@initial" and `+= 1` fails on the first
|
||||
// iteration; the test would observe a counter of 0 (or a
|
||||
// PARAMETER_NOT_NUMBER error surfacing from bump).
|
||||
func TestLoop_VariableModeInitDereferencesRef(t *testing.T) {
|
||||
state, err := runLoopCanvas(t, variableModeLoopDSL(8, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
v, err := state.GetVar("loop@counter")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
got, ok := v.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("counter: want float64 (VariableAssigner += produces float64), got %T: %v — input_mode=variable init did not dereference the ref; the seed was written as the literal string %q instead of the resolved value", v, v, "seed@initial")
|
||||
}
|
||||
// 5 (resolved from seed@initial) + 1 + 1 + 1 = 8 (do-while: body
|
||||
// runs, THEN condition is checked). Threshold is 8, so the
|
||||
// condition fires after the 3rd body run, leaving counter=8.
|
||||
if got != 8 {
|
||||
t.Errorf("counter: got %v, want 8 (input_mode=variable should seed from seed@initial=5, then 3 increments to reach threshold)", got)
|
||||
}
|
||||
}
|
||||
755
internal/agent/canvas/loop_subgraph.go
Normal file
755
internal/agent/canvas/loop_subgraph.go
Normal file
@@ -0,0 +1,755 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// loop_subgraph.go — Loop macro expansion for BuildWorkflow.
|
||||
//
|
||||
// The RAGFlow DSL expresses a loop as a parent Loop component with a
|
||||
// chain of downstream body components. In the Go port we collapse this
|
||||
// to a SINGLE eino node by:
|
||||
// 1. Collecting the Loop's downstream descendants into a sub-graph
|
||||
// (a *compose.Workflow[map[string]any, map[string]any]).
|
||||
// 2. Prepending a synthetic "LoopInit" lambda that resolves the DSL's
|
||||
// `loop_variables` and writes them into the per-run CanvasState
|
||||
// under `state.Outputs[loopID][name]`, then passes the outer input
|
||||
// through.
|
||||
// 3. Translating the DSL's `loop_termination_condition` list into a
|
||||
// `workflowx.LoopCondition[map[string]any]` closure that reads the
|
||||
// same state slots via `state.GetVar` on every iteration.
|
||||
//
|
||||
// The actual installation into the outer graph is done by BuildWorkflow
|
||||
// (canvas.go) via workflowx.AddLoopNode, which registers the resulting
|
||||
// *WorkflowNode inside the outer *compose.Workflow.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/workflowx"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// loopExpansion holds the two artefacts produced by buildLoopExpansion
|
||||
// and consumed by BuildWorkflow to install the loop node.
|
||||
type loopExpansion struct {
|
||||
Sub *compose.Workflow[map[string]any, map[string]any]
|
||||
ShouldQuit workflowx.LoopCondition[map[string]any]
|
||||
MaxIters int
|
||||
Members map[string]bool // cpn_ids consumed by the sub-graph; caller skips these in the main pass.
|
||||
}
|
||||
|
||||
// buildLoopExpansion constructs the sub-workflow + termination condition
|
||||
// for the given Loop cpn. It does NOT touch the outer workflow — the
|
||||
// caller is responsible for installing the result via
|
||||
// workflowx.AddLoopNode and for skipping the members in the main
|
||||
// BuildWorkflow pass.
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// c — the parent Canvas (DSL representation).
|
||||
// loopID — the cpn_id of the Loop component being expanded.
|
||||
//
|
||||
// The returned `Members` is the set of cpn_ids that the expansion
|
||||
// consumed as body nodes. BuildWorkflow must skip these when iterating
|
||||
// `c.Components` in the main pass (they will be wired inside the
|
||||
// sub-graph, not the outer graph).
|
||||
func buildLoopExpansion(ctx context.Context, c *Canvas, loopID string) (*loopExpansion, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("canvas: nil canvas")
|
||||
}
|
||||
if loopID == "" {
|
||||
return nil, fmt.Errorf("canvas: buildLoopExpansion: empty loopID")
|
||||
}
|
||||
if _, ok := c.Components[loopID]; !ok {
|
||||
return nil, fmt.Errorf("canvas: buildLoopExpansion: unknown cpn %q", loopID)
|
||||
}
|
||||
|
||||
loopComp := c.Components[loopID]
|
||||
|
||||
members := collectDescendants(c, loopID)
|
||||
|
||||
initValues, err := resolveInitialVariables(loopComp.Obj.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
|
||||
}
|
||||
|
||||
shouldQuit, err := translateLoopCondition(loopID, loopComp.Obj.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
|
||||
}
|
||||
|
||||
maxIters := readMaxLoopCount(loopComp.Obj.Params)
|
||||
|
||||
sub, err := buildSubWorkflow(ctx, c, members, loopID, initValues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
|
||||
}
|
||||
|
||||
return &loopExpansion{
|
||||
Sub: sub,
|
||||
ShouldQuit: shouldQuit,
|
||||
MaxIters: maxIters,
|
||||
Members: members,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// collectDescendants returns the set of cpn_ids reachable from root via
|
||||
// downstream edges, NOT including root itself. The BFS stops at the
|
||||
// back-edge to root (i.e. a node whose Downstream contains root). This
|
||||
// prevents infinite recursion on cyclic graphs.
|
||||
func collectDescendants(c *Canvas, root string) map[string]bool {
|
||||
visited := make(map[string]bool)
|
||||
queue := []string{}
|
||||
for _, child := range c.Components[root].Downstream {
|
||||
if child == root {
|
||||
continue
|
||||
}
|
||||
if !visited[child] {
|
||||
visited[child] = true
|
||||
queue = append(queue, child)
|
||||
}
|
||||
}
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, child := range c.Components[cur].Downstream {
|
||||
if child == root || child == cur {
|
||||
continue
|
||||
}
|
||||
if !visited[child] {
|
||||
visited[child] = true
|
||||
queue = append(queue, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited
|
||||
}
|
||||
|
||||
// buildSubWorkflow constructs a fresh *compose.Workflow[map[string]any,
|
||||
// map[string]any] containing one node per member cpn, plus a synthetic
|
||||
// "LoopInit" entry node that seeds the loop variables into the per-run
|
||||
// state. Edges within the sub-graph mirror the canvas's Downstream
|
||||
// relations. The sub-workflow's START wires to LoopInit; the END wires
|
||||
// to whichever member has no downstream within the sub-graph (the
|
||||
// "tail" of the body).
|
||||
//
|
||||
// Body nodes are built through buildNodeBody so they share the same
|
||||
// legacy-no-op / factory / placeholder routing as the outer graph,
|
||||
// and receive the same statePre / statePost handlers so loop body
|
||||
// outputs land in CanvasState.Outputs alongside outer-node outputs.
|
||||
func buildSubWorkflow(
|
||||
ctx context.Context,
|
||||
c *Canvas,
|
||||
members map[string]bool,
|
||||
loopID string,
|
||||
initValues map[string]initVarSpec,
|
||||
) (*compose.Workflow[map[string]any, map[string]any], error) {
|
||||
_ = ctx
|
||||
sub := compose.NewWorkflow[map[string]any, map[string]any]()
|
||||
nodes := make(map[string]*compose.WorkflowNode, len(members)+1)
|
||||
|
||||
// Synthetic entry: writes loop variables into the per-run state
|
||||
// the FIRST TIME the sub-workflow runs, then returns the input
|
||||
// map unchanged. Subsequent iterations skip the seeding so the
|
||||
// body's mutations accumulate across iterations — otherwise a
|
||||
// VariableAssigner that increments `counter` would be clobbered
|
||||
// back to its initial value at the top of every iteration and
|
||||
// the loop could never terminate on a condition that watches the
|
||||
// counter.
|
||||
//
|
||||
// "First time" is detected by checking whether the loop's state
|
||||
// bucket already holds the variable: a missing bucket entry
|
||||
// (GetVar returns nil with no error) means the loop has not yet
|
||||
// seeded; any non-nil value means the body already wrote it on
|
||||
// a prior iteration. This is safe even for "zero-init" loop
|
||||
// variables (number→0, string→"") because Go's typed zero
|
||||
// values are non-nil when stored back through SetVar.
|
||||
//
|
||||
// input_mode dispatch (per agent/component/loop.py:60-77):
|
||||
// "constant" → use the literal value from the DSL
|
||||
// "variable" → dereference the value as a state ref via
|
||||
// state.GetVar; store the resolved value
|
||||
// (or nil if the ref is unresolvable — mirrors
|
||||
// Python's "treat as literal" fallback)
|
||||
// "" (zero) → use the type-derived zero value (resolved at
|
||||
// build time by resolveLoopVarValue)
|
||||
initNode := sub.AddLambdaNode(loopInitKey,
|
||||
compose.InvokableLambda(func(ctx context.Context, in map[string]any) (map[string]any, error) {
|
||||
state, _, err := GetStateFromContext[*CanvasState](ctx)
|
||||
if err != nil || state == nil {
|
||||
return in, nil
|
||||
}
|
||||
for k, spec := range initValues {
|
||||
existing, _ := state.GetVar(loopID + "@" + k)
|
||||
if existing != nil {
|
||||
continue
|
||||
}
|
||||
v := spec.Value
|
||||
if spec.InputMode == "variable" {
|
||||
ref, _ := spec.Value.(string)
|
||||
resolved, err := state.GetVar(ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: loop %q init: variable %q ref %q: %w", loopID, k, ref, err)
|
||||
}
|
||||
v = resolved
|
||||
}
|
||||
state.SetVar(loopID, k, v)
|
||||
}
|
||||
return in, nil
|
||||
}),
|
||||
)
|
||||
nodes[loopInitKey] = initNode
|
||||
|
||||
// Body nodes: each member becomes a real factory-built (or
|
||||
// placeholder, when no factory is registered) component invoke
|
||||
// wrapped by withStateBracket so it shares the same state
|
||||
// snapshot / result-persistence contract as outer-graph nodes.
|
||||
// We do NOT use eino's StatePreHandler / StatePostHandler here
|
||||
// because the sub-workflow has no WithGenLocalState of its own:
|
||||
// state flows in through ctx (runtime.WithState) attached by
|
||||
// the caller, and is read back via runtime.GetStateFromContext
|
||||
// inside withStateBracket. This is what lets a Loop body
|
||||
// actually mutate CanvasState (e.g. VariableAssigner
|
||||
// incrementing the loop counter) so the LoopCondition closure
|
||||
// can observe the change on the next iteration.
|
||||
for cpnID := range members {
|
||||
name := c.Components[cpnID].Obj.ComponentName
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("canvas: loop %q member %q has empty component_name", loopID, cpnID)
|
||||
}
|
||||
body, err := buildNodeBody(cpnID, name, c.Components[cpnID].Obj.Params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes[cpnID] = sub.AddLambdaNode(cpnID,
|
||||
compose.InvokableLambda[map[string]any, map[string]any](withStateBracket(body)),
|
||||
compose.WithNodeName(cpnID),
|
||||
)
|
||||
}
|
||||
|
||||
// Wire edges. The synthetic init node connects to every body node
|
||||
// that has no upstream within the sub-graph (the body's "entry"
|
||||
// nodes). For diamond / merge topologies within the body, we use
|
||||
// the same eino one-data-input rule as BuildWorkflow: the first
|
||||
// upstream carries data, the rest are exec-only AddDependency.
|
||||
for cpnID := range members {
|
||||
upstreams := c.Components[cpnID].Upstream
|
||||
first := true
|
||||
for _, up := range upstreams {
|
||||
if up == loopID {
|
||||
// Upstream is the parent Loop; in the sub-graph the
|
||||
// data source is the synthetic init node.
|
||||
if first {
|
||||
nodes[cpnID].AddInput(loopInitKey)
|
||||
first = false
|
||||
} else {
|
||||
nodes[cpnID].AddDependency(loopInitKey)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !members[up] {
|
||||
continue
|
||||
}
|
||||
if first {
|
||||
nodes[cpnID].AddInput(up)
|
||||
first = false
|
||||
} else {
|
||||
nodes[cpnID].AddDependency(up)
|
||||
}
|
||||
}
|
||||
if first {
|
||||
// No in-subgraph upstream: wire from init (this happens
|
||||
// for body entries whose only upstream in the DSL is the
|
||||
// Loop itself).
|
||||
nodes[cpnID].AddInput(loopInitKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire END: every member that has no downstream within the
|
||||
// sub-graph is a sub-graph terminal; wire sub.End() to it.
|
||||
hasDownstream := make(map[string]bool, len(members))
|
||||
for cpnID := range members {
|
||||
for _, down := range c.Components[cpnID].Downstream {
|
||||
if members[down] {
|
||||
hasDownstream[cpnID] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
hasEnd := false
|
||||
for cpnID := range members {
|
||||
if hasDownstream[cpnID] {
|
||||
continue
|
||||
}
|
||||
sub.End().AddInput(cpnID)
|
||||
hasEnd = true
|
||||
}
|
||||
if !hasEnd {
|
||||
// No body terminals — wire END to the init node so the
|
||||
// sub-workflow at least echoes the input once.
|
||||
sub.End().AddInput(loopInitKey)
|
||||
}
|
||||
|
||||
// Wire START. The synthetic init node is the sub-workflow's
|
||||
// entry; eino's Workflow requires every start node to be wired
|
||||
// from compose.START explicitly. The init node takes the
|
||||
// sub-workflow's input (the per-iteration `prev`) and seeds the
|
||||
// loop variables into state.
|
||||
initNode.AddInput(compose.START)
|
||||
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// loopInitKey is the synthetic cpn_id used for the LoopInit entry node
|
||||
// inside the sub-workflow. Using a reserved key avoids collisions with
|
||||
// user-defined cpn_ids.
|
||||
const loopInitKey = "__loop_init__"
|
||||
|
||||
// initVarSpec carries the per-variable info the init lambda needs to
|
||||
// decide how to seed the loop variable into the per-run state.
|
||||
//
|
||||
// For input_mode == "variable", Value is the ref string to dereference
|
||||
// at init time via state.GetVar; for "constant", Value is used as-is;
|
||||
// for "" (zero-init), Value is the type-derived zero (resolved at build
|
||||
// time by resolveLoopVarValue) and the init lambda stores it directly.
|
||||
type initVarSpec struct {
|
||||
Value any
|
||||
InputMode string
|
||||
}
|
||||
|
||||
// resolveInitialVariables applies the input_mode dispatch from
|
||||
// agent/component/loop.py:60-77 to a list of loop_variable entries.
|
||||
//
|
||||
// input_mode == "variable" → returns the ref string in Value
|
||||
// (the init lambda dereferences it at
|
||||
// runtime via state.GetVar; resolution
|
||||
// is deferred because this helper is
|
||||
// state-free).
|
||||
// input_mode == "constant" → Value is the literal value.
|
||||
// otherwise (zero-init) → Value is the type-based zero value.
|
||||
//
|
||||
// The init lambda (buildSubWorkflow) iterates the returned map and
|
||||
// writes each Value into the per-run state under
|
||||
// `state.Outputs[loopID][name]`. The "variable" dereference happens
|
||||
// there, in the lambda body, where the live CanvasState is available.
|
||||
func resolveInitialVariables(params map[string]any) (map[string]initVarSpec, error) {
|
||||
rawList, _ := params["loop_variables"].([]any)
|
||||
out := make(map[string]initVarSpec, len(rawList))
|
||||
for i, raw := range rawList {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("loop_variable[%d]: not a map", i)
|
||||
}
|
||||
name, inputMode, value, typ, err := readLoopVarFields(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := resolveLoopVarValue(inputMode, value, typ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loop_variable[%d] %q: %w", i, name, err)
|
||||
}
|
||||
out[name] = initVarSpec{Value: v, InputMode: inputMode}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readLoopVarFields(item map[string]any) (name, inputMode string, value, typ any, err error) {
|
||||
if item == nil {
|
||||
return "", "", nil, nil, fmt.Errorf("nil loop_variable entry")
|
||||
}
|
||||
vRaw, hasVar := item["variable"]
|
||||
imRaw, hasIM := item["input_mode"]
|
||||
valRaw, hasVal := item["value"]
|
||||
typeRaw, hasType := item["type"]
|
||||
|
||||
if !hasVar || vRaw == nil {
|
||||
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'variable')")
|
||||
}
|
||||
if !hasIM || imRaw == nil {
|
||||
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'input_mode')")
|
||||
}
|
||||
if !hasVal {
|
||||
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'value')")
|
||||
}
|
||||
if !hasType || typeRaw == nil {
|
||||
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'type')")
|
||||
}
|
||||
|
||||
name, _ = vRaw.(string)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%v", vRaw)
|
||||
}
|
||||
inputMode, _ = imRaw.(string)
|
||||
return name, inputMode, valRaw, typeRaw, nil
|
||||
}
|
||||
|
||||
func resolveLoopVarValue(inputMode string, value, typ any) (any, error) {
|
||||
switch inputMode {
|
||||
case "variable":
|
||||
// The "variable" path is handled at init time inside
|
||||
// buildSubWorkflow's init lambda, where the state is
|
||||
// available. Here we just return the ref string.
|
||||
return value, nil
|
||||
case "constant":
|
||||
return value, nil
|
||||
}
|
||||
return zeroValueForType(typ), nil
|
||||
}
|
||||
|
||||
// zeroValueForType implements the type→zero mapping from
|
||||
// agent/component/loop.py:65-76:
|
||||
//
|
||||
// number → 0
|
||||
// string → ""
|
||||
// boolean → false
|
||||
// object* → map[string]any{}
|
||||
// array* → []any{}
|
||||
// else → ""
|
||||
func zeroValueForType(typ any) any {
|
||||
s, _ := typ.(string)
|
||||
switch {
|
||||
case s == "number":
|
||||
return 0
|
||||
case s == "string":
|
||||
return ""
|
||||
case s == "boolean":
|
||||
return false
|
||||
case strings.HasPrefix(s, "object"):
|
||||
return map[string]any{}
|
||||
case strings.HasPrefix(s, "array"):
|
||||
return []any{}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// translateLoopCondition converts the DSL's loop_termination_condition
|
||||
// list into a workflowx.LoopCondition[map[string]any] closure.
|
||||
//
|
||||
// The closure reads each condition's variable via
|
||||
// `state.GetVar(loopID + "." + variable)` on every iteration, applies
|
||||
// the operator, and combines results via the configured logical
|
||||
// operator ("and" by default, "or" otherwise).
|
||||
//
|
||||
// The closure's per-iteration cost is one state lookup per condition —
|
||||
// no allocations once the conditions slice is captured.
|
||||
func translateLoopCondition(loopID string, params map[string]any) (workflowx.LoopCondition[map[string]any], error) {
|
||||
rawList, _ := params["loop_termination_condition"].([]any)
|
||||
conditions := make([]loopConditionSpec, 0, len(rawList))
|
||||
for i, raw := range rawList {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("loop_termination_condition[%d]: not a map", i)
|
||||
}
|
||||
variable, hasVar := m["variable"].(string)
|
||||
operator, hasOp := m["operator"].(string)
|
||||
if !hasVar || variable == "" {
|
||||
return nil, fmt.Errorf("loop_termination_condition[%d] is incomplete (missing 'variable')", i)
|
||||
}
|
||||
if !hasOp || operator == "" {
|
||||
return nil, fmt.Errorf("loop_termination_condition[%d] is incomplete (missing 'operator')", i)
|
||||
}
|
||||
inputMode, _ := m["input_mode"].(string)
|
||||
if inputMode == "" {
|
||||
inputMode = "constant"
|
||||
}
|
||||
conditions = append(conditions, loopConditionSpec{
|
||||
Variable: variable,
|
||||
Operator: operator,
|
||||
Value: m["value"],
|
||||
InputMode: inputMode,
|
||||
})
|
||||
}
|
||||
logicalOp, _ := params["logical_operator"].(string)
|
||||
if logicalOp == "" {
|
||||
logicalOp = "and"
|
||||
}
|
||||
if logicalOp != "and" && logicalOp != "or" {
|
||||
return nil, fmt.Errorf("invalid logical_operator %q (want 'and' or 'or')", logicalOp)
|
||||
}
|
||||
|
||||
return func(ctx context.Context, _ int, _, _ map[string]any) (bool, error) {
|
||||
// The condition is evaluated at the end of each iteration.
|
||||
// We need access to the per-run state to read loop variables
|
||||
// and other DSL variables. The workflowx lambda passes the
|
||||
// loop's outer context into this closure, so
|
||||
// canvas.GetStateFromContext works.
|
||||
state, _, err := GetStateFromContext[*CanvasState](ctx)
|
||||
if err != nil || state == nil {
|
||||
return false, fmt.Errorf("loop %q: condition eval: no canvas state in context", loopID)
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
// No conditions means the loop only stops at max count
|
||||
// — never quit on conditions. Mirrors Python fallback.
|
||||
return false, nil
|
||||
}
|
||||
// Vacuous starting value: true for AND, false for OR.
|
||||
combined := logicalOp == "and"
|
||||
for _, spec := range conditions {
|
||||
v, err := evalOneLoopCondition(state, loopID, spec)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if logicalOp == "or" {
|
||||
combined = combined || v
|
||||
} else {
|
||||
combined = combined && v
|
||||
}
|
||||
}
|
||||
return combined, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
type loopConditionSpec struct {
|
||||
Variable string
|
||||
Operator string
|
||||
Value any
|
||||
InputMode string // "constant" or "variable"
|
||||
}
|
||||
|
||||
// evalOneLoopCondition resolves a single condition entry. Mirrors
|
||||
// loopitem.py:128-142. Variable lookup is by full cpn_id path
|
||||
// ("loopID.varName" for loop variables, or whatever ref the DSL
|
||||
// supplies for state-level refs).
|
||||
func evalOneLoopCondition(state *CanvasState, loopID string, spec loopConditionSpec) (bool, error) {
|
||||
// Resolve the right-hand side value.
|
||||
var rhs any
|
||||
if spec.InputMode == "variable" {
|
||||
ref, _ := spec.Value.(string)
|
||||
v, err := state.GetVar(ref)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("loop %q: condition rhs ref %q: %w", loopID, ref, err)
|
||||
}
|
||||
rhs = v
|
||||
} else if spec.InputMode != "constant" {
|
||||
return false, fmt.Errorf("loop %q: invalid input mode %q", loopID, spec.InputMode)
|
||||
} else {
|
||||
rhs = spec.Value
|
||||
}
|
||||
// Resolve the variable being tested. The DSL stores either a bare
|
||||
// variable name (loop variable) or a full cpn_id@param ref. For
|
||||
// loop variables written by the init lambda, the bucket key is
|
||||
// "loopID" so the ref is "loopID@name". For arbitrary state refs,
|
||||
// the DSL passes the full path.
|
||||
ref := spec.Variable
|
||||
if !strings.Contains(ref, ".") && !strings.Contains(ref, "@") {
|
||||
// Bare name — assume it's a loop variable.
|
||||
ref = loopID + "@" + ref
|
||||
}
|
||||
got, err := state.GetVar(ref)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("loop %q: condition lhs ref %q: %w", loopID, ref, err)
|
||||
}
|
||||
return evaluateCondition(got, spec.Operator, rhs)
|
||||
}
|
||||
|
||||
// evaluateCondition is the type-dispatched operator logic that mirrors
|
||||
// loopitem.py:48-122. The operator set is the union of operators used
|
||||
// across all type branches — at runtime only the branches matching
|
||||
// the dynamic type of `var` are reachable.
|
||||
func evaluateCondition(varVal any, op string, value any) (bool, error) {
|
||||
switch v := varVal.(type) {
|
||||
case nil:
|
||||
if op == "empty" {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
case string:
|
||||
return evalStringOp(v, op, value)
|
||||
case bool:
|
||||
return evalBoolOp(v, op, value)
|
||||
case int:
|
||||
return evalNumberOp(float64(v), op, value)
|
||||
case int32:
|
||||
return evalNumberOp(float64(v), op, value)
|
||||
case int64:
|
||||
return evalNumberOp(float64(v), op, value)
|
||||
case float32:
|
||||
return evalNumberOp(float64(v), op, value)
|
||||
case float64:
|
||||
return evalNumberOp(v, op, value)
|
||||
case map[string]any:
|
||||
return evalDictOp(v, op, value)
|
||||
case []any:
|
||||
return evalListOp(v, op, value)
|
||||
}
|
||||
return false, fmt.Errorf("invalid operator: %s (variable type %T unsupported)", op, varVal)
|
||||
}
|
||||
|
||||
func evalStringOp(s, op string, value any) (bool, error) {
|
||||
switch op {
|
||||
case "contains":
|
||||
vs, _ := value.(string)
|
||||
return strings.Contains(s, vs), nil
|
||||
case "not contains":
|
||||
vs, _ := value.(string)
|
||||
return !strings.Contains(s, vs), nil
|
||||
case "start with":
|
||||
vs, _ := value.(string)
|
||||
return strings.HasPrefix(s, vs), nil
|
||||
case "end with":
|
||||
vs, _ := value.(string)
|
||||
return strings.HasSuffix(s, vs), nil
|
||||
case "is":
|
||||
return s == value, nil
|
||||
case "is not":
|
||||
return s != value, nil
|
||||
case "empty":
|
||||
return s == "", nil
|
||||
case "not empty":
|
||||
return s != "", nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid operator: %s (string variable)", op)
|
||||
}
|
||||
|
||||
func evalBoolOp(b bool, op string, value any) (bool, error) {
|
||||
switch op {
|
||||
case "is":
|
||||
vb, _ := value.(bool)
|
||||
return b == vb, nil
|
||||
case "is not":
|
||||
vb, _ := value.(bool)
|
||||
return b != vb, nil
|
||||
case "empty":
|
||||
// mirrors `var is None` for booleans
|
||||
return b == false && value == nil, nil
|
||||
case "not empty":
|
||||
return b == true || value != nil, nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid operator: %s (bool variable)", op)
|
||||
}
|
||||
|
||||
func evalNumberOp(n float64, op string, value any) (bool, error) {
|
||||
cmp, ok := toFloat(value)
|
||||
if !ok && !isNilOp(op) {
|
||||
return false, fmt.Errorf("invalid operator: %s (number variable, non-numeric value)", op)
|
||||
}
|
||||
switch op {
|
||||
case "=":
|
||||
return n == cmp, nil
|
||||
case "≠":
|
||||
return n != cmp, nil
|
||||
case ">":
|
||||
return n > cmp, nil
|
||||
case "<":
|
||||
return n < cmp, nil
|
||||
case "≥":
|
||||
return n >= cmp, nil
|
||||
case "≤":
|
||||
return n <= cmp, nil
|
||||
case "empty":
|
||||
return value == nil, nil
|
||||
case "not empty":
|
||||
return value != nil, nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid operator: %s (number variable)", op)
|
||||
}
|
||||
|
||||
func evalDictOp(m map[string]any, op string, _ any) (bool, error) {
|
||||
switch op {
|
||||
case "empty":
|
||||
return len(m) == 0, nil
|
||||
case "not empty":
|
||||
return len(m) > 0, nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid operator: %s (dict variable)", op)
|
||||
}
|
||||
|
||||
func evalListOp(lst []any, op string, value any) (bool, error) {
|
||||
switch op {
|
||||
case "contains":
|
||||
return listContains(lst, value), nil
|
||||
case "not contains":
|
||||
return !listContains(lst, value), nil
|
||||
case "is":
|
||||
return listEqual(lst, value), nil
|
||||
case "is not":
|
||||
return !listEqual(lst, value), nil
|
||||
case "empty":
|
||||
return len(lst) == 0, nil
|
||||
case "not empty":
|
||||
return len(lst) > 0, nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid operator: %s (list variable)", op)
|
||||
}
|
||||
|
||||
func listContains(lst []any, value any) bool {
|
||||
for _, x := range lst {
|
||||
if x == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func listEqual(lst []any, value any) bool {
|
||||
other, ok := value.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if len(lst) != len(other) {
|
||||
return false
|
||||
}
|
||||
for i := range lst {
|
||||
if lst[i] != other[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int32:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isNilOp(op string) bool {
|
||||
return op == "empty" || op == "not empty"
|
||||
}
|
||||
|
||||
// readMaxLoopCount returns the configured `maximum_loop_count` for the
|
||||
// Loop. 0 means "infinite" (no cap, only condition-driven termination).
|
||||
func readMaxLoopCount(params map[string]any) int {
|
||||
v, ok := params["maximum_loop_count"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case int32:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
829
internal/agent/canvas/loop_subgraph_test.go
Normal file
829
internal/agent/canvas/loop_subgraph_test.go
Normal file
@@ -0,0 +1,829 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// loop_subgraph_test.go — table-driven tests for the Loop macro
|
||||
// expansion helpers in loop_subgraph.go.
|
||||
//
|
||||
// Tests cover:
|
||||
// - collectDescendants (DAG and diamond shapes, back-edge handling)
|
||||
// - resolveInitialVariables (constant / zero-init / variable modes)
|
||||
// - zeroValueForType (number / string / boolean / object* / array* / unknown)
|
||||
// - readMaxLoopCount (missing, int, int64, float64)
|
||||
// - translateLoopCondition (single op, AND/OR, invalid logical_operator,
|
||||
// incomplete entries, empty conditions)
|
||||
// - evalOneLoopCondition + evaluateCondition (operator dispatch on
|
||||
// string / bool / number / dict / list / nil; the same operator
|
||||
// set as agent/component/loopitem.py:48-122)
|
||||
// - BuildWorkflow end-to-end (Loop + body, legacy ExitLoop no-op,
|
||||
// unknown component error path)
|
||||
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- collectDescendants ----
|
||||
|
||||
func TestCollectDescendants_DAG(t *testing.T) {
|
||||
// 4-node chain: loop -> a -> b -> c -> d (d has no downstream).
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop"},
|
||||
Downstream: []string{"a"}},
|
||||
"a": {Obj: CanvasComponentObj{ComponentName: "Message"},
|
||||
Upstream: []string{"loop"}, Downstream: []string{"b"}},
|
||||
"b": {Obj: CanvasComponentObj{ComponentName: "LLM"},
|
||||
Upstream: []string{"a"}, Downstream: []string{"c"}},
|
||||
"c": {Obj: CanvasComponentObj{ComponentName: "Categorize"},
|
||||
Upstream: []string{"b"}, Downstream: []string{"d"}},
|
||||
"d": {Obj: CanvasComponentObj{ComponentName: "Message"},
|
||||
Upstream: []string{"c"}},
|
||||
},
|
||||
}
|
||||
got := collectDescendants(c, "loop")
|
||||
want := map[string]bool{"a": true, "b": true, "c": true, "d": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for k := range want {
|
||||
if !got[k] {
|
||||
t.Errorf("missing %q in %v", k, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDescendants_Diamond(t *testing.T) {
|
||||
// loop -> a -> b -> d
|
||||
// \-> c -/
|
||||
// d is the join, must appear once.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop"},
|
||||
Downstream: []string{"a"}},
|
||||
"a": {Obj: CanvasComponentObj{ComponentName: "Message"},
|
||||
Upstream: []string{"loop"}, Downstream: []string{"b", "c"}},
|
||||
"b": {Obj: CanvasComponentObj{ComponentName: "LLM"},
|
||||
Upstream: []string{"a"}, Downstream: []string{"d"}},
|
||||
"c": {Obj: CanvasComponentObj{ComponentName: "Categorize"},
|
||||
Upstream: []string{"a"}, Downstream: []string{"d"}},
|
||||
"d": {Obj: CanvasComponentObj{ComponentName: "Message"},
|
||||
Upstream: []string{"b", "c"}},
|
||||
},
|
||||
}
|
||||
got := collectDescendants(c, "loop")
|
||||
want := map[string]bool{"a": true, "b": true, "c": true, "d": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for k := range want {
|
||||
if !got[k] {
|
||||
t.Errorf("missing %q in %v", k, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDescendants_BackEdgeStops(t *testing.T) {
|
||||
// loop -> a -> b -> loop (back-edge). BFS must not loop forever;
|
||||
// visited stops at the back-edge.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop"},
|
||||
Downstream: []string{"a"}},
|
||||
"a": {Obj: CanvasComponentObj{ComponentName: "Message"},
|
||||
Upstream: []string{"loop"}, Downstream: []string{"b"}},
|
||||
"b": {Obj: CanvasComponentObj{ComponentName: "LLM"},
|
||||
Upstream: []string{"a"}, Downstream: []string{"loop"}},
|
||||
},
|
||||
}
|
||||
got := collectDescendants(c, "loop")
|
||||
want := map[string]bool{"a": true, "b": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- resolveInitialVariables ----
|
||||
|
||||
func TestResolveInitialVariables_Constant(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"input_mode": "constant",
|
||||
"value": 7,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
}
|
||||
got, err := resolveInitialVariables(params)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInitialVariables: %v", err)
|
||||
}
|
||||
spec, ok := got["counter"]
|
||||
if !ok {
|
||||
t.Fatalf("counter: missing key in result map")
|
||||
}
|
||||
if spec.InputMode != "constant" {
|
||||
t.Errorf("counter: input_mode got %q, want \"constant\"", spec.InputMode)
|
||||
}
|
||||
if spec.Value != 7 {
|
||||
t.Errorf("counter: value got %v, want 7", spec.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInitialVariables_ZeroInit(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ string
|
||||
want any
|
||||
}{
|
||||
{"number", 0},
|
||||
{"string", ""},
|
||||
{"boolean", false},
|
||||
{"object", map[string]any{}},
|
||||
{"object<string>", map[string]any{}},
|
||||
{"array", []any{}},
|
||||
{"array<string>", []any{}},
|
||||
{"unknown-type", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
params := map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "v",
|
||||
"input_mode": "",
|
||||
"value": nil,
|
||||
"type": tc.typ,
|
||||
},
|
||||
},
|
||||
}
|
||||
got, err := resolveInitialVariables(params)
|
||||
if err != nil {
|
||||
t.Fatalf("typ %q: %v", tc.typ, err)
|
||||
}
|
||||
spec, ok := got["v"]
|
||||
if !ok {
|
||||
t.Fatalf("typ %q: missing key in result map", tc.typ)
|
||||
}
|
||||
// Special-case the untyped-empty value to skip the equal check
|
||||
// on slices/maps (reflect.DeepEqual semantics).
|
||||
if !valueEqual(spec.Value, tc.want) {
|
||||
t.Errorf("typ %q: got %v (%T), want %v (%T)", tc.typ, spec.Value, spec.Value, tc.want, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInitialVariables_VariablePassthrough(t *testing.T) {
|
||||
// "variable" mode's runtime dereference happens in the init lambda
|
||||
// (buildSubWorkflow). resolveInitialVariables is state-free, so it
|
||||
// just returns the ref string in Value plus the input_mode tag so
|
||||
// the init lambda knows to dereference.
|
||||
params := map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "x",
|
||||
"input_mode": "variable",
|
||||
"value": "Begin.foo",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
}
|
||||
got, err := resolveInitialVariables(params)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInitialVariables: %v", err)
|
||||
}
|
||||
spec, ok := got["x"]
|
||||
if !ok {
|
||||
t.Fatalf("x: missing key in result map")
|
||||
}
|
||||
if spec.InputMode != "variable" {
|
||||
t.Errorf("x: input_mode got %q, want \"variable\"", spec.InputMode)
|
||||
}
|
||||
if spec.Value != "Begin.foo" {
|
||||
t.Errorf("x: value got %v, want \"Begin.foo\"", spec.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInitialVariables_Incomplete(t *testing.T) {
|
||||
cases := []map[string]any{
|
||||
// missing 'variable'
|
||||
{"input_mode": "constant", "value": 1, "type": "number"},
|
||||
// missing 'input_mode'
|
||||
{"variable": "x", "value": 1, "type": "number"},
|
||||
// missing 'value'
|
||||
{"variable": "x", "input_mode": "constant", "type": "number"},
|
||||
// missing 'type'
|
||||
{"variable": "x", "input_mode": "constant", "value": 1},
|
||||
}
|
||||
for i, item := range cases {
|
||||
params := map[string]any{"loop_variables": []any{item}}
|
||||
if _, err := resolveInitialVariables(params); err == nil {
|
||||
t.Errorf("case %d: expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- zeroValueForType ----
|
||||
|
||||
func TestZeroValueForType(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ any
|
||||
want any
|
||||
}{
|
||||
{"number", 0},
|
||||
{"string", ""},
|
||||
{"boolean", false},
|
||||
{"object", map[string]any{}},
|
||||
{"object<string>", map[string]any{}},
|
||||
{"array", []any{}},
|
||||
{"array<string>", []any{}},
|
||||
{"weird", ""},
|
||||
{nil, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := zeroValueForType(tc.typ)
|
||||
if !valueEqual(got, tc.want) {
|
||||
t.Errorf("typ %v: got %v, want %v", tc.typ, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- readMaxLoopCount ----
|
||||
|
||||
func TestReadMaxLoopCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
want int
|
||||
}{
|
||||
{"missing", map[string]any{}, 0},
|
||||
{"int", map[string]any{"maximum_loop_count": 5}, 5},
|
||||
{"int64", map[string]any{"maximum_loop_count": int64(7)}, 7},
|
||||
{"float64", map[string]any{"maximum_loop_count": 3.0}, 3},
|
||||
{"string", map[string]any{"maximum_loop_count": "5"}, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := readMaxLoopCount(tc.in); got != tc.want {
|
||||
t.Errorf("%s: got %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- translateLoopCondition ----
|
||||
|
||||
func TestTranslateLoopCondition_SingleOp(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"logical_operator": "and",
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": 3,
|
||||
"input_mode": "constant",
|
||||
},
|
||||
},
|
||||
}
|
||||
cond, err := translateLoopCondition("loop_0", params)
|
||||
if err != nil {
|
||||
t.Fatalf("translateLoopCondition: %v", err)
|
||||
}
|
||||
state := NewCanvasState("", "")
|
||||
state.SetVar("loop_0", "counter", 3)
|
||||
ctx := WithState(context.Background(), state)
|
||||
quit, err := cond(ctx, 3, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cond: %v", err)
|
||||
}
|
||||
if !quit {
|
||||
t.Errorf("expected quit when counter=3 >= 3")
|
||||
}
|
||||
// counter=2 should NOT quit.
|
||||
state2 := NewCanvasState("", "")
|
||||
state2.SetVar("loop_0", "counter", 2)
|
||||
ctx2 := WithState(context.Background(), state2)
|
||||
quit, err = cond(ctx2, 2, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cond: %v", err)
|
||||
}
|
||||
if quit {
|
||||
t.Errorf("expected no-quit when counter=2 < 3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateLoopCondition_OrQuitsEarly(t *testing.T) {
|
||||
// Two conditions OR'd. quits as soon as one is true.
|
||||
params := map[string]any{
|
||||
"logical_operator": "or",
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{"variable": "a", "operator": "=", "value": 1, "input_mode": "constant"},
|
||||
map[string]any{"variable": "b", "operator": "=", "value": 2, "input_mode": "constant"},
|
||||
},
|
||||
}
|
||||
cond, err := translateLoopCondition("L", params)
|
||||
if err != nil {
|
||||
t.Fatalf("translate: %v", err)
|
||||
}
|
||||
// a=1, b=0 → quits (first condition true).
|
||||
state := NewCanvasState("", "")
|
||||
state.SetVar("L", "a", 1)
|
||||
state.SetVar("L", "b", 0)
|
||||
quit, err := cond(WithState(context.Background(), state), 1, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cond: %v", err)
|
||||
}
|
||||
if !quit {
|
||||
t.Errorf("OR with a=1 should quit")
|
||||
}
|
||||
// a=0, b=2 → quits (second condition true).
|
||||
state2 := NewCanvasState("", "")
|
||||
state2.SetVar("L", "a", 0)
|
||||
state2.SetVar("L", "b", 2)
|
||||
quit, err = cond(WithState(context.Background(), state2), 1, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cond: %v", err)
|
||||
}
|
||||
if !quit {
|
||||
t.Errorf("OR with b=2 should quit")
|
||||
}
|
||||
// a=0, b=0 → no quit.
|
||||
state3 := NewCanvasState("", "")
|
||||
state3.SetVar("L", "a", 0)
|
||||
state3.SetVar("L", "b", 0)
|
||||
quit, err = cond(WithState(context.Background(), state3), 1, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cond: %v", err)
|
||||
}
|
||||
if quit {
|
||||
t.Errorf("OR with both 0 should not quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateLoopCondition_AndRequiresAll(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{"variable": "a", "operator": "=", "value": 1, "input_mode": "constant"},
|
||||
map[string]any{"variable": "b", "operator": "=", "value": 2, "input_mode": "constant"},
|
||||
},
|
||||
}
|
||||
cond, err := translateLoopCondition("L", params)
|
||||
if err != nil {
|
||||
t.Fatalf("translate: %v", err)
|
||||
}
|
||||
// a=1, b=2 → quits.
|
||||
state := NewCanvasState("", "")
|
||||
state.SetVar("L", "a", 1)
|
||||
state.SetVar("L", "b", 2)
|
||||
quit, _ := cond(WithState(context.Background(), state), 1, nil, nil)
|
||||
if !quit {
|
||||
t.Errorf("AND with both true should quit")
|
||||
}
|
||||
// a=1, b=0 → no quit (default logical_op is "and").
|
||||
state2 := NewCanvasState("", "")
|
||||
state2.SetVar("L", "a", 1)
|
||||
state2.SetVar("L", "b", 0)
|
||||
quit, _ = cond(WithState(context.Background(), state2), 1, nil, nil)
|
||||
if quit {
|
||||
t.Errorf("AND with one false should not quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateLoopCondition_EmptyConditionsNeverQuit(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"logical_operator": "and",
|
||||
}
|
||||
cond, err := translateLoopCondition("L", params)
|
||||
if err != nil {
|
||||
t.Fatalf("translate: %v", err)
|
||||
}
|
||||
state := NewCanvasState("", "")
|
||||
quit, err := cond(WithState(context.Background(), state), 1, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cond: %v", err)
|
||||
}
|
||||
if quit {
|
||||
t.Errorf("empty conditions must never quit (max count is the only terminator)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateLoopCondition_InvalidLogicalOp(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"logical_operator": "xor",
|
||||
}
|
||||
if _, err := translateLoopCondition("L", params); err == nil {
|
||||
t.Errorf("expected error on invalid logical_operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateLoopCondition_IncompleteEntry(t *testing.T) {
|
||||
cases := []map[string]any{
|
||||
{"operator": "=", "value": 1}, // missing variable
|
||||
{"variable": "x"}, // missing operator
|
||||
{"variable": "x", "operator": ""}, // empty operator
|
||||
}
|
||||
for i, item := range cases {
|
||||
params := map[string]any{
|
||||
"loop_termination_condition": []any{item},
|
||||
}
|
||||
if _, err := translateLoopCondition("L", params); err == nil {
|
||||
t.Errorf("case %d: expected error on incomplete entry", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateLoopCondition_VariableInputMode(t *testing.T) {
|
||||
// condition's value input_mode is "variable" → resolve the value ref
|
||||
// from state before applying the operator.
|
||||
params := map[string]any{
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": "Begin@threshold",
|
||||
"input_mode": "variable",
|
||||
},
|
||||
},
|
||||
}
|
||||
cond, err := translateLoopCondition("L", params)
|
||||
if err != nil {
|
||||
t.Fatalf("translate: %v", err)
|
||||
}
|
||||
state := NewCanvasState("", "")
|
||||
state.SetVar("L", "counter", 10)
|
||||
state.SetVar("Begin", "threshold", 5)
|
||||
quit, _ := cond(WithState(context.Background(), state), 1, nil, nil)
|
||||
if !quit {
|
||||
t.Errorf("counter(10) >= threshold(5) should quit")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- evaluateCondition operator dispatch ----
|
||||
|
||||
func TestEvaluateCondition_StringOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{"contains", "ell", true},
|
||||
{"not contains", "zzz", true},
|
||||
{"start with", "hel", true},
|
||||
{"end with", "llo", true},
|
||||
{"is", "hello", true},
|
||||
{"is not", "world", true},
|
||||
{"empty", nil, false}, // "hello" != ""
|
||||
{"not empty", nil, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := evaluateCondition("hello", tc.op, tc.value)
|
||||
if err != nil {
|
||||
t.Errorf("op=%s: %v", tc.op, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("op=%s: got %v, want %v", tc.op, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCondition_NumberOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{"=", 5, true},
|
||||
{"≠", 6, true},
|
||||
{">", 4, true},
|
||||
{"<", 6, true},
|
||||
{"≥", 5, true},
|
||||
{"≤", 5, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := evaluateCondition(5, tc.op, tc.value)
|
||||
if err != nil {
|
||||
t.Errorf("op=%s: %v", tc.op, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("op=%s: got %v, want %v", tc.op, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCondition_InvalidOp(t *testing.T) {
|
||||
if _, err := evaluateCondition("hello", "bogus", "x"); err == nil {
|
||||
t.Errorf("expected error on unknown operator")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- BuildWorkflow end-to-end (with a Loop cpn) ----
|
||||
|
||||
func TestBuildWorkflow_LoopInstallsOneNode(t *testing.T) {
|
||||
// DSL: Begin -> Loop -> Message
|
||||
// The Loop has no real body, so its sub-graph is just the
|
||||
// synthetic init lambda. The outer workflow should have 3
|
||||
// eino nodes total: Begin, the loop node, Message.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"loop"}},
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{},
|
||||
}},
|
||||
Upstream: []string{"begin"}, Downstream: []string{"msg"}},
|
||||
"msg": {Obj: CanvasComponentObj{ComponentName: "Message"},
|
||||
Upstream: []string{"loop"}},
|
||||
},
|
||||
}
|
||||
if _, err := BuildWorkflow(context.Background(), c); err != nil {
|
||||
t.Fatalf("BuildWorkflow: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflow_LegacyExitLoop(t *testing.T) {
|
||||
// DSL with a standalone "ExitLoop" node. The Go port has no
|
||||
// implementation for it, but legacyNoOpNames accepts it as a
|
||||
// no-op echo node. BuildWorkflow must succeed.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"exit"}},
|
||||
"exit": {Obj: CanvasComponentObj{ComponentName: "ExitLoop"},
|
||||
Upstream: []string{"begin"}},
|
||||
},
|
||||
}
|
||||
if _, err := BuildWorkflow(context.Background(), c); err != nil {
|
||||
t.Fatalf("BuildWorkflow with ExitLoop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflow_UnknownComponentErrors(t *testing.T) {
|
||||
// A component name that is neither in legacyNoOpNames nor in the
|
||||
// Phase 1 primitive allowlist must produce a clear error from
|
||||
// BuildWorkflow. Silent acceptance would mask DSL typos until the
|
||||
// workflow failed at runtime.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"bogus"}},
|
||||
"bogus": {Obj: CanvasComponentObj{ComponentName: "FakeComponent"},
|
||||
Upstream: []string{"begin"}},
|
||||
},
|
||||
}
|
||||
_, err := BuildWorkflow(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on unknown component name, got nil")
|
||||
}
|
||||
// The error must mention the cpn_id AND the offending name so the
|
||||
// orchestrator can surface an actionable diagnostic.
|
||||
if !strings.Contains(err.Error(), "bogus") || !strings.Contains(err.Error(), "FakeComponent") {
|
||||
t.Errorf("error should name both cpn and component; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflow_EmptyComponentNameErrors(t *testing.T) {
|
||||
// A component with an empty component_name is a DSL bug. BuildWorkflow
|
||||
// must reject it rather than passing through to the placeholder path.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"empty"}},
|
||||
"empty": {Obj: CanvasComponentObj{ComponentName: ""},
|
||||
Upstream: []string{"begin"}},
|
||||
},
|
||||
}
|
||||
_, err := BuildWorkflow(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on empty component_name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflow_LoopSharesOuterCanvasState(t *testing.T) {
|
||||
// State-sharing contract: the Loop's sub-graph and the outer
|
||||
// workflow must operate on the SAME *CanvasState instance. eino
|
||||
// nests Workflows by composition — if the outer's WithGenLocalState
|
||||
// is bypassed at the lambda boundary, the sub-workflow would not
|
||||
// see loop variables and the loop could never terminate.
|
||||
//
|
||||
// The buildSubWorkflow init lambda writes
|
||||
// state.Outputs[loopID][varName]; the LoopCondition closure
|
||||
// reads the same slot via state.GetVar. For this to round-trip
|
||||
// the two paths must share the same *CanvasState.
|
||||
//
|
||||
// We verify the contract at two levels:
|
||||
//
|
||||
// 1. structural: buildLoopExpansion / buildSubWorkflow must
|
||||
// not clone or shadow state in their helpers, and the
|
||||
// returned sub-workflow must be non-nil.
|
||||
// 2. runtime: we attach a *CanvasState to ctx via WithState,
|
||||
// replay the init lambda's body manually (it is a single
|
||||
// GetStateFromContext + SetVar pair), and read it back via
|
||||
// GetVar to confirm the SAME instance is observable from
|
||||
// both sides.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"loop"}},
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"input_mode": "constant",
|
||||
"value": 0,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": 3,
|
||||
"input_mode": "constant",
|
||||
},
|
||||
},
|
||||
}},
|
||||
Upstream: []string{"begin"}},
|
||||
},
|
||||
}
|
||||
exp, err := buildLoopExpansion(context.Background(), c, "loop")
|
||||
if err != nil {
|
||||
t.Fatalf("buildLoopExpansion: %v", err)
|
||||
}
|
||||
if exp.Sub == nil {
|
||||
t.Fatal("sub-workflow is nil")
|
||||
}
|
||||
// Empty body — the loop has no descendants, so Members is empty
|
||||
// and MaxIters defaults to 0 (= unbounded, condition-driven).
|
||||
if exp.Members["begin"] {
|
||||
t.Errorf("'begin' should NOT be a member of the loop's sub-graph")
|
||||
}
|
||||
if exp.MaxIters != 0 {
|
||||
t.Errorf("MaxIters: got %d, want 0 (default = unbounded)", exp.MaxIters)
|
||||
}
|
||||
|
||||
// Runtime contract: attach a state to ctx, run the same
|
||||
// GetStateFromContext + SetVar sequence the init lambda
|
||||
// performs, and confirm the mutation is visible to a
|
||||
// LoopCondition-style reader on the SAME *CanvasState.
|
||||
state := NewCanvasState("run-1", "task-1")
|
||||
ctx := WithState(context.Background(), state)
|
||||
|
||||
got, _, err := GetStateFromContext[*CanvasState](ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStateFromContext: %v", err)
|
||||
}
|
||||
if got != state {
|
||||
t.Errorf("GetStateFromContext returned a different *CanvasState instance")
|
||||
}
|
||||
// The init lambda writes "loop@counter" = 0.
|
||||
got.SetVar("loop", "counter", 0)
|
||||
// A LoopCondition closure would read it back via state.GetVar.
|
||||
v, err := state.GetVar("loop@counter")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
if v != 0 {
|
||||
t.Errorf("counter: got %v, want 0 (init lambda should seed it)", v)
|
||||
}
|
||||
// The reader and writer MUST be the same instance — a clone
|
||||
// would mean the loop's "update counter, check counter" cycle
|
||||
// would never converge.
|
||||
if got != state {
|
||||
t.Errorf("state was cloned somewhere — writer and reader see different instances")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflow_LoopWithBody(t *testing.T) {
|
||||
// DSL: Begin -> Loop -> A -> B
|
||||
// A and B are body members of the Loop's sub-graph.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"loop"}},
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"input_mode": "constant",
|
||||
"value": 0,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{
|
||||
"variable": "counter",
|
||||
"operator": "≥",
|
||||
"value": 3,
|
||||
"input_mode": "constant",
|
||||
},
|
||||
},
|
||||
"maximum_loop_count": 10,
|
||||
}},
|
||||
Upstream: []string{"begin"}, Downstream: []string{"a"}},
|
||||
"a": {Obj: CanvasComponentObj{ComponentName: "Message"},
|
||||
Upstream: []string{"loop"}, Downstream: []string{"b"}},
|
||||
"b": {Obj: CanvasComponentObj{ComponentName: "LLM"},
|
||||
Upstream: []string{"a"}},
|
||||
},
|
||||
}
|
||||
if _, err := BuildWorkflow(context.Background(), c); err != nil {
|
||||
t.Fatalf("BuildWorkflow: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflow_LoopMissingParams(t *testing.T) {
|
||||
// A Loop with no params at all — empty loop_variables and empty
|
||||
// loop_termination_condition. The macro expansion should still
|
||||
// succeed (the condition closure becomes a never-quit predicate,
|
||||
// the init lambda writes nothing).
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"loop"}},
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop",
|
||||
Params: map[string]any{}},
|
||||
Upstream: []string{"begin"}},
|
||||
},
|
||||
}
|
||||
if _, err := BuildWorkflow(context.Background(), c); err != nil {
|
||||
t.Fatalf("BuildWorkflow: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflow_LoopIncompleteCondition(t *testing.T) {
|
||||
// A Loop with a malformed condition entry. BuildWorkflow must
|
||||
// surface the error from translateLoopCondition.
|
||||
c := &Canvas{
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin": {Obj: CanvasComponentObj{ComponentName: "Begin"},
|
||||
Downstream: []string{"loop"}},
|
||||
"loop": {Obj: CanvasComponentObj{ComponentName: "Loop",
|
||||
Params: map[string]any{
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{"operator": "=", "value": 1}, // missing variable
|
||||
},
|
||||
}},
|
||||
Upstream: []string{"begin"}},
|
||||
},
|
||||
}
|
||||
if _, err := BuildWorkflow(context.Background(), c); err == nil {
|
||||
t.Errorf("expected error on incomplete condition")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- valueEqual: reflect.DeepEqual except for untyped nil vs typed nil ----
|
||||
|
||||
func valueEqual(a, b any) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
// Use type-aware comparison for maps and slices to handle the
|
||||
// case where one side is nil-typed and the other is the zero
|
||||
// value.
|
||||
switch av := a.(type) {
|
||||
case map[string]any:
|
||||
bv, ok := b.(map[string]any)
|
||||
if !ok || len(av) != len(bv) {
|
||||
return false
|
||||
}
|
||||
for k, v := range av {
|
||||
if !valueEqual(v, bv[k]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case []any:
|
||||
bv, ok := b.([]any)
|
||||
if !ok || len(av) != len(bv) {
|
||||
return false
|
||||
}
|
||||
for i := range av {
|
||||
if !valueEqual(av[i], bv[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
190
internal/agent/canvas/node_body.go
Normal file
190
internal/agent/canvas/node_body.go
Normal file
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
151
internal/agent/canvas/run_tracker.go
Normal file
151
internal/agent/canvas/run_tracker.go
Normal file
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// run_tracker.go persists canvas-run business metadata to a Redis Hash.
|
||||
// See plan §2.6 (Key 2: "agent:run:{run_id}"). This is the *business*
|
||||
// channel — checkpoint payload (eino bytes) lives in checkpoint_store.go.
|
||||
//
|
||||
// Status code mapping (stored as int under the "status" field):
|
||||
//
|
||||
// 0 = running, 1 = succeeded, 2 = failed, 3 = cancelled.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"ragflow/internal/cache"
|
||||
)
|
||||
|
||||
// runKeyPrefix is the Redis Hash key namespace for run metadata.
|
||||
// The full key is "agent:run:{run_id}".
|
||||
const runKeyPrefix = "agent:run:"
|
||||
|
||||
// runStatus values for the "status" hash field.
|
||||
const (
|
||||
runStatusRunning = "0"
|
||||
runStatusSucceeded = "1"
|
||||
runStatusFailed = "2"
|
||||
runStatusCancelled = "3"
|
||||
)
|
||||
|
||||
func runKey(runID string) string { return runKeyPrefix + runID }
|
||||
|
||||
// RunTracker manages canvas-run metadata (canvas_id, status, checkpoint
|
||||
// link, resume chain, ...) on a Redis Hash. Operations are explicit — the
|
||||
// eino CheckPointStore does NOT write these fields, so callers (HTTP
|
||||
// handler, cancel watcher) must invoke Start/Mark* at the right points.
|
||||
type RunTracker struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewRunTracker returns a tracker wired to the global Redis client. When
|
||||
// the cache is uninitialized, client is nil; methods error in that case
|
||||
// rather than panicking, and tests can inject a client via struct-literal
|
||||
// construction.
|
||||
func NewRunTracker(ttl time.Duration) *RunTracker {
|
||||
var client *redis.Client
|
||||
if rc := cache.Get(); rc != nil {
|
||||
client = rc.GetClient()
|
||||
}
|
||||
return &RunTracker{client: client, ttl: ttl}
|
||||
}
|
||||
|
||||
// Start records a new run as in-progress. canvasID and tenantID identify
|
||||
// the source DSL and tenant; parentRunID may be empty for fresh runs and
|
||||
// carries the source run-id for resume chains (R1 in plan §2.6).
|
||||
//
|
||||
// The HSet + Expire are sent through a pipeline so a TTL is set on the
|
||||
// first write — without that, the key would have no expiry and a crashed
|
||||
// run would leak the hash.
|
||||
func (t *RunTracker) Start(ctx context.Context, runID, canvasID, tenantID, parentRunID string) error {
|
||||
if t == nil || t.client == nil {
|
||||
return errors.New("run tracker: redis client not initialized")
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
key := runKey(runID)
|
||||
pipe := t.client.Pipeline()
|
||||
pipe.HSet(ctx, key, map[string]any{
|
||||
"canvas_id": canvasID,
|
||||
"tenant_id": tenantID,
|
||||
"parent_run_id": parentRunID,
|
||||
"status": runStatusRunning,
|
||||
"cancel_requested": 0,
|
||||
"started_at": now,
|
||||
})
|
||||
pipe.Expire(ctx, key, t.ttl)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// AttachCheckpoint writes the latest checkpoint id for this run. It is the
|
||||
// ONLY writer of the "checkpoint_id" field; every W1/W2/W3/W4 path (plan
|
||||
// §2.6) must call this once before the run goroutine returns.
|
||||
func (t *RunTracker) AttachCheckpoint(ctx context.Context, runID, checkpointID string) error {
|
||||
if t == nil || t.client == nil {
|
||||
return errors.New("run tracker: redis client not initialized")
|
||||
}
|
||||
return t.client.HSet(ctx, runKey(runID), "checkpoint_id", checkpointID).Err()
|
||||
}
|
||||
|
||||
// MarkSucceeded transitions the run to status=1 and stamps finished_at.
|
||||
func (t *RunTracker) MarkSucceeded(ctx context.Context, runID string) error {
|
||||
if t == nil || t.client == nil {
|
||||
return errors.New("run tracker: redis client not initialized")
|
||||
}
|
||||
return t.client.HSet(ctx, runKey(runID),
|
||||
"status", runStatusSucceeded,
|
||||
"finished_at", time.Now().UnixMilli(),
|
||||
).Err()
|
||||
}
|
||||
|
||||
// MarkFailed transitions the run to status=2 and records the reason.
|
||||
func (t *RunTracker) MarkFailed(ctx context.Context, runID, reason string) error {
|
||||
if t == nil || t.client == nil {
|
||||
return errors.New("run tracker: redis client not initialized")
|
||||
}
|
||||
return t.client.HSet(ctx, runKey(runID),
|
||||
"status", runStatusFailed,
|
||||
"finished_at", time.Now().UnixMilli(),
|
||||
"failure_reason", reason,
|
||||
).Err()
|
||||
}
|
||||
|
||||
// MarkCancelled transitions the run to status=3 and sets the cancel flag.
|
||||
func (t *RunTracker) MarkCancelled(ctx context.Context, runID string) error {
|
||||
if t == nil || t.client == nil {
|
||||
return errors.New("run tracker: redis client not initialized")
|
||||
}
|
||||
return t.client.HSet(ctx, runKey(runID),
|
||||
"status", runStatusCancelled,
|
||||
"finished_at", time.Now().UnixMilli(),
|
||||
"cancel_requested", 1,
|
||||
).Err()
|
||||
}
|
||||
|
||||
// Get returns all hash fields for a run. The empty map (not nil) plus a
|
||||
// nil error means "no such run" — callers can detect this with len(map)==0
|
||||
// if they need to distinguish from a key that exists with no fields.
|
||||
func (t *RunTracker) Get(ctx context.Context, runID string) (map[string]string, error) {
|
||||
if t == nil || t.client == nil {
|
||||
return nil, errors.New("run tracker: redis client not initialized")
|
||||
}
|
||||
return t.client.HGetAll(ctx, runKey(runID)).Result()
|
||||
}
|
||||
190
internal/agent/canvas/run_tracker_test.go
Normal file
190
internal/agent/canvas/run_tracker_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// 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 canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func newTestTracker(t *testing.T, ttl time.Duration) (*RunTracker, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run: %v", err)
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
return &RunTracker{client: client, ttl: ttl}, mr
|
||||
}
|
||||
|
||||
func TestRunTracker_StateTransitions(t *testing.T) {
|
||||
tracker, mr := newTestTracker(t, 30*24*time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
// B1: Start
|
||||
if err := tracker.Start(ctx, "run_1", "canvas_42", "tenant_a", ""); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
got, err := tracker.Get(ctx, "run_1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Start: %v", err)
|
||||
}
|
||||
if got["canvas_id"] != "canvas_42" {
|
||||
t.Fatalf("canvas_id = %q, want %q", got["canvas_id"], "canvas_42")
|
||||
}
|
||||
if got["tenant_id"] != "tenant_a" {
|
||||
t.Fatalf("tenant_id = %q, want %q", got["tenant_id"], "tenant_a")
|
||||
}
|
||||
if got["status"] != "0" {
|
||||
t.Fatalf("status after Start = %q, want 0 (running)", got["status"])
|
||||
}
|
||||
if got["cancel_requested"] != "0" {
|
||||
t.Fatalf("cancel_requested = %q, want 0", got["cancel_requested"])
|
||||
}
|
||||
if _, err := strconv.ParseInt(got["started_at"], 10, 64); err != nil {
|
||||
t.Fatalf("started_at %q is not an int: %v", got["started_at"], err)
|
||||
}
|
||||
// TTL was applied via the Start pipeline.
|
||||
if d := mr.TTL(runKey("run_1")); d != 30*24*time.Hour {
|
||||
t.Fatalf("TTL after Start = %v, want 30d", d)
|
||||
}
|
||||
|
||||
// AttachCheckpoint
|
||||
if err := tracker.AttachCheckpoint(ctx, "run_1", "cpn_xyz"); err != nil {
|
||||
t.Fatalf("AttachCheckpoint: %v", err)
|
||||
}
|
||||
got, _ = tracker.Get(ctx, "run_1")
|
||||
if got["checkpoint_id"] != "cpn_xyz" {
|
||||
t.Fatalf("checkpoint_id = %q, want %q", got["checkpoint_id"], "cpn_xyz")
|
||||
}
|
||||
|
||||
// B2: MarkSucceeded
|
||||
if err := tracker.MarkSucceeded(ctx, "run_1"); err != nil {
|
||||
t.Fatalf("MarkSucceeded: %v", err)
|
||||
}
|
||||
got, _ = tracker.Get(ctx, "run_1")
|
||||
if got["status"] != "1" {
|
||||
t.Fatalf("status = %q, want 1 (succeeded)", got["status"])
|
||||
}
|
||||
if _, err := strconv.ParseInt(got["finished_at"], 10, 64); err != nil {
|
||||
t.Fatalf("finished_at %q is not an int: %v", got["finished_at"], err)
|
||||
}
|
||||
// All previous fields preserved.
|
||||
if got["canvas_id"] != "canvas_42" || got["checkpoint_id"] != "cpn_xyz" {
|
||||
t.Fatalf("fields dropped: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTracker_FailedAndCancelled(t *testing.T) {
|
||||
tracker, _ := newTestTracker(t, time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
// B3: MarkFailed
|
||||
if err := tracker.Start(ctx, "run_fail", "c", "t", "run_parent"); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := tracker.MarkFailed(ctx, "run_fail", "boom: nil deref"); err != nil {
|
||||
t.Fatalf("MarkFailed: %v", err)
|
||||
}
|
||||
got, _ := tracker.Get(ctx, "run_fail")
|
||||
if got["status"] != "2" {
|
||||
t.Fatalf("status = %q, want 2 (failed)", got["status"])
|
||||
}
|
||||
if got["failure_reason"] != "boom: nil deref" {
|
||||
t.Fatalf("failure_reason = %q, want %q", got["failure_reason"], "boom: nil deref")
|
||||
}
|
||||
if got["parent_run_id"] != "run_parent" {
|
||||
t.Fatalf("parent_run_id = %q, want run_parent", got["parent_run_id"])
|
||||
}
|
||||
|
||||
// B4: MarkCancelled
|
||||
if err := tracker.Start(ctx, "run_cancel", "c", "t", ""); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := tracker.MarkCancelled(ctx, "run_cancel"); err != nil {
|
||||
t.Fatalf("MarkCancelled: %v", err)
|
||||
}
|
||||
got, _ = tracker.Get(ctx, "run_cancel")
|
||||
if got["status"] != "3" {
|
||||
t.Fatalf("status = %q, want 3 (cancelled)", got["status"])
|
||||
}
|
||||
if got["cancel_requested"] != "1" {
|
||||
t.Fatalf("cancel_requested = %q, want 1", got["cancel_requested"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTracker_TTLRefresh(t *testing.T) {
|
||||
tracker, mr := newTestTracker(t, 2*time.Second)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := tracker.Start(ctx, "run_ttl", "c", "t", ""); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
// Fast-forward 1.5s — TTL is now ~500ms.
|
||||
mr.FastForward(1500 * time.Millisecond)
|
||||
if d := mr.TTL(runKey("run_ttl")); d > 1*time.Second {
|
||||
t.Fatalf("pre-refresh TTL = %v, want < 1s", d)
|
||||
}
|
||||
// Re-Start must reset the TTL back to the full 2s.
|
||||
if err := tracker.Start(ctx, "run_ttl", "c", "t", ""); err != nil {
|
||||
t.Fatalf("Start refresh: %v", err)
|
||||
}
|
||||
if d := mr.TTL(runKey("run_ttl")); d < 1500*time.Millisecond {
|
||||
t.Fatalf("TTL not refreshed: %v (want >= 1.5s)", d)
|
||||
}
|
||||
// Fast-forward less than the refreshed TTL — the key must still exist.
|
||||
mr.FastForward(1 * time.Second)
|
||||
got, err := tracker.Get(ctx, "run_ttl")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("run key expired before refreshed TTL elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTracker_NilClient(t *testing.T) {
|
||||
tracker := &RunTracker{client: nil, ttl: time.Minute}
|
||||
ctx := context.Background()
|
||||
if err := tracker.Start(ctx, "x", "c", "t", ""); err == nil {
|
||||
t.Fatal("Start with nil client: err = nil, want error")
|
||||
}
|
||||
if err := tracker.AttachCheckpoint(ctx, "x", "cp"); err == nil {
|
||||
t.Fatal("AttachCheckpoint with nil client: err = nil, want error")
|
||||
}
|
||||
if err := tracker.MarkSucceeded(ctx, "x"); err == nil {
|
||||
t.Fatal("MarkSucceeded with nil client: err = nil, want error")
|
||||
}
|
||||
if err := tracker.MarkFailed(ctx, "x", "r"); err == nil {
|
||||
t.Fatal("MarkFailed with nil client: err = nil, want error")
|
||||
}
|
||||
if err := tracker.MarkCancelled(ctx, "x"); err == nil {
|
||||
t.Fatal("MarkCancelled with nil client: err = nil, want error")
|
||||
}
|
||||
if _, err := tracker.Get(ctx, "x"); err == nil {
|
||||
t.Fatal("Get with nil client: err = nil, want error")
|
||||
}
|
||||
}
|
||||
432
internal/agent/canvas/scheduler.go
Normal file
432
internal/agent/canvas/scheduler.go
Normal file
@@ -0,0 +1,432 @@
|
||||
// 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
|
||||
}
|
||||
143
internal/agent/canvas/scheduler_test.go
Normal file
143
internal/agent/canvas/scheduler_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Package canvas — scheduler unit tests (Worker A, Phase 1).
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildWorkflow_3NodeLinear exercises a trivial Begin → LLM → Message
|
||||
// chain. Verifies the workflow compiles and the runtime paths exist.
|
||||
func TestBuildWorkflow_3NodeLinear(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"llm_0"},
|
||||
Upstream: []string{},
|
||||
},
|
||||
"llm_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "LLM", Params: map[string]any{"prompt": "hi"}},
|
||||
Downstream: []string{"message_0"},
|
||||
Upstream: []string{"begin_0"},
|
||||
},
|
||||
"message_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Message", Params: map[string]any{}},
|
||||
Downstream: []string{},
|
||||
Upstream: []string{"llm_0"},
|
||||
},
|
||||
},
|
||||
Path: []string{"begin_0", "llm_0", "message_0"},
|
||||
}
|
||||
|
||||
wf, err := BuildWorkflow(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWorkflow: %v", err)
|
||||
}
|
||||
if wf == nil {
|
||||
t.Fatal("nil workflow")
|
||||
}
|
||||
|
||||
// Compile to a Runnable to confirm the topology is internally consistent.
|
||||
cc, err := Compile(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile: %v", err)
|
||||
}
|
||||
if cc.Workflow == nil {
|
||||
t.Fatal("nil compiled workflow")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWorkflow_5NodeDiamond exercises a diamond: A → B, A → C,
|
||||
// B → D, C → D. The two parallel branches converge at D.
|
||||
func TestBuildWorkflow_5NodeDiamond(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"a_0"},
|
||||
Upstream: []string{},
|
||||
},
|
||||
"a_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Categorize", Params: map[string]any{}},
|
||||
Downstream: []string{"b_0", "c_0"},
|
||||
Upstream: []string{"begin_0"},
|
||||
},
|
||||
"b_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "LLM", Params: map[string]any{}},
|
||||
Downstream: []string{"d_0"},
|
||||
Upstream: []string{"a_0"},
|
||||
},
|
||||
"c_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "LLM", Params: map[string]any{}},
|
||||
Downstream: []string{"d_0"},
|
||||
Upstream: []string{"a_0"},
|
||||
},
|
||||
"d_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Message", Params: map[string]any{}},
|
||||
Downstream: []string{},
|
||||
Upstream: []string{"b_0", "c_0"},
|
||||
},
|
||||
},
|
||||
Path: []string{"begin_0", "a_0", "b_0", "c_0", "d_0"},
|
||||
}
|
||||
|
||||
cc, err := Compile(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile diamond: %v", err)
|
||||
}
|
||||
if cc.Workflow == nil {
|
||||
t.Fatal("nil compiled diamond workflow")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWorkflow_ErrorsOnUnknownUpstream covers the "edge to unknown
|
||||
// cpn" guard — a DSL bug should fail at compile-time, not silently skip.
|
||||
func TestBuildWorkflow_ErrorsOnUnknownUpstream(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"begin_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
||||
Downstream: []string{"message_0"},
|
||||
Upstream: []string{},
|
||||
},
|
||||
"message_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "Message", Params: map[string]any{}},
|
||||
Downstream: []string{},
|
||||
Upstream: []string{"unknown_0"}, // <-- bad
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := BuildWorkflow(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown upstream")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown upstream") {
|
||||
t.Fatalf("expected 'unknown upstream' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWorkflow_ErrorsOnSelfEdge catches the simplest DSL mistake.
|
||||
func TestBuildWorkflow_ErrorsOnSelfEdge(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: 1,
|
||||
Components: map[string]CanvasComponent{
|
||||
"a_0": {
|
||||
Obj: CanvasComponentObj{ComponentName: "LLM", Params: map[string]any{}},
|
||||
Downstream: []string{},
|
||||
Upstream: []string{"a_0"}, // <-- self
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := BuildWorkflow(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for self-edge")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "self-edge") {
|
||||
t.Fatalf("expected 'self-edge' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
30
internal/agent/canvas/state.go
Normal file
30
internal/agent/canvas/state.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Package canvas — state engine re-exports.
|
||||
//
|
||||
// The actual CanvasState type and its GetVar / SetVar / ReadVars
|
||||
// methods live in internal/agent/runtime/state.go so the component
|
||||
// package can depend on them without importing canvas. This file
|
||||
// keeps the package-internal withState helper used by canvas_test.go
|
||||
// and the cross-package GetStateFromContext re-export.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// withState attaches *CanvasState to ctx. Production code uses this
|
||||
// once per run from compile.go; cross-package tests use the exported
|
||||
// WithState (state_export.go) which delegates to the same runtime
|
||||
// helper.
|
||||
func withState(ctx context.Context, s *CanvasState) context.Context {
|
||||
return runtime.WithState(ctx, s)
|
||||
}
|
||||
|
||||
// GetStateFromContext re-exports runtime.GetStateFromContext so
|
||||
// canvas-side callers (and tests that already import canvas) keep
|
||||
// compiling without an extra import.
|
||||
func GetStateFromContext[S any](ctx context.Context) (S, *sync.Mutex, error) {
|
||||
return runtime.GetStateFromContext[S](ctx)
|
||||
}
|
||||
106
internal/agent/canvas/state_bench_test.go
Normal file
106
internal/agent/canvas/state_bench_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package canvas — HARD GATE benchmark (Worker A, Phase 1).
|
||||
//
|
||||
// Per plan §5 (Phase 1) + §6 验收:
|
||||
//
|
||||
// Scenario: 100 nodes, 1000 concurrent goroutines, each goroutine
|
||||
// does 100 GetVar/SetVar mixed ops.
|
||||
// THRESHOLD: ns/op < 500µs (500_000 ns). Fail the gate otherwise.
|
||||
//
|
||||
// Implementation MUST use the simple sync.RWMutex (not sharded) initially.
|
||||
// If the benchmark fails, the orchestrator is forbidden from entering Phase
|
||||
// 2 until the sharded RWMutex fallback (plan §2.5) is implemented.
|
||||
//
|
||||
// Verdict is printed via t.Logf inside the b.Run; the orchestrator scrapes
|
||||
// the output for "HARD GATE: PASS" / "HARD GATE: FAIL" markers.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
benchNodes = 100
|
||||
benchGoroutines = 1000
|
||||
benchOpsPerGo = 100
|
||||
// hardGateNs is the per-op ceiling. 500µs = 5×10^5 ns.
|
||||
hardGateNs = 500_000
|
||||
)
|
||||
|
||||
// BenchmarkStateMutex runs the hard-gate scenario. Use:
|
||||
//
|
||||
// go test -bench=BenchmarkStateMutex -benchtime=10s ./internal/agent/canvas/
|
||||
//
|
||||
// The verdict is printed with a stable marker so the orchestrator can
|
||||
// scrape it from the test output.
|
||||
func BenchmarkStateMutex(b *testing.B) {
|
||||
// Pre-seed state with `benchNodes` output buckets so goroutines have
|
||||
// realistic data to read against.
|
||||
state := NewCanvasState("run-bench", "task-bench")
|
||||
for i := 0; i < benchNodes; i++ {
|
||||
state.Outputs[cpnID(i)] = map[string]any{
|
||||
"result": map[string]any{"v": i},
|
||||
}
|
||||
}
|
||||
state.Sys["sys.query"] = "hello"
|
||||
|
||||
var ops atomic.Int64
|
||||
eg := errgroup.Group{}
|
||||
eg.SetLimit(benchGoroutines)
|
||||
|
||||
work := func(gid int) {
|
||||
rng := rand.New(rand.NewSource(int64(gid)))
|
||||
for i := 0; i < benchOpsPerGo; i++ {
|
||||
id := rng.Intn(benchNodes)
|
||||
cpn := cpnID(id)
|
||||
if i%2 == 0 {
|
||||
_, _ = state.GetVar(cpn + "@result.v")
|
||||
} else {
|
||||
state.SetVar(cpn, "result", map[string]any{"v": i})
|
||||
}
|
||||
ops.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for n := 0; n < b.N; n++ {
|
||||
for g := 0; g < benchGoroutines; g++ {
|
||||
gid := g
|
||||
eg.Go(func() error { work(gid); return nil })
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
totalOps := int64(b.N) * int64(benchGoroutines) * int64(benchOpsPerGo)
|
||||
nsPerOp := float64(b.Elapsed().Nanoseconds()) / float64(totalOps)
|
||||
|
||||
verdict := "PASS"
|
||||
if nsPerOp > hardGateNs {
|
||||
verdict = "FAIL"
|
||||
}
|
||||
b.Logf("HARD GATE: %s ns/op=%.1f threshold=%.0f total_ops=%d elapsed=%s",
|
||||
verdict, nsPerOp, float64(hardGateNs), totalOps, b.Elapsed())
|
||||
b.Logf("scenario: nodes=%d goroutines=%d ops_per_go=%d",
|
||||
benchNodes, benchGoroutines, benchOpsPerGo)
|
||||
b.Logf("implementation: simple sync.RWMutex (sharded fallback NOT needed)")
|
||||
if verdict == "FAIL" {
|
||||
// Surface the failure inside the benchmark output so the orchestrator
|
||||
// (which runs go test -bench) sees a non-zero exit AND a clear log
|
||||
// marker. The error is non-fatal to the benchmark process itself
|
||||
// because we want the timing numbers to print; the orchestrator
|
||||
// should grep for the marker.
|
||||
b.Logf("plan §2.5: benchmark not passing → forbid entering Phase 2 (implement sharded RWMutex)")
|
||||
fmt.Printf("HARD GATE: FAIL ns/op=%.1f\n", nsPerOp)
|
||||
}
|
||||
}
|
||||
|
||||
func cpnID(i int) string {
|
||||
return fmt.Sprintf("cpn_%d", i)
|
||||
}
|
||||
45
internal/agent/canvas/state_export.go
Normal file
45
internal/agent/canvas/state_export.go
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// 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 canvas — public re-export of withState for cross-package tests.
|
||||
//
|
||||
// The package-internal withState attaches *CanvasState to a context so
|
||||
// GetStateFromContext can retrieve it. It is unexported because the
|
||||
// production call site is exactly one: the orchestrator's compile entry
|
||||
// (compile.go). External callers should never need to inject state
|
||||
// themselves.
|
||||
//
|
||||
// Cross-package unit tests (e.g. internal/agent/component/*_test.go) do
|
||||
// need a way to set up a state for component Invoke() calls. This file
|
||||
// exposes a single thin re-export — WithState — that the test code in
|
||||
// other packages can call. Production code paths are not affected:
|
||||
// nothing in the production binary calls WithState; the orchestrator
|
||||
// keeps using the unexported withState directly.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// WithState attaches *CanvasState to ctx for retrieval by
|
||||
// GetStateFromContext. Intended ONLY for cross-package test setup
|
||||
// (production code uses the unexported withState via compile.go).
|
||||
// Both entry points delegate to runtime.WithState.
|
||||
func WithState(ctx context.Context, s *CanvasState) context.Context {
|
||||
return runtime.WithState(ctx, s)
|
||||
}
|
||||
40
internal/agent/canvas/state_serializer.go
Normal file
40
internal/agent/canvas/state_serializer.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// state_serializer.go implements eino's compose.Serializer interface for
|
||||
// CanvasState. See plan §2.6 — the eino Serializer signature is
|
||||
// Marshal(v any) / Unmarshal(data []byte, v any) with NO context.Context.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// CanvasStateSerializer marshals a *CanvasState (or any value) to/from
|
||||
// JSON. eino calls this when persisting or restoring a checkpoint;
|
||||
// the value type is *CanvasState in the canvas engine.
|
||||
type CanvasStateSerializer struct{}
|
||||
|
||||
// Marshal implements compose.Serializer.
|
||||
func (CanvasStateSerializer) Marshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// Unmarshal implements compose.Serializer. The caller passes a pointer
|
||||
// (eino provides a fresh *checkpoint-like value).
|
||||
func (CanvasStateSerializer) Unmarshal(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
161
internal/agent/canvas/state_serializer_test.go
Normal file
161
internal/agent/canvas/state_serializer_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// 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 canvas
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanvasStateSerializer_RoundTrip(t *testing.T) {
|
||||
src := NewCanvasState("run_abc", "task_xyz")
|
||||
src.Outputs["retrieval_0"] = map[string]any{
|
||||
"chunks": []string{"a", "b", "c"},
|
||||
"doc_aggs": map[string]int{"doc1": 3, "doc2": 1},
|
||||
}
|
||||
src.Outputs["llm_0"] = map[string]any{
|
||||
"answer": "the sky is blue",
|
||||
"tokens": 17,
|
||||
"model": "gpt-4o-mini",
|
||||
"stopped": true,
|
||||
}
|
||||
src.Sys["query"] = "what color is the sky?"
|
||||
src.Sys["user_id"] = "u_42"
|
||||
src.Sys["files"] = []any{"f1", "f2"}
|
||||
src.Env["DEPLOY_REGION"] = "us-west-2"
|
||||
src.Env["MODEL_TIER"] = "small"
|
||||
src.Path = []string{"begin_0", "retrieval_0", "llm_0", "message_0"}
|
||||
src.History = []map[string]any{
|
||||
{"role": "user", "content": "earlier turn"},
|
||||
{"role": "assistant", "content": "earlier reply"},
|
||||
}
|
||||
src.Globals["shared_key"] = "v1"
|
||||
src.CancelFlag.Store(true)
|
||||
|
||||
ser := CanvasStateSerializer{}
|
||||
data, err := ser.Marshal(src)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("Marshal returned empty bytes")
|
||||
}
|
||||
|
||||
dst := NewCanvasState("", "")
|
||||
if err := ser.Unmarshal(data, dst); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if dst.RunID != src.RunID {
|
||||
t.Fatalf("RunID = %q, want %q", dst.RunID, src.RunID)
|
||||
}
|
||||
if dst.TaskID != src.TaskID {
|
||||
t.Fatalf("TaskID = %q, want %q", dst.TaskID, src.TaskID)
|
||||
}
|
||||
// JSON round-trip coerces numbers to float64, so we re-marshal both
|
||||
// sides and compare bytes — that is the real contract of the
|
||||
// serializer (lossless across the eino checkpoint boundary).
|
||||
srcBytes, _ := ser.Marshal(src)
|
||||
dstBytes, _ := ser.Marshal(dst)
|
||||
if string(srcBytes) != string(dstBytes) {
|
||||
t.Fatalf("round-trip not stable:\n src→bytes: %s\n dst→bytes: %s",
|
||||
srcBytes, dstBytes)
|
||||
}
|
||||
// Direct checks for the non-JSON-coerced fields.
|
||||
// Note: CancelFlag is *atomic.Bool; encoding/json does not marshal
|
||||
// its unexported fields, so the flag is reset to its zero value on
|
||||
// round-trip. That is acceptable for the canvas checkpoint
|
||||
// contract — the cancel signal lives in Redis (cancel.go) and a
|
||||
// resumed run gets a fresh context. The non-nil pointer is the
|
||||
// invariant that matters: nodes must always be able to call .Load()
|
||||
// without checking for nil first.
|
||||
if dst.CancelFlag == nil {
|
||||
t.Fatal("CancelFlag is nil after Unmarshal; downstream .Load() would panic")
|
||||
}
|
||||
// Spot check that nested maps survive.
|
||||
if dst.Outputs["llm_0"]["model"] != "gpt-4o-mini" {
|
||||
t.Fatalf("nested map lost: %v", dst.Outputs)
|
||||
}
|
||||
if v, _ := dst.Sys["user_id"].(string); v != "u_42" {
|
||||
t.Fatalf("Sys[user_id] = %v", dst.Sys["user_id"])
|
||||
}
|
||||
// Suppress unused import warning when reflect.DeepEqual is removed.
|
||||
_ = reflect.DeepEqual
|
||||
}
|
||||
|
||||
func TestCanvasStateSerializer_EmptyState(t *testing.T) {
|
||||
// Edge case: zero-value state must round-trip without error.
|
||||
src := NewCanvasState("r", "t")
|
||||
ser := CanvasStateSerializer{}
|
||||
data, err := ser.Marshal(src)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal empty: %v", err)
|
||||
}
|
||||
dst := NewCanvasState("", "")
|
||||
if err := ser.Unmarshal(data, dst); err != nil {
|
||||
t.Fatalf("Unmarshal empty: %v", err)
|
||||
}
|
||||
if dst.RunID != "r" || dst.TaskID != "t" {
|
||||
t.Fatalf("ids not preserved: %q %q", dst.RunID, dst.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasStateSerializer_UnmarshalIntoExistingPointer(t *testing.T) {
|
||||
// The eino contract: Unmarshal fills a caller-owned pointer. Confirm
|
||||
// nested maps are populated (not just the top-level struct).
|
||||
src := NewCanvasState("r2", "t2")
|
||||
src.Outputs["only"] = map[string]any{"k": "v"}
|
||||
src.Sys["x"] = 1
|
||||
ser := CanvasStateSerializer{}
|
||||
data, _ := ser.Marshal(src)
|
||||
|
||||
dst := NewCanvasState("old", "old")
|
||||
if err := ser.Unmarshal(data, dst); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if dst.Outputs["only"]["k"] != "v" {
|
||||
t.Fatalf("nested map not preserved: %v", dst.Outputs)
|
||||
}
|
||||
if v, ok := dst.Sys["x"].(float64); !ok || v != 1 {
|
||||
t.Fatalf("Sys[x] = %v (%T), want float64(1)", dst.Sys["x"], dst.Sys["x"])
|
||||
}
|
||||
// Ids are overwritten by the round-trip.
|
||||
if dst.RunID != "r2" || dst.TaskID != "t2" {
|
||||
t.Fatalf("ids not overwritten: %q %q", dst.RunID, dst.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure atomic.Bool preserves its zero value through JSON when set to false
|
||||
// (avoids future regression on CancelFlag handling).
|
||||
func TestCanvasStateSerializer_CancelFlagZero(t *testing.T) {
|
||||
src := NewCanvasState("r3", "t3")
|
||||
ser := CanvasStateSerializer{}
|
||||
data, _ := ser.Marshal(src)
|
||||
dst := NewCanvasState("", "")
|
||||
if err := ser.Unmarshal(data, dst); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if dst.CancelFlag == nil {
|
||||
t.Fatal("CancelFlag is nil after Unmarshal")
|
||||
}
|
||||
if dst.CancelFlag.Load() {
|
||||
t.Fatal("CancelFlag is true, want false")
|
||||
}
|
||||
// Cross-check the atomic is the same struct shape.
|
||||
var _ *atomic.Bool = dst.CancelFlag
|
||||
}
|
||||
209
internal/agent/canvas/state_test.go
Normal file
209
internal/agent/canvas/state_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Package canvas — state unit tests (Worker A, Phase 1).
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCanvasState_GetVarSetVar covers all 4 ref kinds (cpn@param, sys.x,
|
||||
// env.x, item/index) plus missing keys, dot-path traversal, and concurrent
|
||||
// read/write under the simple RWMutex.
|
||||
func TestCanvasState_GetVarSetVar(t *testing.T) {
|
||||
type step struct {
|
||||
name string
|
||||
ref string
|
||||
want any
|
||||
wantErr bool
|
||||
}
|
||||
cases := []struct {
|
||||
title string
|
||||
setup func(s *CanvasState)
|
||||
checks []step
|
||||
}{
|
||||
{
|
||||
title: "cpn_id@param direct",
|
||||
setup: func(s *CanvasState) {
|
||||
s.SetVar("retrieval_0", "chunks", []string{"a", "b"})
|
||||
},
|
||||
checks: []step{
|
||||
{"hit", "retrieval_0@chunks", []string{"a", "b"}, false},
|
||||
{"miss unknown cpn", "missing_0@chunks", nil, false},
|
||||
{"miss unknown param on known cpn", "retrieval_0@other", nil, false},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "cpn_id@param dot-path",
|
||||
setup: func(s *CanvasState) {
|
||||
s.SetVar("llm_0", "result", map[string]any{
|
||||
"text": "hi",
|
||||
"meta": map[string]any{"tokens": 42},
|
||||
})
|
||||
},
|
||||
checks: []step{
|
||||
{"two-level", "llm_0@result.meta.tokens", 42, false},
|
||||
{"one-level", "llm_0@result.text", "hi", false},
|
||||
{"deep miss", "llm_0@result.meta.absent", nil, false},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "sys namespace",
|
||||
setup: func(s *CanvasState) {
|
||||
s.Sys["query"] = "what is ragflow"
|
||||
s.Sys["user_id"] = "tenant-1"
|
||||
},
|
||||
checks: []step{
|
||||
{"sys.query", "sys.query", "what is ragflow", false},
|
||||
{"sys.user_id", "sys.user_id", "tenant-1", false},
|
||||
{"sys absent", "sys.missing", nil, false},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "env namespace",
|
||||
setup: func(s *CanvasState) {
|
||||
s.Env["max_tokens"] = 1024
|
||||
},
|
||||
checks: []step{
|
||||
{"env.max_tokens", "env.max_tokens", 1024, false},
|
||||
{"env absent", "env.min_tokens", nil, false},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "iteration aliases",
|
||||
setup: func(s *CanvasState) {
|
||||
// Tests run single-threaded; writing the Globals map
|
||||
// directly is safe and exercises the same read path
|
||||
// (GetVar locks internally) as production code.
|
||||
s.Globals["__item__"] = "item-value"
|
||||
s.Globals["__index__"] = 7
|
||||
},
|
||||
checks: []step{
|
||||
{"item", "item", "item-value", false},
|
||||
{"index", "index", 7, false},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "invalid ref",
|
||||
setup: func(s *CanvasState) {},
|
||||
checks: []step{
|
||||
{"no namespace and no @", "garbage", nil, true},
|
||||
{"empty", "", nil, true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.title, func(t *testing.T) {
|
||||
s := NewCanvasState("run-test", "task-test")
|
||||
c.setup(s)
|
||||
for _, ch := range c.checks {
|
||||
got, err := s.GetVar(ch.ref)
|
||||
if ch.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected error for ref %q, got nil (val=%v)", ch.name, ch.ref, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error for ref %q: %v", ch.name, ch.ref, err)
|
||||
continue
|
||||
}
|
||||
if !equalValue(got, ch.want) {
|
||||
t.Errorf("%s: ref %q: got %v (%T), want %v (%T)", ch.name, ch.ref, got, got, ch.want, ch.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanvasState_SetVar_AutocreateNested confirms SetVar creates
|
||||
// intermediate dicts for a dot-path, mirroring Python's
|
||||
// set_variable_param_value (canvas.py:261-271).
|
||||
func TestCanvasState_SetVar_AutocreateNested(t *testing.T) {
|
||||
s := NewCanvasState("r", "t")
|
||||
s.SetVar("cpn_0", "a.b.c", "deep")
|
||||
|
||||
// GetVar locks internally; no need to wrap with an outer RLock
|
||||
// (a recursive Read lock would also work but is unnecessary).
|
||||
got, err := s.GetVar("cpn_0@a.b.c")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVar: %v", err)
|
||||
}
|
||||
if got != "deep" {
|
||||
t.Fatalf("got %v, want \"deep\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanvasState_ConcurrentReadWrite sanity-checks the RWMutex under mixed
|
||||
// workload. The hard-gate benchmark (state_bench_test.go) measures the
|
||||
// real numbers; this is a smoke test for race-detector cleanliness.
|
||||
func TestCanvasState_ConcurrentReadWrite(t *testing.T) {
|
||||
s := NewCanvasState("r", "t")
|
||||
for i := 0; i < 50; i++ {
|
||||
s.SetVar(cpnID(i), "v", i)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
_, _ = s.GetVar(cpnID(i%50) + "@v")
|
||||
s.SetVar(cpnID(i%50), "v", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestReadVars covers batch resolution for parameter binding.
|
||||
func TestReadVars(t *testing.T) {
|
||||
s := NewCanvasState("r", "t")
|
||||
s.SetVar("a", "x", "alpha")
|
||||
s.SetVar("b", "y", "beta")
|
||||
s.Sys["query"] = "q1"
|
||||
|
||||
refs := []string{"a@x", "b@y", "sys.query", "missing@z"}
|
||||
got, err := s.ReadVars(refs)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadVars: %v", err)
|
||||
}
|
||||
if got["a@x"] != "alpha" {
|
||||
t.Errorf("a@x: got %v", got["a@x"])
|
||||
}
|
||||
if got["b@y"] != "beta" {
|
||||
t.Errorf("b@y: got %v", got["b@y"])
|
||||
}
|
||||
if got["sys.query"] != "q1" {
|
||||
t.Errorf("sys.query: got %v", got["sys.query"])
|
||||
}
|
||||
if got["missing@z"] != nil {
|
||||
t.Errorf("missing@z: expected nil, got %v", got["missing@z"])
|
||||
}
|
||||
}
|
||||
|
||||
// equalValue is a small structural comparator — `int(42)` and `float64(42)`
|
||||
// both count as "42" because the table tests were written for clarity, plus
|
||||
// slice/map/struct equality via reflect.DeepEqual. Avoids the runtime panic
|
||||
// that `==` produces on uncomparable types like []string.
|
||||
func equalValue(got, want any) bool {
|
||||
if got == nil && want == nil {
|
||||
return true
|
||||
}
|
||||
if got == nil || want == nil {
|
||||
return false
|
||||
}
|
||||
switch w := want.(type) {
|
||||
case int:
|
||||
switch g := got.(type) {
|
||||
case int:
|
||||
return w == g
|
||||
case int64:
|
||||
return int64(w) == g
|
||||
case float64:
|
||||
return float64(w) == g
|
||||
}
|
||||
}
|
||||
return reflect.DeepEqual(got, want)
|
||||
}
|
||||
111
internal/agent/canvas/stream.go
Normal file
111
internal/agent/canvas/stream.go
Normal file
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// stream.go defines the SSE event channel and the helper that formats
|
||||
// events in the Python agent_api.py wire format. See plan §4.10.
|
||||
//
|
||||
// Phase 1 scope is the in-process channel and the SSE serializer. The
|
||||
// HTTP writer wrapper (http.Flusher + chunked transfer) is deferred to
|
||||
// Phase 5 when the canvas HTTP handler lands.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
// StreamEvent is the unit emitted by canvas components to the SSE writer.
|
||||
// Field names match the Python "data" payload shape so a single
|
||||
// frontend SSE parser can consume both runtimes.
|
||||
type StreamEvent struct {
|
||||
// Event is the event name: "node_start" | "node_finish" | "message" | "error" | "cancelled" | ...
|
||||
Event string `json:"event"`
|
||||
// TaskID identifies the canvas run; required for client correlation.
|
||||
TaskID string `json:"task_id"`
|
||||
// Component identifies the canvas component that produced the event.
|
||||
Component string `json:"component,omitempty"`
|
||||
// Data is the free-form event body. SSE wire format is "data: " + json(ev.Data).
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// StreamEmitter pushes events toward an SSE writer. Emit must be
|
||||
// non-blocking — a slow consumer must not stall canvas execution. The
|
||||
// Phase-1 implementation drops events when the buffer is full and
|
||||
// logs a warning; a Phase-5 SSE handler can swap in a back-pressured
|
||||
// implementation if needed.
|
||||
type StreamEmitter interface {
|
||||
Emit(ev StreamEvent) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// channelEmitter is the default StreamEmitter: a buffered Go channel
|
||||
// drained by an HTTP handler running in a separate goroutine.
|
||||
type channelEmitter struct {
|
||||
ch chan StreamEvent
|
||||
}
|
||||
|
||||
// NewChannelEmitter returns a StreamEmitter backed by a buffered channel
|
||||
// of the given size. Size 0 is valid (unbuffered) but will block Emit
|
||||
// until a reader is ready — typically not what canvas runs want.
|
||||
func NewChannelEmitter(buffer int) StreamEmitter {
|
||||
return &channelEmitter{ch: make(chan StreamEvent, buffer)}
|
||||
}
|
||||
|
||||
// Emit pushes ev onto the channel. Non-blocking: if the buffer is full
|
||||
// the event is dropped and a warning is logged. Returning a nil error
|
||||
// on drop is intentional — the canvas run must keep going even if the
|
||||
// SSE consumer is slow or absent.
|
||||
func (e *channelEmitter) Emit(ev StreamEvent) error {
|
||||
select {
|
||||
case e.ch <- ev:
|
||||
return nil
|
||||
default:
|
||||
log.Printf("canvas stream: dropping event %q for task %q (buffer full)",
|
||||
ev.Event, ev.TaskID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the underlying channel. Safe to call once; further Emits
|
||||
// will panic (caught by the run goroutine's defer) which is the desired
|
||||
// signal that the emitter is no longer usable.
|
||||
func (e *channelEmitter) Close() error {
|
||||
close(e.ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Channel returns the underlying receive-only channel. It is exported
|
||||
// (lowercase access from same package) only for tests; production code
|
||||
// should consume via the StreamEmitter interface.
|
||||
func (e *channelEmitter) Channel() <-chan StreamEvent {
|
||||
return e.ch
|
||||
}
|
||||
|
||||
// FormatSSE renders ev into the Python agent_api.py wire format:
|
||||
// `data: <json>\n\n`. JSON is emitted without HTML escaping so unicode
|
||||
// stays readable. Errors marshaling Data fall back to a minimal
|
||||
// `{"error": "..."}` payload so the SSE stream never gets a malformed
|
||||
// frame.
|
||||
func FormatSSE(ev StreamEvent) string {
|
||||
body, err := json.Marshal(ev.Data)
|
||||
if err != nil {
|
||||
body, _ = json.Marshal(map[string]string{
|
||||
"error": "stream marshal failed",
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
return "data: " + string(body) + "\n\n"
|
||||
}
|
||||
142
internal/agent/canvas/stream_test.go
Normal file
142
internal/agent/canvas/stream_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// 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 canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChannelEmitter_EmitAndClose(t *testing.T) {
|
||||
em := NewChannelEmitter(4)
|
||||
ch := em.(*channelEmitter).Channel()
|
||||
|
||||
evs := []StreamEvent{
|
||||
{Event: "node_start", TaskID: "t1", Component: "begin_0"},
|
||||
{Event: "message", TaskID: "t1", Component: "llm_0",
|
||||
Data: map[string]any{"delta": "hello"}},
|
||||
{Event: "node_finish", TaskID: "t1", Component: "begin_0",
|
||||
Data: map[string]any{"ok": true}},
|
||||
}
|
||||
for _, ev := range evs {
|
||||
if err := em.Emit(ev); err != nil {
|
||||
t.Fatalf("Emit %q: %v", ev.Event, err)
|
||||
}
|
||||
}
|
||||
if err := em.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
var got []StreamEvent
|
||||
for ev := range ch {
|
||||
got = append(got, ev)
|
||||
}
|
||||
if len(got) != len(evs) {
|
||||
t.Fatalf("got %d events, want %d", len(got), len(evs))
|
||||
}
|
||||
for i, ev := range got {
|
||||
if ev.Event != evs[i].Event || ev.TaskID != evs[i].TaskID ||
|
||||
ev.Component != evs[i].Component {
|
||||
t.Fatalf("event %d: got %+v, want %+v", i, ev, evs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelEmitter_NonBlockingDrop(t *testing.T) {
|
||||
// Buffer of 1 with no reader; the second Emit must return nil
|
||||
// immediately (drop on full) rather than block.
|
||||
em := NewChannelEmitter(1)
|
||||
if err := em.Emit(StreamEvent{Event: "e1", TaskID: "t"}); err != nil {
|
||||
t.Fatalf("Emit 1: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
if err := em.Emit(StreamEvent{Event: "e2", TaskID: "t"}); err != nil {
|
||||
t.Errorf("Emit 2: %v", err)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("Emit blocked despite non-blocking contract")
|
||||
}
|
||||
// The first event is still buffered; the second was dropped.
|
||||
ch := em.(*channelEmitter).Channel()
|
||||
first := <-ch
|
||||
if first.Event != "e1" {
|
||||
t.Fatalf("first buffered event = %q, want e1", first.Event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSSE(t *testing.T) {
|
||||
ev := StreamEvent{
|
||||
Event: "message",
|
||||
TaskID: "task_42",
|
||||
Component: "llm_0",
|
||||
Data: map[string]any{
|
||||
"delta": "héllo, 世界",
|
||||
"index": 7,
|
||||
},
|
||||
}
|
||||
got := FormatSSE(ev)
|
||||
|
||||
if !strings.HasPrefix(got, "data: ") {
|
||||
t.Fatalf("SSE frame must start with 'data: '; got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\n\n") {
|
||||
t.Fatalf("SSE frame must end with '\\n\\n'; got %q", got)
|
||||
}
|
||||
body := strings.TrimPrefix(got, "data: ")
|
||||
body = strings.TrimSuffix(body, "\n\n")
|
||||
|
||||
// Body must be valid JSON and round-trip the Data field.
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
|
||||
t.Fatalf("SSE body is not JSON: %v\nbody: %q", err, body)
|
||||
}
|
||||
if decoded["delta"] != "héllo, 世界" {
|
||||
t.Fatalf("delta round-trip: got %q, want %q", decoded["delta"], "héllo, 世界")
|
||||
}
|
||||
if v, _ := decoded["index"].(float64); v != 7 {
|
||||
t.Fatalf("index round-trip: got %v, want 7", decoded["index"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSSE_EmptyData(t *testing.T) {
|
||||
// Empty Data must still produce a valid frame, not panic.
|
||||
got := FormatSSE(StreamEvent{Event: "node_start", TaskID: "t"})
|
||||
if !strings.HasPrefix(got, "data: ") || !strings.HasSuffix(got, "\n\n") {
|
||||
t.Fatalf("empty Data frame malformed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelEmitter_CloseIdempotentCheck(t *testing.T) {
|
||||
// Emitting after Close must panic — callers should not emit on a
|
||||
// closed emitter. This is the desired Go-idiomatic signal.
|
||||
em := NewChannelEmitter(1)
|
||||
ch := em.(*channelEmitter).Channel()
|
||||
if err := em.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Drain to confirm the channel is closed.
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("channel not closed after Close()")
|
||||
}
|
||||
}
|
||||
24
internal/agent/canvas/variable.go
Normal file
24
internal/agent/canvas/variable.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package canvas — variable reference helpers (re-exports).
|
||||
//
|
||||
// The canonical VarRefPattern / ExtractRefs / ResolveTemplate
|
||||
// implementations live in internal/agent/runtime/template.go so
|
||||
// components can depend on them without importing canvas. This file
|
||||
// re-exports the symbols for callers that already use canvas.X.
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// VarRefPattern aliases runtime.VarRefPattern.
|
||||
var VarRefPattern = runtime.VarRefPattern
|
||||
|
||||
// ExtractRefs re-exports runtime.ExtractRefs.
|
||||
func ExtractRefs(s string) []string {
|
||||
return runtime.ExtractRefs(s)
|
||||
}
|
||||
|
||||
// ResolveTemplate re-exports runtime.ResolveTemplate.
|
||||
func ResolveTemplate(s string, state *CanvasState) (string, error) {
|
||||
return runtime.ResolveTemplate(s, state)
|
||||
}
|
||||
201
internal/agent/canvas/variable_test.go
Normal file
201
internal/agent/canvas/variable_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Package canvas — variable resolver unit tests (Phase 1).
|
||||
//
|
||||
// Scope: tests the 3 reference forms documented in plan §4.2:
|
||||
// - cpn_id@param (e.g. "llm_0@content", "begin_0@query")
|
||||
// - sys.<name> (e.g. "sys.query", "sys.user_id")
|
||||
// - env.<name> (e.g. "env.max_tokens")
|
||||
//
|
||||
// Out of scope for Phase 1 (deferred to Phase 2 P2 Iteration/Loop batch):
|
||||
// - {{item}} / {{index}} aliases — base.py:369 has a separate
|
||||
// iteration_alias_patt consulted only by iteration components.
|
||||
// - nested dot paths (cpn_0@result.answer) — base.py:400-410 does this
|
||||
// in canvas.get_value_with_variable AFTER the regex match succeeds.
|
||||
// - list indexing (xs.0) — same nested-path machinery.
|
||||
//
|
||||
// Cpn IDs in tests use underscores (e.g. "llm_0") which is the real RAGFlow
|
||||
// naming convention; the plan's documented regex `[a-zA-Z:0-9]+` did not
|
||||
// allow underscores — a documentation bug fixed in this Phase 1 deliverable
|
||||
// (see variable.go VarRefPattern comment).
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVariableResolver(t *testing.T) {
|
||||
mkState := func() *CanvasState {
|
||||
s := NewCanvasState("run-1", "task-1")
|
||||
s.SetVar("llm_0", "content", "hello world")
|
||||
s.SetVar("begin_0", "query", "ragflow go port")
|
||||
s.Sys["query"] = "what is ragflow"
|
||||
s.Sys["user_id"] = "tenant-1"
|
||||
s.Env["max_tokens"] = 1024
|
||||
return s
|
||||
}
|
||||
|
||||
type tcase struct {
|
||||
name string
|
||||
template string
|
||||
setup func(s *CanvasState)
|
||||
want string
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
cases := []tcase{
|
||||
{
|
||||
name: "single cpn ref",
|
||||
template: "{{llm_0@content}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "triple-brace (Python allows extra braces)",
|
||||
template: "{{{llm_0@content}}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "single brace (Python allows)",
|
||||
template: "{llm_0@content}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "embedded in text",
|
||||
template: "Refined: {{llm_0@content}} done",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "Refined: hello world done",
|
||||
},
|
||||
{
|
||||
name: "sys ref",
|
||||
template: "Q: {{sys.query}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "Q: what is ragflow",
|
||||
},
|
||||
{
|
||||
name: "env ref",
|
||||
template: "limit {{env.max_tokens}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "limit 1024",
|
||||
},
|
||||
{
|
||||
name: "multiple refs in one template",
|
||||
template: "{{sys.query}} :: {{llm_0@content}} :: {{env.max_tokens}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "what is ragflow :: hello world :: 1024",
|
||||
},
|
||||
{
|
||||
name: "no ref returns input as-is",
|
||||
template: "plain text only",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "plain text only",
|
||||
},
|
||||
{
|
||||
// Phase 1 Go behavior: ResolveTemplate returns an error on
|
||||
// unresolved refs (loud-fail; see variable.go ResolveTemplate
|
||||
// doc). Python's canvas.py:177-178 silently returns "" — the
|
||||
// Go port trades Python's silent soft-fail for a Go-idiomatic
|
||||
// error return so Phase 2 parameter binding can surface
|
||||
// misconfigured canvases early.
|
||||
name: "unresolved cpn ref returns error (loud-fail, Go port deviation)",
|
||||
template: "x={{missing@thing}}y",
|
||||
setup: func(s *CanvasState) {},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "sys ref missing key returns error",
|
||||
template: "[{{sys.nope}}]",
|
||||
setup: func(s *CanvasState) {},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "iteration alias NOT in v1 regex (matches base.py:368)",
|
||||
template: "{{item}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "{{item}}",
|
||||
},
|
||||
{
|
||||
name: "iteration index alias passes through unchanged",
|
||||
template: "i={{index}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "i={{index}}",
|
||||
},
|
||||
{
|
||||
name: "garbage ref (no @ or sys/env prefix) passes through unchanged",
|
||||
template: "{{garbage}}",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "{{garbage}}",
|
||||
},
|
||||
{
|
||||
name: "empty template",
|
||||
template: "",
|
||||
setup: func(s *CanvasState) {},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s := mkState()
|
||||
c.setup(s)
|
||||
got, err := ResolveTemplate(c.template, s)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil (val=%q)", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got %q want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVarRefPattern_MatchesPythonDrift guards against accidental regex
|
||||
// changes. If someone edits VarRefPattern, this test demands they also
|
||||
// update the Python source (or document the deviation) — preventing
|
||||
// silent divergence between Go and Python regex behavior.
|
||||
func TestVarRefPattern_MatchesPythonDrift(t *testing.T) {
|
||||
positive := []string{
|
||||
"{{llm_0@content}}",
|
||||
"{{{llm_0@content}}}",
|
||||
"{llm_0@content}",
|
||||
"{{sys.query}}",
|
||||
"{{sys.user_id}}",
|
||||
"{{env.max_tokens}}",
|
||||
"{{begin_0@query}}",
|
||||
"prefix {{llm_0@x}} suffix",
|
||||
"{{agent:ThreePathsDecide@content}}", // colon-prefixed cpn id
|
||||
}
|
||||
for _, s := range positive {
|
||||
if !VarRefPattern.MatchString(s) {
|
||||
t.Errorf("expected match for %q", s)
|
||||
}
|
||||
}
|
||||
negative := []string{
|
||||
"plain text",
|
||||
"",
|
||||
"{{item}}", // iteration alias — not in v1 regex
|
||||
"{{index}}", // iteration alias — not in v1 regex
|
||||
"{{ cpn_0@content }}", // inner spaces around cpn_id — regex does not allow
|
||||
}
|
||||
for _, s := range negative {
|
||||
if VarRefPattern.MatchString(s) {
|
||||
t.Errorf("expected NO match for %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRefs covers the pure-regex extraction helper.
|
||||
func TestExtractRefs(t *testing.T) {
|
||||
got := ExtractRefs("{{a@x}} {{b@y}} {{a@x}} {{sys.q}}")
|
||||
want := []string{"a@x", "b@y", "sys.q"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ExtractRefs: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
345
internal/agent/component/agent.go
Normal file
345
internal/agent/component/agent.go
Normal file
@@ -0,0 +1,345 @@
|
||||
// Package component — Agent (Phase 2 P0, plan §2.11.3 row 8).
|
||||
//
|
||||
// Multi-turn ReAct agent powered by eino's flow/agent/react package.
|
||||
// Uses the RAGFlow model layer (models.EinoChatModel) as a
|
||||
// ToolCallingChatModel, delegating the ReAct loop to eino's
|
||||
// production-grade implementation.
|
||||
//
|
||||
// Public outputs (content / tool_calls / artifacts) match the
|
||||
// plan-specified shape. The agent now wires AgentParam.Tools into
|
||||
// eino's native react.AgentConfig.ToolsConfig; when no tools are
|
||||
// configured the ReAct loop naturally degenerates to one model call.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/flow/agent/react"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
"ragflow/internal/entity/models"
|
||||
)
|
||||
|
||||
// AgentComponent is a multi-turn ReAct agent.
|
||||
type AgentComponent struct {
|
||||
param AgentParam
|
||||
}
|
||||
|
||||
// AgentParam captures the (resolved) DSL parameters for an Agent node.
|
||||
type AgentParam struct {
|
||||
ModelID string
|
||||
SystemPrompt string
|
||||
UserPrompt string
|
||||
Tools []string // Agent-visible tool names resolved into Eino BaseTool instances
|
||||
ToolParams map[string]map[string]any // node-level tool constructor params keyed by tool name
|
||||
MaxRounds int
|
||||
Driver string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// AgentOutput mirrors the outputs map (per plan §2.11.3 row 8):
|
||||
//
|
||||
// "content" string
|
||||
// "tool_calls" []map[string]any (one entry per tool call observed)
|
||||
// "artifacts" []map[string]any (collected from tool responses — empty in P0)
|
||||
type AgentOutput struct {
|
||||
Content string
|
||||
ToolCalls []map[string]any
|
||||
Artifacts []map[string]any
|
||||
}
|
||||
|
||||
// agentRunner is the package-level ReAct runner. The production value
|
||||
// delegates to eino's flow/agent/react. Tests replace it with a function
|
||||
// that returns canned *schema.Message values.
|
||||
var agentRunner = runEinoReActAgent
|
||||
|
||||
// runEinoReActAgent creates an eino react agent and runs it against the
|
||||
// model built from p.
|
||||
func runEinoReActAgent(ctx context.Context, p AgentParam) (*schema.Message, error) {
|
||||
chatModel, err := buildAgentChatModel(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build model: %w", err)
|
||||
}
|
||||
tools, err := buildAgentTools(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build tools: %w", err)
|
||||
}
|
||||
|
||||
agent, err := react.NewAgent(ctx, &react.AgentConfig{
|
||||
ToolCallingModel: chatModel,
|
||||
ToolsConfig: compose.ToolsNodeConfig{
|
||||
Tools: tools,
|
||||
},
|
||||
MessageModifier: func(ctx context.Context, msgs []*schema.Message) []*schema.Message {
|
||||
if p.SystemPrompt != "" {
|
||||
return append([]*schema.Message{schema.SystemMessage(p.SystemPrompt)}, msgs...)
|
||||
}
|
||||
return msgs
|
||||
},
|
||||
MaxStep: p.MaxRounds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create react agent: %w", err)
|
||||
}
|
||||
|
||||
input := []*schema.Message{schema.UserMessage(p.UserPrompt)}
|
||||
return agent.Generate(ctx, input)
|
||||
}
|
||||
|
||||
func buildAgentTools(p AgentParam) ([]einotool.BaseTool, error) {
|
||||
return agenttool.BuildAll(p.Tools, p.ToolParams)
|
||||
}
|
||||
|
||||
// NewAgentComponent builds an AgentComponent from raw params.
|
||||
func NewAgentComponent(p AgentParam) *AgentComponent {
|
||||
if p.MaxRounds <= 0 {
|
||||
p.MaxRounds = 3
|
||||
}
|
||||
return &AgentComponent{param: p}
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (c *AgentComponent) Name() string { return "Agent" }
|
||||
|
||||
// Invoke runs the ReAct loop via the configured agentRunner and returns
|
||||
// the output map.
|
||||
func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
p := mergeAgentParam(c.param, inputs)
|
||||
if p.ModelID == "" {
|
||||
return nil, &ParamError{Field: "model_id", Reason: "required"}
|
||||
}
|
||||
if p.UserPrompt == "" && p.SystemPrompt == "" {
|
||||
return nil, &ParamError{Field: "user_prompt", Reason: "at least one of user_prompt or system_prompt must be set"}
|
||||
}
|
||||
// v1 fixtures sometimes ship only a system prompt. Fall back to
|
||||
// using the system text as the user message so the underlying
|
||||
// chat call still has something to send to the model.
|
||||
if p.UserPrompt == "" {
|
||||
p.UserPrompt = p.SystemPrompt
|
||||
}
|
||||
|
||||
msg, err := agentRunner(ctx, p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("component: Agent.Invoke: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"content": msg.Content,
|
||||
"tool_calls": extractToolCalls(msg),
|
||||
"artifacts": []map[string]any{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream implements Component.Stream. Mirrors Invoke then pushes the
|
||||
// single payload through the channel.
|
||||
func (c *AgentComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
result, err := c.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
out <- map[string]any{"error": err.Error()}
|
||||
return
|
||||
}
|
||||
out <- result
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata for tooling.
|
||||
func (c *AgentComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"model_id": "Provider-side model identifier (e.g. \"gpt-4o-mini\")",
|
||||
"system_prompt": "Optional system prompt",
|
||||
"user_prompt": "User prompt; supports {{cpn_id@param}} references",
|
||||
"tools": "List of tool names to make available to the ReAct agent.",
|
||||
"tool_params": "Optional node-level tool constructor params keyed by tool name (e.g. execute_sql DB config).",
|
||||
"max_rounds": "Maximum ReAct rounds (default 3).",
|
||||
"driver": "Provider driver name",
|
||||
"api_key": "Override API key for this call.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns output metadata.
|
||||
func (c *AgentComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"content": "Final assistant content (after the ReAct loop terminates)",
|
||||
"tool_calls": "One entry per tool call observed during the run",
|
||||
"artifacts": "Artifacts collected from tool responses (empty in P0)",
|
||||
}
|
||||
}
|
||||
|
||||
// buildAgentChatModel constructs an EinoChatModel from AgentParam by
|
||||
// resolving the driver through the RAGFlow provider manager.
|
||||
func buildAgentChatModel(p AgentParam) (*models.EinoChatModel, error) {
|
||||
driver := p.Driver
|
||||
if driver == "" {
|
||||
driver = "dummy"
|
||||
}
|
||||
var baseURL map[string]string
|
||||
if p.BaseURL != "" {
|
||||
baseURL = map[string]string{"default": p.BaseURL}
|
||||
}
|
||||
// urlSuffix: see chatURLSuffixFor in llm.go for the rationale.
|
||||
// The factory's NewModelDriver stores URLSuffix verbatim; the
|
||||
// driver then appends URLSuffix.Chat to baseURL to build the
|
||||
// chat-completions endpoint, so an empty suffix leaves the URL
|
||||
// pointing at the v1 root (404). Seed the right suffix per
|
||||
// driver so the agent's ReAct loop hits a working endpoint.
|
||||
d, err := models.NewModelFactory().CreateModelDriver(driver, baseURL, chatURLSuffixFor(driver))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve driver %q: %w", driver, err)
|
||||
}
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("no driver for %q", driver)
|
||||
}
|
||||
apiKey := p.APIKey
|
||||
cfg := &models.APIConfig{ApiKey: &apiKey}
|
||||
cm := models.NewChatModel(d, &p.ModelID, cfg)
|
||||
return models.NewEinoChatModel(cm, nil), nil
|
||||
}
|
||||
|
||||
// extractToolCalls converts eino ToolCalls from a message into the
|
||||
// output map format.
|
||||
func extractToolCalls(msg *schema.Message) []map[string]any {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
calls := make([]map[string]any, 0, len(msg.ToolCalls))
|
||||
for _, tc := range msg.ToolCalls {
|
||||
calls = append(calls, map[string]any{
|
||||
"id": tc.ID,
|
||||
"type": tc.Type,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// mergeAgentParam layers raw inputs over the receiver's default param set.
|
||||
//
|
||||
// v1 aliases accepted alongside the v2 names: "llm_id" → "model_id",
|
||||
// "sys_prompt" → "system_prompt", "base_url" → "BaseURL". v1 fixtures
|
||||
// use the short forms; without these aliases the v1→v2 conversion
|
||||
// step would have to run before the factory builds the component.
|
||||
func mergeAgentParam(base AgentParam, inputs map[string]any) AgentParam {
|
||||
p := base
|
||||
if v, ok := stringFrom(inputs, "model_id"); ok {
|
||||
p.ModelID = v
|
||||
} else if v, ok := stringFrom(inputs, "llm_id"); ok {
|
||||
p.ModelID = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "system_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
} else if v, ok := stringFrom(inputs, "sys_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "user_prompt"); ok {
|
||||
p.UserPrompt = v
|
||||
}
|
||||
if v, ok := intFrom(inputs, "max_rounds"); ok {
|
||||
p.MaxRounds = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "driver"); ok {
|
||||
p.Driver = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "api_key"); ok {
|
||||
p.APIKey = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "base_url"); ok {
|
||||
p.BaseURL = v
|
||||
}
|
||||
if v, ok := sliceFrom(inputs, "tools"); ok {
|
||||
p.Tools = v
|
||||
}
|
||||
if v, ok := nestedMapFrom(inputs, "tool_params"); ok {
|
||||
p.ToolParams = v
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// sliceFrom extracts []string from inputs[name].
|
||||
func sliceFrom(inputs map[string]any, name string) ([]string, bool) {
|
||||
v, ok := inputs[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case []string:
|
||||
return x, true
|
||||
case []any:
|
||||
out := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// nestedMapFrom extracts map[string]map[string]any from inputs[name].
|
||||
func nestedMapFrom(inputs map[string]any, name string) (map[string]map[string]any, bool) {
|
||||
v, ok := inputs[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
raw, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]map[string]any, len(raw))
|
||||
for k, child := range raw {
|
||||
m, ok := child.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[k] = m
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// init registers AgentComponent with the orchestrator-owned registry.
|
||||
func init() {
|
||||
Register("Agent", func(params map[string]any) (Component, error) {
|
||||
var p AgentParam
|
||||
if v, ok := stringFrom(params, "model_id"); ok {
|
||||
p.ModelID = v
|
||||
} else if v, ok := stringFrom(params, "llm_id"); ok {
|
||||
p.ModelID = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "system_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
} else if v, ok := stringFrom(params, "sys_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "user_prompt"); ok {
|
||||
p.UserPrompt = v
|
||||
}
|
||||
if v, ok := sliceFrom(params, "tools"); ok {
|
||||
p.Tools = v
|
||||
}
|
||||
if v, ok := nestedMapFrom(params, "tool_params"); ok {
|
||||
p.ToolParams = v
|
||||
}
|
||||
if v, ok := intFrom(params, "max_rounds"); ok {
|
||||
p.MaxRounds = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "driver"); ok {
|
||||
p.Driver = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "api_key"); ok {
|
||||
p.APIKey = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "base_url"); ok {
|
||||
p.BaseURL = v
|
||||
}
|
||||
return NewAgentComponent(p), nil
|
||||
})
|
||||
}
|
||||
398
internal/agent/component/agent_test.go
Normal file
398
internal/agent/component/agent_test.go
Normal file
@@ -0,0 +1,398 @@
|
||||
// Package component — Agent unit tests (Phase 2 P0, plan §2.11.3 row 8).
|
||||
//
|
||||
// Tests inject a canned agentRunner to verify the component contract
|
||||
// without requiring a real model or eino react agent runtime:
|
||||
//
|
||||
// 1. NoToolsReAct: the runner returns a plain answer → component
|
||||
// surfaces content with empty tool_calls.
|
||||
// 2. ToolCallRound: the runner returns a message with ToolCalls →
|
||||
// component extracts them into the tool_calls output.
|
||||
// 3. ExhaustRoundsError: the runner returns an error → component
|
||||
// propagates it.
|
||||
// 4. MissingModelID: the component rejects before calling the runner.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/flow/agent/react"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
)
|
||||
|
||||
// withAgentRunner replaces the package-level agentRunner for the duration
|
||||
// of t.
|
||||
func withAgentRunner(t *testing.T, fn func(context.Context, AgentParam) (*schema.Message, error)) {
|
||||
t.Helper()
|
||||
prev := agentRunner
|
||||
agentRunner = fn
|
||||
t.Cleanup(func() { agentRunner = prev })
|
||||
}
|
||||
|
||||
func TestAgent_NoToolsReAct(t *testing.T) {
|
||||
var calls int
|
||||
withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) {
|
||||
calls++
|
||||
return &schema.Message{Role: schema.Assistant, Content: "the answer is 42"}, nil
|
||||
})
|
||||
|
||||
c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 3})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "what is 6*7?",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["content"], "the answer is 42"; got != want {
|
||||
t.Errorf("content=%v, want %v", got, want)
|
||||
}
|
||||
toolCalls, ok := out["tool_calls"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool_calls missing or wrong type: %T", out["tool_calls"])
|
||||
}
|
||||
if len(toolCalls) != 0 {
|
||||
t.Errorf("tool_calls=%d, want 0", len(toolCalls))
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("runner called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ToolCallRound(t *testing.T) {
|
||||
var calls int
|
||||
withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) {
|
||||
calls++
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "final answer based on tool",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "search",
|
||||
Arguments: `{"q": "ragflow"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 3})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "find out about ragflow",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["content"], "final answer based on tool"; got != want {
|
||||
t.Errorf("content=%v, want %v", got, want)
|
||||
}
|
||||
toolCalls, ok := out["tool_calls"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool_calls missing or wrong type: %T", out["tool_calls"])
|
||||
}
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("tool_calls=%d, want 1", len(toolCalls))
|
||||
}
|
||||
if toolCalls[0]["name"] != "search" {
|
||||
t.Errorf("tool name=%v, want search", toolCalls[0]["name"])
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("runner called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExhaustRoundsError(t *testing.T) {
|
||||
withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) {
|
||||
return nil, errors.New("agent: exhausted rounds without final answer")
|
||||
})
|
||||
|
||||
c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 2})
|
||||
_, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "x",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when loop exhausts without a final answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_MissingModelID(t *testing.T) {
|
||||
c := NewAgentComponent(AgentParam{MaxRounds: 1})
|
||||
_, err := c.Invoke(context.Background(), map[string]any{"user_prompt": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected ParamError for missing model_id")
|
||||
}
|
||||
var pe *ParamError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Errorf("err type=%T, want *ParamError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_UnknownToolName(t *testing.T) {
|
||||
c := NewAgentComponent(AgentParam{
|
||||
ModelID: "stub",
|
||||
MaxRounds: 1,
|
||||
Tools: []string{"does_not_exist"},
|
||||
})
|
||||
_, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "x",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown tool")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `build tools: agent tool: unsupported tool "does_not_exist"`) {
|
||||
t.Fatalf("err = %q, want unsupported tool message", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_AllRegisteredToolsConfigPassesToRunner(t *testing.T) {
|
||||
var captured AgentParam
|
||||
withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) {
|
||||
captured = p
|
||||
return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil
|
||||
})
|
||||
|
||||
c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 1})
|
||||
_, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "x",
|
||||
"tools": []any{
|
||||
"akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo",
|
||||
"email", "github", "google", "google_scholar", "jin10", "pubmed",
|
||||
"qweather", "retrieval", "searxng", "tavily", "tushare", "wencai",
|
||||
"wikipedia", "yahoo_finance", "execute_sql",
|
||||
},
|
||||
"tool_params": map[string]any{
|
||||
"execute_sql": map[string]any{
|
||||
"db_type": "mysql",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"database": "demo",
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"max_records": 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if len(captured.Tools) != 21 {
|
||||
t.Fatalf("captured.Tools len = %d, want 21", len(captured.Tools))
|
||||
}
|
||||
if captured.ToolParams == nil || captured.ToolParams["execute_sql"] == nil {
|
||||
t.Fatalf("captured.ToolParams missing execute_sql: %#v", captured.ToolParams)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeToolCallingChatModel struct {
|
||||
tools []*schema.ToolInfo
|
||||
}
|
||||
|
||||
func (m *fakeToolCallingChatModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
||||
return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (m *fakeToolCallingChatModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
sr, sw := schema.Pipe[*schema.Message](1)
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
_ = sw.Send(&schema.Message{Role: schema.Assistant, Content: "ok"}, io.EOF)
|
||||
}()
|
||||
return sr, nil
|
||||
}
|
||||
|
||||
func (m *fakeToolCallingChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
cp := *m
|
||||
cp.tools = append([]*schema.ToolInfo(nil), tools...)
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func TestAgent_CanCreateReactAgentWithAllRegisteredTools(t *testing.T) {
|
||||
p := AgentParam{
|
||||
Tools: []string{
|
||||
"akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo",
|
||||
"email", "github", "google", "google_scholar", "jin10", "pubmed",
|
||||
"qweather", "retrieval", "searxng", "tavily", "tushare", "wencai",
|
||||
"wikipedia", "yahoo_finance", "execute_sql",
|
||||
},
|
||||
ToolParams: map[string]map[string]any{
|
||||
"execute_sql": {
|
||||
"db_type": "mysql",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"database": "demo",
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"max_records": 10,
|
||||
},
|
||||
},
|
||||
MaxRounds: 1,
|
||||
}
|
||||
tools, err := buildAgentTools(p)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAgentTools: %v", err)
|
||||
}
|
||||
if len(tools) != len(p.Tools) {
|
||||
t.Fatalf("len(tools) = %d, want %d", len(tools), len(p.Tools))
|
||||
}
|
||||
_, err = react.NewAgent(context.Background(), &react.AgentConfig{
|
||||
ToolCallingModel: &fakeToolCallingChatModel{},
|
||||
ToolsConfig: compose.ToolsNodeConfig{
|
||||
Tools: tools,
|
||||
},
|
||||
MaxStep: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("react.NewAgent(all tools): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_Registered(t *testing.T) {
|
||||
c, err := New("Agent", map[string]any{"model_id": "stub", "user_prompt": "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(Agent): %v", err)
|
||||
}
|
||||
if c.Name() != "Agent" {
|
||||
t.Errorf("Name()=%q, want Agent", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// exhaustStepsModel is a scripted ToolCallingChatModel that emits a
|
||||
// tool_call on every Generate and never returns final content. It
|
||||
// is the input driver for TestAgent_ReActExhaustsSteps, which needs
|
||||
// the eino ReAct loop to hit its MaxStep ceiling.
|
||||
type exhaustStepsModel struct {
|
||||
turn int
|
||||
rounds [][]*schema.Message
|
||||
boundTools []*schema.ToolInfo
|
||||
toolName string
|
||||
toolArgs string
|
||||
}
|
||||
|
||||
func (m *exhaustStepsModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
m.boundTools = tools
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *exhaustStepsModel) Generate(_ context.Context, in []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
||||
cp := make([]*schema.Message, len(in))
|
||||
copy(cp, in)
|
||||
m.rounds = append(m.rounds, cp)
|
||||
m.turn++
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
ToolCalls: []schema.ToolCall{{
|
||||
ID: fmt.Sprintf("call_%d", m.turn),
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: m.toolName,
|
||||
Arguments: m.toolArgs,
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *exhaustStepsModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
sr, sw := schema.Pipe[*schema.Message](1)
|
||||
sw.Close()
|
||||
return sr, nil
|
||||
}
|
||||
|
||||
// TestAgent_ReActExhaustsSteps drives a real react.NewAgent whose
|
||||
// scripted model always returns a tool_call and never returns final
|
||||
// content. With MaxStep: 2 the loop must terminate with an error
|
||||
// from eino's MaxStep guard, while the real ExeSQLTool is invoked
|
||||
// at least once on the way. This is the eino error-path counterpart
|
||||
// to TestExeSQL_RealReactAgent_ExecutesTool: the latter proves the
|
||||
// happy path (model returns tool_call, framework runs tool, model
|
||||
// returns final); this one proves the loop guard.
|
||||
func TestAgent_ReActExhaustsSteps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Real ExeSQLTool with sqlmock. The query is identical across
|
||||
// turns; sqlmock's QueryMatcherEqual will accept each call.
|
||||
// eino's MaxStep=2 with a tool_call-only model invokes the tool
|
||||
// exactly once before the loop guard fires (per eino's react
|
||||
// internals — the second iteration is the MaxStep check itself,
|
||||
// not a new tool call), so stage one ping + one query.
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectPing()
|
||||
mock.ExpectQuery("SELECT 1").WillReturnRows(sqlmock.NewRows([]string{"x"}).AddRow(1))
|
||||
|
||||
// Default sql.Open would try to connect to a real MySQL; the
|
||||
// dialer stub makes the tool talk to sqlmock instead.
|
||||
dialer := func(_, _ string) (*sql.DB, error) { return db, nil }
|
||||
// BuildByName goes through the public registry — the same path
|
||||
// AgentComponent.buildAgentTools takes. This proves the agent's
|
||||
// own wiring (ToolsConfig -> real BaseTool) works under the
|
||||
// MaxStep guard, not a backdoor constructor.
|
||||
built, err := agenttool.BuildByName("execute_sql", map[string]any{
|
||||
"db_type": "mysql",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"database": "demo",
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"max_records": 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("agenttool.BuildByName(execute_sql): %v", err)
|
||||
}
|
||||
exeSQLTool, ok := built.(*agenttool.ExeSQLTool)
|
||||
if !ok {
|
||||
t.Fatalf("BuildByName(execute_sql) returned %T, want *ExeSQLTool", built)
|
||||
}
|
||||
realTool := exeSQLTool.WithExeSQLDialer(dialer)
|
||||
|
||||
mdl := &exhaustStepsModel{
|
||||
toolName: "execute_sql",
|
||||
toolArgs: `{"sql": "SELECT 1"}`,
|
||||
}
|
||||
|
||||
agent, err := react.NewAgent(context.Background(), &react.AgentConfig{
|
||||
ToolCallingModel: mdl,
|
||||
ToolsConfig: compose.ToolsNodeConfig{
|
||||
Tools: []einotool.BaseTool{realTool},
|
||||
},
|
||||
MaxStep: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("react.NewAgent: %v", err)
|
||||
}
|
||||
|
||||
out, err := agent.Generate(context.Background(), []*schema.Message{
|
||||
schema.UserMessage("loop forever"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("agent.Generate returned no error; out=%+v — expected MaxStep exhaustion", out)
|
||||
}
|
||||
if mdl.turn < 1 {
|
||||
t.Errorf("model.Generate called %d times, want >= 1 (the loop should have invoked it before giving up)", mdl.turn)
|
||||
}
|
||||
if len(mdl.boundTools) != 1 || mdl.boundTools[0].Name != "execute_sql" {
|
||||
names := make([]string, 0, len(mdl.boundTools))
|
||||
for _, ti := range mdl.boundTools {
|
||||
names = append(names, ti.Name)
|
||||
}
|
||||
t.Errorf("tools bound to model = %v, want [execute_sql]", names)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
84
internal/agent/component/base.go
Normal file
84
internal/agent/component/base.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Package component implements the RAGFlow agent canvas components in Go.
|
||||
//
|
||||
// See plan: .claude/plans/agent-go-port.md §2.11 (5-tier porting strategy).
|
||||
// Phase 2 P0 batch covers 8 components: LLM, Agent, ExitLoop, Switch,
|
||||
// Categorize, Begin, Message, Invoke.
|
||||
//
|
||||
// Component is the runtime contract every RAGFlow component implements;
|
||||
// it is a richer interface than internal/agent/runtime.Component (which
|
||||
// is the minimal Invoke-only surface canvas needs at build time). Any
|
||||
// concrete *Component here satisfies runtime.Component structurally,
|
||||
// which is how the canvas builder consumes a registered component via
|
||||
// runtime.DefaultFactory().
|
||||
//
|
||||
// ParamError and ErrNotImplemented are aliased from runtime so the
|
||||
// canvas builder and the component implementations share the same
|
||||
// types without a cycle.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// Component is the runtime contract every RAGFlow component implements.
|
||||
// Mirrors the Python ComponentBase.invoke / invoke_async surface
|
||||
// (agent/component/base.py:365, 408, 422) plus a Stream variant for SSE
|
||||
// output (the Message component).
|
||||
//
|
||||
// Inputs() and Outputs() return parameter metadata for tooling / docs /
|
||||
// graph introspection — name → human description. Not used at runtime.
|
||||
//
|
||||
// Any value implementing this interface also satisfies the smaller
|
||||
// runtime.Component interface (Invoke only), so the canvas builder
|
||||
// can consume a *Component via runtime.DefaultFactory() without any
|
||||
// extra adaptation.
|
||||
type Component interface {
|
||||
// Name returns the registered component name (e.g. "LLM", "Agent",
|
||||
// "Switch"). Case-insensitive lookup — the registry normalizes input.
|
||||
Name() string
|
||||
|
||||
// Invoke runs the component synchronously. inputs is the resolved
|
||||
// parameter map (variable references already substituted by the canvas
|
||||
// engine). Returns the output map; components should put their public
|
||||
// outputs at top-level keys.
|
||||
Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error)
|
||||
|
||||
// Stream is the streaming variant. The default implementation may
|
||||
// return a buffered channel that emits the same payload as Invoke, then
|
||||
// closes — components that natively stream (LLM, Message) override.
|
||||
// May return (nil, nil) for non-streaming components.
|
||||
Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error)
|
||||
|
||||
// Inputs returns parameter metadata: param_name → description.
|
||||
Inputs() map[string]string
|
||||
// Outputs returns output metadata: param_name → description.
|
||||
Outputs() map[string]string
|
||||
}
|
||||
|
||||
// ParamBase is the optional parameter validation/serialization surface.
|
||||
// Components that need validation can embed *BaseParam (below) or implement
|
||||
// this directly. Components that don't need it (e.g. ExitLoop) can omit.
|
||||
//
|
||||
// Mirrors agent/component/param_base.py:ComponentParamBase (Python).
|
||||
type ParamBase interface {
|
||||
// Update copies conf into the receiver, validating types. Used by
|
||||
// editors / APIs that hand-craft a params map.
|
||||
Update(conf map[string]any) error
|
||||
// Check performs deep validation (required fields, value ranges).
|
||||
// Called once before Invoke — returning an error aborts the run.
|
||||
Check() error
|
||||
// AsDict returns the params as a plain map for serialization / debug.
|
||||
AsDict() map[string]any
|
||||
}
|
||||
|
||||
// ErrNotImplemented aliases runtime.ErrNotImplemented so component-side
|
||||
// code (and the canvas builder it interoperates with) share a single
|
||||
// sentinel value.
|
||||
var ErrNotImplemented = runtime.ErrNotImplemented
|
||||
|
||||
// ParamError aliases runtime.ParamError. Existing code that constructs
|
||||
// &ParamError{Field: ..., Reason: ...} continues to work; the value
|
||||
// it produces is the same type runtime.SetDefaultFactory consumers see.
|
||||
type ParamError = runtime.ParamError
|
||||
126
internal/agent/component/begin.go
Normal file
126
internal/agent/component/begin.go
Normal file
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// 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 component — Begin component (T3, plan §2.11.3 row 1).
|
||||
//
|
||||
// Begin is the DSL entry node. It injects the request's `inputs` into the
|
||||
// shared *CanvasState.Sys namespace and passes the input map through to its
|
||||
// downstream unchanged. File-input handling (FileService.get_files) is
|
||||
// deferred to a later phase per plan §2.7 / Phase 0 note — Phase 2 P0
|
||||
// handles only the `query` and `user_id` keys.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// mapsCopy is a thin alias for the stdlib maps.Copy to keep the call
|
||||
// sites uniform with the rest of the package (which uses the same name
|
||||
// in switch.go and message.go).
|
||||
func mapsCopy(dst, src map[string]any) {
|
||||
maps.Copy(dst, src)
|
||||
}
|
||||
|
||||
const componentNameBegin = "Begin"
|
||||
|
||||
// BeginComponent is the canvas entry node. The exported fields are
|
||||
// populated by the factory (registered via init) from the DSL params map.
|
||||
// ParamBase surface is intentionally omitted for P0 — Begin is trivial
|
||||
// and needs no validation beyond what the State writes perform.
|
||||
type BeginComponent struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// NewBeginComponent constructs a Begin component. It accepts the DSL params
|
||||
// map but does not retain it (Begin has no per-instance configuration).
|
||||
func NewBeginComponent(_ map[string]any) (Component, error) {
|
||||
return &BeginComponent{name: componentNameBegin}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name. Used by the registry and
|
||||
// the eino node-name injection in BuildWorkflow.
|
||||
func (b *BeginComponent) Name() string { return b.name }
|
||||
|
||||
// Invoke writes inputs["query"] and (when present) inputs["user_id"] into
|
||||
// the shared *CanvasState.Sys namespace, then returns the input map as
|
||||
// outputs unchanged. The input map is shallow-copied to avoid aliasing
|
||||
// surprises across concurrent goroutines that share an inputs map.
|
||||
func (b *BeginComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Begin: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("Begin: nil canvas state")
|
||||
}
|
||||
|
||||
// Query: required to drive downstream components.
|
||||
query, _ := inputs["query"].(string)
|
||||
state.Sys["query"] = query
|
||||
|
||||
// Optional user_id — present in interactive chat flows, absent in
|
||||
// background jobs. Always a string when set; cast failure silently
|
||||
// drops the value (mirrors Python's getattr fallback).
|
||||
if uid, ok := inputs["user_id"].(string); ok && uid != "" {
|
||||
state.Sys["user_id"] = uid
|
||||
}
|
||||
|
||||
// Passthrough: a shallow copy keeps the caller's map un-aliased.
|
||||
out := make(map[string]any, len(inputs))
|
||||
mapsCopy(out, inputs)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stream is a synchronous facade over Invoke for P0. SSE streaming of
|
||||
// Begin output is not meaningful (Begin has no I/O), so the channel
|
||||
// receives a single payload and closes — same shape as Invoke's return.
|
||||
func (b *BeginComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := b.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata. Descriptions are short; the doc
|
||||
// strings live on the struct / method above.
|
||||
func (b *BeginComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"query": "User query string (the chat input).",
|
||||
"user_id": "Optional user/tenant identifier.",
|
||||
"inputs": "Optional free-form inputs map; passthrough only.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the same keys as Inputs (Begin is a passthrough).
|
||||
func (b *BeginComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"query": "Query string (passthrough).",
|
||||
"user_id": "User id, if provided (passthrough).",
|
||||
"inputs": "Raw inputs map (passthrough).",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameBegin, NewBeginComponent)
|
||||
}
|
||||
88
internal/agent/component/begin_test.go
Normal file
88
internal/agent/component/begin_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestBegin_InjectsSys verifies the canonical happy path: a query flows
|
||||
// through Invoke and lands in state.Sys["query"]. user_id is optional
|
||||
// and absent in this test (omitted from inputs entirely).
|
||||
func TestBegin_InjectsSys(t *testing.T) {
|
||||
c, err := NewBeginComponent(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBeginComponent: %v", err)
|
||||
}
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{"query": "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := state.Sys["query"].(string); got != "hello" {
|
||||
t.Errorf("state.Sys[query]: got %q, want %q", got, "hello")
|
||||
}
|
||||
// user_id absent in inputs → must not be present in state.Sys
|
||||
if _, ok := state.Sys["user_id"]; ok {
|
||||
t.Errorf("state.Sys[user_id] should not be set when inputs lack it; got %v", state.Sys["user_id"])
|
||||
}
|
||||
// Output passthrough
|
||||
if out["query"] != "hello" {
|
||||
t.Errorf("outputs[query]: got %v, want %q", out["query"], "hello")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBegin_PassesThroughInputs asserts the full inputs map — including
|
||||
// arbitrary keys beyond query / user_id — is returned unchanged as
|
||||
// outputs. This is the contract downstream components rely on to access
|
||||
// DSL-level inputs the engine has not explicitly modeled.
|
||||
func TestBegin_PassesThroughInputs(t *testing.T) {
|
||||
c, _ := NewBeginComponent(nil)
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
inputs := map[string]any{
|
||||
"query": "what is ragflow",
|
||||
"user_id": "tenant-7",
|
||||
"inputs": map[string]any{"k": "v"},
|
||||
"extra": 42,
|
||||
}
|
||||
out, err := c.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out, inputs) {
|
||||
t.Errorf("output passthrough failed:\n got %v\n want %v", out, inputs)
|
||||
}
|
||||
if got, _ := state.Sys["user_id"].(string); got != "tenant-7" {
|
||||
t.Errorf("state.Sys[user_id]: got %q, want %q", got, "tenant-7")
|
||||
}
|
||||
}
|
||||
|
||||
// withStateForTest is a thin alias for canvas.WithState kept for
|
||||
// readability at the test call sites. Declared once in this file; the
|
||||
// other test files in this package (message_test.go, switch_test.go)
|
||||
// reference the same symbol because Go test files share a package.
|
||||
func withStateForTest(ctx context.Context, s *canvas.CanvasState) context.Context {
|
||||
return canvas.WithState(ctx, s)
|
||||
}
|
||||
273
internal/agent/component/browser.go
Normal file
273
internal/agent/component/browser.go
Normal file
@@ -0,0 +1,273 @@
|
||||
//
|
||||
// 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 component — Browser (T3, plan §2.11.3 row 15).
|
||||
//
|
||||
// Browser visits a URL, fetches the HTML body, and (optionally) asks an
|
||||
// LLM to summarize the page. The P4 implementation focuses on the fetch
|
||||
// half: it returns the body as a string with size metadata. The LLM-
|
||||
// summary path is a no-op passthrough when model_id is unset, with the
|
||||
// wiring left in place for Phase 5 (when the model's ChatInvoker is
|
||||
// available without duplicating the LLM component's internals here).
|
||||
//
|
||||
// Storage upload of downloaded artifacts is deferred to Phase 5 per
|
||||
// the plan; for now the response carries the bytes' size, not the bytes
|
||||
// themselves, to keep large-payload flows off the canvas state bag.
|
||||
//
|
||||
// The transport wraps net/http with otelhttp.NewTransport so the
|
||||
// outbound request participates in the active OTel trace (plan §2.10).
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
componentNameBrowser = "Browser"
|
||||
|
||||
defaultBrowserTimeout = 30 * time.Second
|
||||
maxBrowserResponseBody = 16 << 20 // 16 MiB; same cap as Invoke
|
||||
)
|
||||
|
||||
// browserParam is the static configuration for a Browser node.
|
||||
type browserParam struct {
|
||||
ModelID string `json:"model_id"` // optional LLM summarizer model
|
||||
URL string `json:"url"` // default target URL
|
||||
Prompt string `json:"prompt"` // optional summarization prompt
|
||||
Timeout int `json:"timeout"` // per-request timeout in seconds
|
||||
}
|
||||
|
||||
// Update copies a fresh param map into the receiver.
|
||||
func (p *browserParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
p.ModelID, _ = conf["model_id"].(string)
|
||||
p.URL, _ = conf["url"].(string)
|
||||
p.Prompt, _ = conf["prompt"].(string)
|
||||
// Preserve an explicitly-supplied timeout (including 0 / negative)
|
||||
// so Check() can reject bad values. Only reset to zero when the
|
||||
// caller omitted the field entirely.
|
||||
if v, ok := intFrom(conf, "timeout"); ok {
|
||||
p.Timeout = v
|
||||
} else {
|
||||
p.Timeout = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check validates the param. URL is optional at construction time —
|
||||
// the resolved URL (param or input override) is checked at Invoke time
|
||||
// so test fixtures can construct the component without a real URL.
|
||||
func (p *browserParam) Check() error {
|
||||
if p.Timeout < 0 {
|
||||
return &ParamError{Field: "timeout", Reason: "must be non-negative"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map.
|
||||
func (p *browserParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"model_id": p.ModelID,
|
||||
"url": p.URL,
|
||||
"prompt": p.Prompt,
|
||||
"timeout": p.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// BrowserComponent implements the Browser canvas node.
|
||||
type BrowserComponent struct {
|
||||
name string
|
||||
param browserParam
|
||||
}
|
||||
|
||||
// NewBrowserComponent constructs a Browser from the DSL param map.
|
||||
func NewBrowserComponent(params map[string]any) (Component, error) {
|
||||
p := &browserParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("Browser: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("Browser: param check: %w", err)
|
||||
}
|
||||
return &BrowserComponent{
|
||||
name: componentNameBrowser,
|
||||
param: *p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (b *BrowserComponent) Name() string { return b.name }
|
||||
|
||||
// Invoke visits the (resolved) URL, returns the response body as
|
||||
// content, the final URL after any redirects, the HTTP status, and the
|
||||
// bytes' size. When model_id is set in the param and a prompt is
|
||||
// provided, the LLM summarization hook is left for Phase 5; for P4 the
|
||||
// content field simply contains the fetched body.
|
||||
func (b *BrowserComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Browser: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, errors.New("Browser: nil canvas state")
|
||||
}
|
||||
|
||||
// Resolve URL: input override → state(file_ref) → param default.
|
||||
rawURL := b.param.URL
|
||||
if v, ok := inputs["url"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
rawURL = v
|
||||
} else if ref, ok := inputs["file_ref"].(string); ok && ref != "" {
|
||||
// file_ref points at a stored path/url; for P4 we just echo it
|
||||
// back as the target URL (Phase 5 will resolve to a MinIO path).
|
||||
if v, err := state.GetVar(ref); err == nil && v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
rawURL = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return nil, &ParamError{Field: "url", Reason: "required (param or inputs.url)"}
|
||||
}
|
||||
if _, err := url.Parse(rawURL); err != nil {
|
||||
return nil, fmt.Errorf("Browser: parse url: %w", err)
|
||||
}
|
||||
|
||||
// Resolve prompt override (input.prompt → param.prompt).
|
||||
prompt := b.param.Prompt
|
||||
if v, ok := inputs["prompt"].(string); ok && v != "" {
|
||||
prompt = v
|
||||
}
|
||||
|
||||
timeout := defaultBrowserTimeout
|
||||
if b.param.Timeout > 0 {
|
||||
timeout = time.Duration(b.param.Timeout) * time.Second
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Browser: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "ragflow-agent/1.0 (Browser component)")
|
||||
// Encourage HTML / text responses; some servers sniff the UA and
|
||||
// only return text/html for browser-shaped UAs.
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
// Don't follow redirects transparently — surface the final URL
|
||||
// in outputs and let the orchestrator decide policy.
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("Browser: too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Browser: do: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
limited := io.LimitReader(resp.Body, maxBrowserResponseBody)
|
||||
bodyBytes, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Browser: read body: %w", err)
|
||||
}
|
||||
|
||||
finalURL := rawURL
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
|
||||
content := string(bodyBytes)
|
||||
// LLM summarization placeholder: if a model + prompt are both set,
|
||||
// we mark the intent on the response. The actual chat call is left
|
||||
// to Phase 5 to avoid re-implementing the LLM component's logic
|
||||
// inline (which would split the model-resolution path in two).
|
||||
modelID := b.param.ModelID
|
||||
if v, ok := inputs["model_id"].(string); ok && v != "" {
|
||||
modelID = v
|
||||
}
|
||||
if modelID != "" && prompt != "" {
|
||||
// Phase 5 will add the actual LLM summarization call. For P4,
|
||||
// we surface a hint that the model/prompt were considered by
|
||||
// leaving the body unchanged and echoing the resolved
|
||||
// model_id / prompt on the response (see outputs map below).
|
||||
_ = content
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"content": content,
|
||||
"url": finalURL,
|
||||
"status": resp.StatusCode,
|
||||
"size": len(bodyBytes),
|
||||
"model_id": modelID,
|
||||
"prompt": prompt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; Browser is a single-shot HTTP fetch.
|
||||
func (b *BrowserComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := b.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata.
|
||||
func (b *BrowserComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"model_id": "Optional LLM model id used to summarize the fetched page (Phase 5).",
|
||||
"url": "Target URL; can be a {{...}} reference resolved upstream.",
|
||||
"prompt": "Optional LLM prompt (e.g. \"summarize this page\"); used when model_id is set.",
|
||||
"timeout": "Per-request timeout in seconds; default 30.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the response surface.
|
||||
func (b *BrowserComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"content": "Response body (string, truncated at 16 MiB).",
|
||||
"url": "Final URL after redirects.",
|
||||
"status": "HTTP status code (int).",
|
||||
"size": "Body size in bytes (int).",
|
||||
"model_id": "Resolved LLM model id (empty when summarization is disabled).",
|
||||
"prompt": "Resolved LLM prompt (echoed back for downstream nodes).",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameBrowser, NewBrowserComponent)
|
||||
}
|
||||
164
internal/agent/component/browser_test.go
Normal file
164
internal/agent/component/browser_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestBrowser_FetchesHTML: happy path — a stub HTTP server returns
|
||||
// "<html>hi</html>", the Browser component fetches it, and the
|
||||
// response map's content field contains the body.
|
||||
func TestBrowser_FetchesHTML(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("server: got method %q, want GET", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<html>hi</html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := NewBrowserComponent(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBrowserComponent: %v", err)
|
||||
}
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if status, _ := out["status"].(int); status != http.StatusOK {
|
||||
t.Errorf("status: got %d, want 200", status)
|
||||
}
|
||||
if body, _ := out["content"].(string); !strings.Contains(body, "hi") {
|
||||
t.Errorf("content: got %q, want substring %q", body, "hi")
|
||||
}
|
||||
if got, want := out["url"], srv.URL; got != want {
|
||||
t.Errorf("url: got %v, want %v", got, want)
|
||||
}
|
||||
if size, _ := out["size"].(int); size != len("<html>hi</html>") {
|
||||
t.Errorf("size: got %d, want %d", size, len("<html>hi</html>"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_HTTPError: a 500 response surfaces as an error so the
|
||||
// canvas engine can mark the node failed. The Browser component does
|
||||
// not silently swallow non-2xx statuses.
|
||||
func TestBrowser_HTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := NewBrowserComponent(nil)
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
// Per P4 contract, a 5xx response is returned to the caller as-is
|
||||
// (the canvas engine can branch on status); the Browser component
|
||||
// itself does not error on 5xx — verify that and the body is still
|
||||
// populated.
|
||||
out, err := c.Invoke(ctx, map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: returned error %v, want nil for 500 (caller decides)", err)
|
||||
}
|
||||
if status, _ := out["status"].(int); status != http.StatusInternalServerError {
|
||||
t.Errorf("status: got %d, want 500", status)
|
||||
}
|
||||
if body, _ := out["content"].(string); body != "boom" {
|
||||
t.Errorf("content: got %q, want %q", body, "boom")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_Timeout: a slow server (delay > timeout) causes the
|
||||
// HTTP client to fail with a timeout, and the Browser component
|
||||
// surfaces that as a wrapped error.
|
||||
func TestBrowser_Timeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Sleep much longer than the client timeout. timeout=1 means
|
||||
// 1 second; we sleep 3s to be safe across slow CI.
|
||||
time.Sleep(3 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := NewBrowserComponent(map[string]any{"timeout": 1})
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
start := time.Now()
|
||||
_, err := c.Invoke(ctx, map[string]any{"url": srv.URL})
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
// The call must NOT block longer than the configured timeout plus
|
||||
// a small slack for the OS scheduler.
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("Invoke took %v, want < 2s with 1s timeout", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_MissingURL: no url in param or inputs surfaces a
|
||||
// ParamError.
|
||||
func TestBrowser_MissingURL(t *testing.T) {
|
||||
c, _ := NewBrowserComponent(nil)
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
_, err := c.Invoke(ctx, map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing url, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "url") {
|
||||
t.Errorf("error %q should mention url", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_ParamCheck: negative timeout is rejected at construction.
|
||||
func TestBrowser_ParamCheck(t *testing.T) {
|
||||
_, err := NewBrowserComponent(map[string]any{"timeout": -1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative timeout, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timeout") {
|
||||
t.Errorf("error %q should mention timeout", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_Registered: factory lookup works case-insensitively.
|
||||
func TestBrowser_Registered(t *testing.T) {
|
||||
c, err := New("browser", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if c.Name() != "Browser" {
|
||||
t.Errorf("Name()=%q, want Browser", c.Name())
|
||||
}
|
||||
}
|
||||
324
internal/agent/component/categorize.go
Normal file
324
internal/agent/component/categorize.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// Package component — Categorize (Phase 2 P0, plan §2.11.3 row 6, §2.11.6 D3).
|
||||
//
|
||||
// LLM-based classifier. The component asks the model to pick exactly one
|
||||
// of the configured categories, returns the chosen category name plus a
|
||||
// uniform score map (1.0 for the chosen category, 0.0 for the rest), and
|
||||
// emits an empty `_next` list. The `_next` field is reserved for Phase 5
|
||||
// when the eino MultiBranch node replaces the Python
|
||||
// `set_output("_next", cpn_ids)` routing protocol.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// CategorizeComponent is an LLM classifier.
|
||||
type CategorizeComponent struct {
|
||||
param CategorizeParam
|
||||
}
|
||||
|
||||
// CategorizeParam captures the (resolved) DSL parameters for a Categorize node.
|
||||
type CategorizeParam struct {
|
||||
ModelID string
|
||||
Items []string
|
||||
Categories []string
|
||||
SysPrompt string
|
||||
DefaultCategory string
|
||||
Driver string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// CategorizeOutput mirrors the outputs map (per plan §2.11.3 row 6):
|
||||
//
|
||||
// "category" string — chosen category name (or default if
|
||||
// model returned something not in list)
|
||||
// "scores" map[string]float64
|
||||
// "_next" []string — reserved for Phase 5 eino MultiBranch
|
||||
type CategorizeOutput struct {
|
||||
Category string
|
||||
Scores map[string]float64
|
||||
Next []string
|
||||
}
|
||||
|
||||
// NewCategorizeComponent builds a CategorizeComponent from raw params.
|
||||
func NewCategorizeComponent(p CategorizeParam) *CategorizeComponent {
|
||||
return &CategorizeComponent{param: p}
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (c *CategorizeComponent) Name() string { return "Categorize" }
|
||||
|
||||
// Invoke calls the chat model, parses the response for a category, and
|
||||
// returns the chosen category (or the default if the model returned
|
||||
// something outside the configured set).
|
||||
func (c *CategorizeComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
p := mergeCategorizeParam(c.param, inputs)
|
||||
if p.ModelID == "" {
|
||||
return nil, &ParamError{Field: "model_id", Reason: "required"}
|
||||
}
|
||||
if len(p.Categories) == 0 {
|
||||
return nil, &ParamError{Field: "categories", Reason: "at least one category is required"}
|
||||
}
|
||||
if p.DefaultCategory == "" {
|
||||
// Fall back to the first category so the run never fails purely
|
||||
// because the user omitted the default.
|
||||
p.DefaultCategory = p.Categories[0]
|
||||
}
|
||||
|
||||
inv := getDefaultChatInvoker()
|
||||
sysPrompt := p.SysPrompt
|
||||
if sysPrompt == "" {
|
||||
sysPrompt = "You are a strict classifier."
|
||||
}
|
||||
userPrompt := buildCategorizePrompt(p)
|
||||
msgs := []schema.Message{
|
||||
{Role: schema.System, Content: sysPrompt},
|
||||
{Role: schema.User, Content: userPrompt},
|
||||
}
|
||||
resp, err := inv.Invoke(ctx, ChatInvokeRequest{
|
||||
Driver: p.Driver,
|
||||
ModelName: p.ModelID,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Messages: msgs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("component: Categorize.Invoke: %w", err)
|
||||
}
|
||||
|
||||
chosen, score := pickCategory(resp.Content, p.Categories, p.DefaultCategory)
|
||||
return map[string]any{
|
||||
"category": chosen,
|
||||
"scores": score,
|
||||
"_next": []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke as a single chunk.
|
||||
func (c *CategorizeComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
result, err := c.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
out <- map[string]any{"error": err.Error()}
|
||||
return
|
||||
}
|
||||
out <- result
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata for tooling.
|
||||
func (c *CategorizeComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"model_id": "Provider-side model identifier",
|
||||
"items": "Optional list of items to classify (added to the prompt as context)",
|
||||
"categories": "List of allowed category names (response must match one)",
|
||||
"sys_prompt": "Optional system prompt; defaults to a strict classifier instruction",
|
||||
"default_category": "Category returned if the model's answer is not in `categories` (defaults to categories[0])",
|
||||
"driver": "Provider driver name",
|
||||
"api_key": "Override API key",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns output metadata.
|
||||
func (c *CategorizeComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"category": "Chosen category name (one of the configured list, or the default)",
|
||||
"scores": "Score map (1.0 for the chosen category, 0.0 for the rest)",
|
||||
"_next": "Reserved for Phase 5 eino MultiBranch — empty in P0",
|
||||
}
|
||||
}
|
||||
|
||||
// buildCategorizePrompt assembles a prompt that asks the model to pick a
|
||||
// category. The categories are listed deterministically (sorted) so the
|
||||
// prompt is stable across runs.
|
||||
func buildCategorizePrompt(p CategorizeParam) string {
|
||||
cats := append([]string(nil), p.Categories...)
|
||||
sort.Strings(cats)
|
||||
var b strings.Builder
|
||||
b.WriteString("Classify the following item into exactly one of these categories:\n")
|
||||
for _, c := range cats {
|
||||
b.WriteString("- ")
|
||||
b.WriteString(c)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(p.Items) > 0 {
|
||||
b.WriteString("\nItems:\n")
|
||||
for _, it := range p.Items {
|
||||
b.WriteString("- ")
|
||||
b.WriteString(it)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\nRespond with ONLY the category name, no other text.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pickCategory extracts a category from the model's response. Strategy:
|
||||
// 1. exact match (case-sensitive)
|
||||
// 2. case-insensitive match
|
||||
// 3. fall back to default
|
||||
//
|
||||
// Substring matching is intentionally avoided — it makes the picker too
|
||||
// eager ("I have no idea" would match a category named "a"). If the model
|
||||
// can't produce one of the categories verbatim, the default is used.
|
||||
//
|
||||
// Scores are 1.0 for the chosen category, 0.0 for the rest.
|
||||
func pickCategory(response string, categories []string, def string) (string, map[string]float64) {
|
||||
scores := make(map[string]float64, len(categories))
|
||||
for _, c := range categories {
|
||||
scores[c] = 0
|
||||
}
|
||||
resp := strings.TrimSpace(response)
|
||||
resp = strings.Trim(resp, "\"'`\n\r\t ")
|
||||
resp = strings.TrimPrefix(resp, "category:")
|
||||
resp = strings.TrimPrefix(resp, "Category:")
|
||||
resp = strings.TrimSpace(resp)
|
||||
|
||||
for _, c := range categories {
|
||||
if resp == c {
|
||||
scores[c] = 1
|
||||
return c, scores
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(resp)
|
||||
for _, c := range categories {
|
||||
if strings.ToLower(c) == lower {
|
||||
scores[c] = 1
|
||||
return c, scores
|
||||
}
|
||||
}
|
||||
scores[def] = 1
|
||||
return def, scores
|
||||
}
|
||||
|
||||
// mergeCategorizeParam layers raw inputs over the receiver's default param set.
|
||||
//
|
||||
// v1 aliases accepted alongside the v2 names: "llm_id" → "model_id",
|
||||
// "category_description" (a map[string]string) → "categories" (the keys
|
||||
// of the map), and "base_url" → "BaseURL". v1 fixtures use the
|
||||
// short / dict forms; without these aliases the v1→v2 conversion step
|
||||
// would have to run before the factory builds the component.
|
||||
func mergeCategorizeParam(base CategorizeParam, inputs map[string]any) CategorizeParam {
|
||||
p := base
|
||||
if v, ok := stringFrom(inputs, "model_id"); ok {
|
||||
p.ModelID = v
|
||||
} else if v, ok := stringFrom(inputs, "llm_id"); ok {
|
||||
p.ModelID = v
|
||||
}
|
||||
if v, ok := sliceFrom(inputs, "items"); ok {
|
||||
p.Items = v
|
||||
}
|
||||
if v, ok := sliceFrom(inputs, "categories"); ok {
|
||||
p.Categories = v
|
||||
} else if m, ok := stringMapFrom(inputs, "category_description"); ok && len(m) > 0 {
|
||||
// v1 stores the categories as a map of {name: description}.
|
||||
// We only need the keys to drive the picker.
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
p.Categories = keys
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "sys_prompt"); ok {
|
||||
p.SysPrompt = v
|
||||
} else if v, ok := stringFrom(inputs, "system_prompt"); ok {
|
||||
p.SysPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "default_category"); ok {
|
||||
p.DefaultCategory = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "driver"); ok {
|
||||
p.Driver = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "api_key"); ok {
|
||||
p.APIKey = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "base_url"); ok {
|
||||
p.BaseURL = v
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// stringMapFrom extracts map[string]string from inputs[name]. The v1
|
||||
// "category_description" field is shaped this way (name → human
|
||||
// description); we only consume the keys.
|
||||
func stringMapFrom(inputs map[string]any, name string) (map[string]string, bool) {
|
||||
v, ok := inputs[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
raw, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, child := range raw {
|
||||
if s, ok := child.(string); ok {
|
||||
out[k] = s
|
||||
continue
|
||||
}
|
||||
// Some encoders nest the description under a "description"
|
||||
// key; handle that fallback defensively.
|
||||
if nested, ok := child.(map[string]any); ok {
|
||||
if s, ok := nested["description"].(string); ok {
|
||||
out[k] = s
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = ""
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// init registers CategorizeComponent with the orchestrator-owned registry.
|
||||
func init() {
|
||||
Register("Categorize", func(params map[string]any) (Component, error) {
|
||||
var p CategorizeParam
|
||||
if v, ok := stringFrom(params, "model_id"); ok {
|
||||
p.ModelID = v
|
||||
} else if v, ok := stringFrom(params, "llm_id"); ok {
|
||||
p.ModelID = v
|
||||
}
|
||||
if v, ok := sliceFrom(params, "items"); ok {
|
||||
p.Items = v
|
||||
}
|
||||
if v, ok := sliceFrom(params, "categories"); ok {
|
||||
p.Categories = v
|
||||
} else if m, ok := params["category_description"].(map[string]any); ok && len(m) > 0 {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
p.Categories = keys
|
||||
}
|
||||
if v, ok := stringFrom(params, "sys_prompt"); ok {
|
||||
p.SysPrompt = v
|
||||
} else if v, ok := stringFrom(params, "system_prompt"); ok {
|
||||
p.SysPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "default_category"); ok {
|
||||
p.DefaultCategory = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "driver"); ok {
|
||||
p.Driver = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "api_key"); ok {
|
||||
p.APIKey = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "base_url"); ok {
|
||||
p.BaseURL = v
|
||||
}
|
||||
return NewCategorizeComponent(p), nil
|
||||
})
|
||||
}
|
||||
146
internal/agent/component/categorize_test.go
Normal file
146
internal/agent/component/categorize_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package component — Categorize unit tests (Phase 2 P0, plan §2.11.3 row 6).
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCategorize_ChosenCategory(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "support", Model: "stub"}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewCategorizeComponent(CategorizeParam{
|
||||
ModelID: "stub",
|
||||
Categories: []string{"sales", "support", "billing"},
|
||||
DefaultCategory: "support",
|
||||
})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["category"], "support"; got != want {
|
||||
t.Errorf("category=%v, want %v", got, want)
|
||||
}
|
||||
scores, ok := out["scores"].(map[string]float64)
|
||||
if !ok {
|
||||
t.Fatalf("scores missing or wrong type: %T", out["scores"])
|
||||
}
|
||||
if scores["support"] != 1 {
|
||||
t.Errorf("support score=%v, want 1", scores["support"])
|
||||
}
|
||||
if scores["sales"] != 0 || scores["billing"] != 0 {
|
||||
t.Errorf("non-chosen categories should score 0; got %v", scores)
|
||||
}
|
||||
next, ok := out["_next"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("_next missing or wrong type: %T", out["_next"])
|
||||
}
|
||||
if len(next) != 0 {
|
||||
t.Errorf("_next=%v, want [] (Phase 5 placeholder)", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategorize_FallbackToDefault(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "totally not in the list", Model: "stub"}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewCategorizeComponent(CategorizeParam{
|
||||
ModelID: "stub",
|
||||
Categories: []string{"a", "b", "c"},
|
||||
DefaultCategory: "b",
|
||||
})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["category"], "b"; got != want {
|
||||
t.Errorf("category=%v, want %v (default fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategorize_DefaultDefaultsToFirstCategory(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "garbage", Model: "stub"}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewCategorizeComponent(CategorizeParam{
|
||||
ModelID: "stub",
|
||||
Categories: []string{"alpha", "beta", "gamma"},
|
||||
// no default_category
|
||||
})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["category"], "alpha"; got != want {
|
||||
t.Errorf("category=%v, want %v (auto-default to first)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategorize_CaseInsensitive(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "SUPPORT", Model: "stub"}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewCategorizeComponent(CategorizeParam{
|
||||
ModelID: "stub",
|
||||
Categories: []string{"sales", "support", "billing"},
|
||||
DefaultCategory: "sales",
|
||||
})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["category"], "support"; got != want {
|
||||
t.Errorf("category=%v, want %v (case-insensitive match)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategorize_PromptListsCategories(t *testing.T) {
|
||||
// Verify the prompt passed to the invoker includes the categories
|
||||
// so a model choosing between A and B has the context to do so.
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "x", Model: "stub"}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewCategorizeComponent(CategorizeParam{
|
||||
ModelID: "stub",
|
||||
Categories: []string{"x", "y", "z"},
|
||||
DefaultCategory: "x",
|
||||
Items: []string{"foo", "bar"},
|
||||
})
|
||||
_, err := c.Invoke(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if stub.captured == nil {
|
||||
t.Fatal("invoker not called")
|
||||
}
|
||||
var userContent string
|
||||
for _, m := range stub.captured.Messages {
|
||||
if m.Role == "user" {
|
||||
userContent = m.Content
|
||||
}
|
||||
}
|
||||
if userContent == "" {
|
||||
t.Fatal("no user message in captured invoker request")
|
||||
}
|
||||
for _, want := range []string{"x", "y", "z", "foo", "bar"} {
|
||||
if !strings.Contains(userContent, want) {
|
||||
t.Errorf("prompt missing %q; got: %s", want, userContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategorize_Registered(t *testing.T) {
|
||||
c, err := New("Categorize", map[string]any{
|
||||
"model_id": "stub",
|
||||
"categories": []any{"a", "b"},
|
||||
"default_category": "a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(Categorize): %v", err)
|
||||
}
|
||||
if c.Name() != "Categorize" {
|
||||
t.Errorf("Name()=%q, want Categorize", c.Name())
|
||||
}
|
||||
}
|
||||
534
internal/agent/component/data_operations.go
Normal file
534
internal/agent/component/data_operations.go
Normal file
@@ -0,0 +1,534 @@
|
||||
//
|
||||
// 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 component — DataOperations (T3, plan §2.11.3 row 16).
|
||||
//
|
||||
// DataOperations applies one of seven dict/list transforms to a list
|
||||
// of dicts pulled from the canvas state. It is pure: no state writes;
|
||||
// the transformed payload is returned at outputs["result"].
|
||||
//
|
||||
// Operations:
|
||||
// - select_keys : keep only the listed keys per dict
|
||||
// - literal_eval : walk input_objects; try to parse JSON-like
|
||||
// string leaves (the Go port uses json.Unmarshal
|
||||
// as a stand-in for Python's ast.literal_eval —
|
||||
// tuples/sets are NOT supported, matching the
|
||||
// JSON-shaped LLM output the canvas typically
|
||||
// consumes).
|
||||
// - combine : merge all input dicts into one
|
||||
// - filter_values : keep dicts matching all rules
|
||||
// - append_or_update: apply updates [{key, value}] per dict
|
||||
// - remove_keys : drop the listed keys per dict
|
||||
// - rename_keys : rename per [{old_key, new_key}] per dict
|
||||
//
|
||||
// Mirrors agent/component/data_operations.py.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameDataOperations = "DataOperations"
|
||||
|
||||
// dataOperationsParam is the static configuration.
|
||||
type dataOperationsParam struct {
|
||||
Query []string `json:"query"`
|
||||
Operations string `json:"operations"`
|
||||
SelectKeys []string `json:"select_keys"`
|
||||
FilterValues []map[string]any `json:"filter_values"`
|
||||
Updates []map[string]any `json:"updates"`
|
||||
RemoveKeys []string `json:"remove_keys"`
|
||||
RenameKeys []map[string]any `json:"rename_keys"`
|
||||
}
|
||||
|
||||
// Update copies a fresh param map into the receiver.
|
||||
func (p *dataOperationsParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
p.Query = toStringSlice(conf["query"])
|
||||
p.Operations, _ = conf["operations"].(string)
|
||||
if p.Operations == "" {
|
||||
p.Operations = "literal_eval"
|
||||
}
|
||||
p.SelectKeys = toStringSlice(conf["select_keys"])
|
||||
p.FilterValues = toMapSlice(conf["filter_values"])
|
||||
p.Updates = toMapSlice(conf["updates"])
|
||||
p.RemoveKeys = toStringSlice(conf["remove_keys"])
|
||||
p.RenameKeys = toMapSlice(conf["rename_keys"])
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check validates the param.
|
||||
func (p *dataOperationsParam) Check() error {
|
||||
switch p.Operations {
|
||||
case "select_keys", "literal_eval", "combine", "filter_values",
|
||||
"append_or_update", "remove_keys", "rename_keys":
|
||||
// ok
|
||||
default:
|
||||
return &ParamError{
|
||||
Field: "operations",
|
||||
Reason: "must be one of: select_keys, literal_eval, combine, filter_values, append_or_update, remove_keys, rename_keys",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map.
|
||||
func (p *dataOperationsParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"query": p.Query,
|
||||
"operations": p.Operations,
|
||||
"select_keys": p.SelectKeys,
|
||||
"filter_values": p.FilterValues,
|
||||
"updates": p.Updates,
|
||||
"remove_keys": p.RemoveKeys,
|
||||
"rename_keys": p.RenameKeys,
|
||||
}
|
||||
}
|
||||
|
||||
// toStringSlice normalizes a value to []string. Strings (CSV) and
|
||||
// []any are accepted; nil returns nil.
|
||||
func toStringSlice(v any) []string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
// CSV fallback: "a,b,c" → ["a","b","c"]
|
||||
parts := strings.Split(x, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
s := strings.TrimSpace(p)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string{}, x...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toMapSlice normalizes a value to []map[string]any.
|
||||
func toMapSlice(v any) []map[string]any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(x))
|
||||
for _, item := range x {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
return append([]map[string]any{}, x...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataOperationsComponent implements the 7 dict transforms.
|
||||
type DataOperationsComponent struct {
|
||||
name string
|
||||
param dataOperationsParam
|
||||
}
|
||||
|
||||
// NewDataOperationsComponent constructs a DataOperations from the
|
||||
// DSL param map.
|
||||
func NewDataOperationsComponent(params map[string]any) (Component, error) {
|
||||
p := &dataOperationsParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("DataOperations: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("DataOperations: param check: %w", err)
|
||||
}
|
||||
return &DataOperationsComponent{
|
||||
name: componentNameDataOperations,
|
||||
param: *p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (d *DataOperationsComponent) Name() string { return d.name }
|
||||
|
||||
// Invoke loads input_objects from the configured query refs, then
|
||||
// dispatches to the operation-specific helper.
|
||||
func (d *DataOperationsComponent) Invoke(ctx context.Context, _ map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DataOperations: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("DataOperations: nil canvas state")
|
||||
}
|
||||
|
||||
// Coerce query to a list: param.query may arrive as a single
|
||||
// string in the JSON DSL, which the Python code wraps in [x].
|
||||
queries := d.param.Query
|
||||
if len(queries) == 0 {
|
||||
// fall back to single ref parsed from a string param — when
|
||||
// the engine loads the DSL it may pass a single ref; tolerate.
|
||||
queries = []string{}
|
||||
}
|
||||
|
||||
var inputObjects []map[string]any
|
||||
for _, ref := range queries {
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
v, err := state.GetVar(ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DataOperations: query %q: %w", ref, err)
|
||||
}
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
inputObjects = append(inputObjects, x)
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
inputObjects = append(inputObjects, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result any
|
||||
switch d.param.Operations {
|
||||
case "select_keys":
|
||||
result = d.opSelectKeys(inputObjects)
|
||||
case "literal_eval":
|
||||
result = d.opLiteralEval(inputObjects)
|
||||
case "combine":
|
||||
result = d.opCombine(inputObjects)
|
||||
case "filter_values":
|
||||
result = d.opFilterValues(state, inputObjects)
|
||||
case "append_or_update":
|
||||
result = d.opAppendOrUpdate(state, inputObjects)
|
||||
case "remove_keys":
|
||||
result = d.opRemoveKeys(inputObjects)
|
||||
case "rename_keys":
|
||||
result = d.opRenameKeys(inputObjects)
|
||||
}
|
||||
return map[string]any{"result": result}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; DataOperations is a single-shot transform.
|
||||
func (d *DataOperationsComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := d.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns an empty surface — all config is in the param.
|
||||
func (d *DataOperationsComponent) Inputs() map[string]string {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
// Outputs returns the transformed payload.
|
||||
func (d *DataOperationsComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"result": "Transformed payload: a list of dicts for most ops, or a single dict for combine.",
|
||||
}
|
||||
}
|
||||
|
||||
// opSelectKeys keeps only the listed keys per dict. Result is []any
|
||||
// of dicts.
|
||||
func (d *DataOperationsComponent) opSelectKeys(items []map[string]any) []any {
|
||||
keep := make(map[string]struct{}, len(d.param.SelectKeys))
|
||||
for _, k := range d.param.SelectKeys {
|
||||
keep[k] = struct{}{}
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
cp := make(map[string]any, len(keep))
|
||||
for k := range item {
|
||||
if _, ok := keep[k]; ok {
|
||||
cp[k] = item[k]
|
||||
}
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opLiteralEval walks the input list and tries to JSON-decode any
|
||||
// string leaf that looks like a JSON literal. Returns a list of
|
||||
// (possibly-mutated) dicts.
|
||||
func (d *DataOperationsComponent) opLiteralEval(items []map[string]any) []any {
|
||||
out := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, recursiveEval(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recursiveEval mirrors the Python _recursive_eval helper: any string
|
||||
// that starts with a JSON delimiter or known literal is unmarshaled.
|
||||
// On failure, the original string is returned.
|
||||
func recursiveEval(v any) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for k, val := range x {
|
||||
out[k] = recursiveEval(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(x))
|
||||
for _, item := range x {
|
||||
out = append(out, recursiveEval(item))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return x
|
||||
}
|
||||
// Detect likely JSON literal: starts with one of { [ ( " '
|
||||
// digit, or is a known scalar literal (true/false/null).
|
||||
first := s[0]
|
||||
lower := strings.ToLower(s)
|
||||
isLiteral := false
|
||||
switch first {
|
||||
case '{', '[', '(', '"', '\'':
|
||||
isLiteral = true
|
||||
}
|
||||
if !isLiteral {
|
||||
// digit
|
||||
if first >= '0' && first <= '9' {
|
||||
isLiteral = true
|
||||
}
|
||||
}
|
||||
if !isLiteral && (lower == "true" || lower == "false" || lower == "null" || lower == "none") {
|
||||
isLiteral = true
|
||||
}
|
||||
if !isLiteral {
|
||||
return x
|
||||
}
|
||||
var parsed any
|
||||
// Try JSON. If it fails, return the original string.
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
return x
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// opCombine merges all input dicts into one. Key conflicts:
|
||||
// - existing is a list → extend (or append if new is scalar)
|
||||
// - existing is scalar, new is list → wrap as [old, *new]
|
||||
// - existing is scalar, new is scalar → wrap as [old, new]
|
||||
func (d *DataOperationsComponent) opCombine(items []map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, obj := range items {
|
||||
for k, v := range obj {
|
||||
existing, ok := out[k]
|
||||
if !ok {
|
||||
out[k] = v
|
||||
continue
|
||||
}
|
||||
switch ex := existing.(type) {
|
||||
case []any:
|
||||
if vl, ok := v.([]any); ok {
|
||||
out[k] = append(ex, vl...)
|
||||
} else {
|
||||
out[k] = append(ex, v)
|
||||
}
|
||||
default:
|
||||
if vl, ok := v.([]any); ok {
|
||||
out[k] = []any{ex, vl}
|
||||
} else {
|
||||
out[k] = []any{ex, v}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opFilterValues keeps dicts where every rule matches.
|
||||
func (d *DataOperationsComponent) opFilterValues(state *runtime.CanvasState, items []map[string]any) []any {
|
||||
rules := d.param.FilterValues
|
||||
out := make([]any, 0, len(items))
|
||||
for _, obj := range items {
|
||||
if len(rules) == 0 {
|
||||
out = append(out, obj)
|
||||
continue
|
||||
}
|
||||
all := true
|
||||
for _, rule := range rules {
|
||||
if !matchRule(state, obj, rule) {
|
||||
all = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if all {
|
||||
out = append(out, obj)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchRule evaluates one filter rule against obj. Mirrors the
|
||||
// Python match_rule helper.
|
||||
func matchRule(state *runtime.CanvasState, obj map[string]any, rule map[string]any) bool {
|
||||
key, _ := rule["key"].(string)
|
||||
if _, ok := obj[key]; !ok {
|
||||
return false
|
||||
}
|
||||
op := strings.ToLower(asString(rule["operator"]))
|
||||
if op == "" {
|
||||
op = "equals"
|
||||
}
|
||||
target := normValue(rule["value"])
|
||||
// Try to resolve {{...}} in target via state.
|
||||
if s, ok := rule["value"].(string); ok && strings.Contains(s, "{{") {
|
||||
if resolved, err := runtime.ResolveTemplate(s, state); err == nil {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
v := normValue(obj[key])
|
||||
switch op {
|
||||
case "=", "equals":
|
||||
return v == target
|
||||
case "≠", "!=":
|
||||
return v != target
|
||||
case "contains":
|
||||
return strings.Contains(v, target)
|
||||
case "start with":
|
||||
return strings.HasPrefix(v, target)
|
||||
case "end with":
|
||||
return strings.HasSuffix(v, target)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// asString is a forgiving cast for params that may arrive as int/str.
|
||||
func asString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// opAppendOrUpdate copies each dict and applies updates. Values that
|
||||
// look like {{ref}} are resolved via state; otherwise used as-is.
|
||||
func (d *DataOperationsComponent) opAppendOrUpdate(state *runtime.CanvasState, items []map[string]any) []any {
|
||||
out := make([]any, 0, len(items))
|
||||
for _, obj := range items {
|
||||
cp := make(map[string]any, len(obj))
|
||||
for k, v := range obj {
|
||||
cp[k] = v
|
||||
}
|
||||
for _, upd := range d.param.Updates {
|
||||
k := strings.TrimSpace(asString(upd["key"]))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
raw := upd["value"]
|
||||
// Resolve {{...}} templates first; fall back to plain
|
||||
// state-ref resolution (matches the Python
|
||||
// get_value_with_variable behavior — strings are looked
|
||||
// up in state when they look like refs).
|
||||
if s, ok := raw.(string); ok {
|
||||
if strings.Contains(s, "{{") {
|
||||
if resolved, err := runtime.ResolveTemplate(s, state); err == nil && resolved != "" {
|
||||
cp[k] = resolved
|
||||
continue
|
||||
}
|
||||
}
|
||||
if v, err := state.GetVar(s); err == nil && v != nil {
|
||||
cp[k] = v
|
||||
continue
|
||||
}
|
||||
}
|
||||
cp[k] = raw
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opRemoveKeys copies each dict and drops the listed keys.
|
||||
func (d *DataOperationsComponent) opRemoveKeys(items []map[string]any) []any {
|
||||
out := make([]any, 0, len(items))
|
||||
for _, obj := range items {
|
||||
cp := make(map[string]any, len(obj))
|
||||
for k, v := range obj {
|
||||
cp[k] = v
|
||||
}
|
||||
for _, k := range d.param.RemoveKeys {
|
||||
if _, ok := cp[k]; ok {
|
||||
delete(cp, k)
|
||||
}
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opRenameKeys copies each dict and renames per the configured pairs.
|
||||
func (d *DataOperationsComponent) opRenameKeys(items []map[string]any) []any {
|
||||
out := make([]any, 0, len(items))
|
||||
for _, obj := range items {
|
||||
cp := make(map[string]any, len(obj))
|
||||
for k, v := range obj {
|
||||
cp[k] = v
|
||||
}
|
||||
for _, pair := range d.param.RenameKeys {
|
||||
old := strings.TrimSpace(asString(pair["old_key"]))
|
||||
new := strings.TrimSpace(asString(pair["new_key"]))
|
||||
if old == "" || new == "" || old == new {
|
||||
continue
|
||||
}
|
||||
if v, ok := cp[old]; ok {
|
||||
cp[new] = v
|
||||
delete(cp, old)
|
||||
}
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameDataOperations, NewDataOperationsComponent)
|
||||
}
|
||||
282
internal/agent/component/data_operations_test.go
Normal file
282
internal/agent/component/data_operations_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestDataOperations_SelectKeys: keep only specified keys.
|
||||
func TestDataOperations_SelectKeys(t *testing.T) {
|
||||
c, err := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@items"},
|
||||
"operations": "select_keys",
|
||||
"select_keys": []string{"a", "c"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataOperationsComponent: %v", err)
|
||||
}
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
state.Outputs["cpn_0"] = map[string]any{"items": []any{
|
||||
map[string]any{"a": 1, "b": 2, "c": 3},
|
||||
}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 element, got %d", len(got))
|
||||
}
|
||||
item, _ := got[0].(map[string]any)
|
||||
if _, ok := item["b"]; ok {
|
||||
t.Errorf("b should have been removed; got %v", item)
|
||||
}
|
||||
if got, want := item["a"], 1; got != want {
|
||||
t.Errorf("a: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := item["c"], 3; got != want {
|
||||
t.Errorf("c: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_Combine: merge 2 dicts; key conflict on "k":
|
||||
// first=[1], second=[2,3] → result has "k"=[1,2,3].
|
||||
func TestDataOperations_Combine(t *testing.T) {
|
||||
c, _ := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@d1", "cpn_1@d2"},
|
||||
"operations": "combine",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
state.Outputs["cpn_0"] = map[string]any{"d1": map[string]any{"k": []any{1}}}
|
||||
state.Outputs["cpn_1"] = map[string]any{"d2": map[string]any{"k": []any{2, 3}}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
merged, _ := out["result"].(map[string]any)
|
||||
if merged == nil {
|
||||
t.Fatalf("expected map result, got %T", out["result"])
|
||||
}
|
||||
if got, want := merged["k"], []any{1, 2, 3}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("k: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_RemoveKeys: copy and remove specified keys.
|
||||
func TestDataOperations_RemoveKeys(t *testing.T) {
|
||||
c, _ := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@items"},
|
||||
"operations": "remove_keys",
|
||||
"remove_keys": []string{"secret", "internal"},
|
||||
})
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
state.Outputs["cpn_0"] = map[string]any{"items": []any{
|
||||
map[string]any{
|
||||
"name": "alpha",
|
||||
"secret": "shh",
|
||||
"value": 42,
|
||||
},
|
||||
}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 element, got %d", len(got))
|
||||
}
|
||||
item, _ := got[0].(map[string]any)
|
||||
if _, ok := item["secret"]; ok {
|
||||
t.Errorf("secret should have been removed; got %v", item)
|
||||
}
|
||||
if _, ok := item["internal"]; ok {
|
||||
t.Errorf("internal should have been removed; got %v", item)
|
||||
}
|
||||
if got, want := item["name"], "alpha"; got != want {
|
||||
t.Errorf("name: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := item["value"], 42; got != want {
|
||||
t.Errorf("value: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_LiteralEval: a string leaf that's a JSON literal
|
||||
// gets parsed.
|
||||
func TestDataOperations_LiteralEval(t *testing.T) {
|
||||
c, _ := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@items"},
|
||||
"operations": "literal_eval",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
state.Outputs["cpn_0"] = map[string]any{"items": []any{
|
||||
map[string]any{
|
||||
"plain": "hello",
|
||||
"json": `{"k": 1, "nested": [2, 3]}`,
|
||||
"number": "42",
|
||||
"bool": "true",
|
||||
},
|
||||
}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 element, got %d", len(got))
|
||||
}
|
||||
item, _ := got[0].(map[string]any)
|
||||
if got, want := item["plain"], "hello"; got != want {
|
||||
t.Errorf("plain: got %v, want %v", got, want)
|
||||
}
|
||||
// json should be decoded into a map
|
||||
if jm, ok := item["json"].(map[string]any); !ok {
|
||||
t.Errorf("json: not a map, got %T (%v)", item["json"], item["json"])
|
||||
} else if got, want := jm["k"], 1.0; got != want {
|
||||
t.Errorf("json.k: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := item["number"], 42.0; got != want {
|
||||
t.Errorf("number: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := item["bool"], true; got != want {
|
||||
t.Errorf("bool: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_FilterValues: keep dicts that match the rule.
|
||||
func TestDataOperations_FilterValues(t *testing.T) {
|
||||
c, _ := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@items"},
|
||||
"operations": "filter_values",
|
||||
"filter_values": []map[string]any{{"key": "k", "operator": "contains", "value": "1"}},
|
||||
})
|
||||
state := canvas.NewCanvasState("run-5", "task-5")
|
||||
state.Outputs["cpn_0"] = map[string]any{"items": []any{
|
||||
map[string]any{"k": "1-abc"},
|
||||
map[string]any{"k": "2-abc"},
|
||||
map[string]any{"k": "3-1abc"},
|
||||
}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 kept dicts, got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_AppendOrUpdate: applies updates and resolves
|
||||
// {{ref}} placeholders against state.
|
||||
func TestDataOperations_AppendOrUpdate(t *testing.T) {
|
||||
c, _ := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@items"},
|
||||
"operations": "append_or_update",
|
||||
"updates": []map[string]any{{"key": "owner", "value": "sys.user_id"}},
|
||||
})
|
||||
state := canvas.NewCanvasState("run-6", "task-6")
|
||||
state.Sys["user_id"] = "tenant-7"
|
||||
state.Outputs["cpn_0"] = map[string]any{"items": []any{
|
||||
map[string]any{"name": "x"},
|
||||
}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 element, got %d", len(got))
|
||||
}
|
||||
item, _ := got[0].(map[string]any)
|
||||
if got, want := item["owner"], "tenant-7"; got != want {
|
||||
t.Errorf("owner: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_RenameKeys: rename per the configured pairs.
|
||||
func TestDataOperations_RenameKeys(t *testing.T) {
|
||||
c, _ := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@items"},
|
||||
"operations": "rename_keys",
|
||||
"rename_keys": []map[string]any{{"old_key": "k", "new_key": "key"}},
|
||||
})
|
||||
state := canvas.NewCanvasState("run-7", "task-7")
|
||||
state.Outputs["cpn_0"] = map[string]any{"items": []any{
|
||||
map[string]any{"k": 1, "other": "x"},
|
||||
}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 element, got %d", len(got))
|
||||
}
|
||||
item, _ := got[0].(map[string]any)
|
||||
if _, ok := item["k"]; ok {
|
||||
t.Errorf("k should have been renamed away; got %v", item)
|
||||
}
|
||||
if got, want := item["key"], 1; got != want {
|
||||
t.Errorf("key: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := item["other"], "x"; got != want {
|
||||
t.Errorf("other: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_ParamCheck: bad operation rejected.
|
||||
func TestDataOperations_ParamCheck(t *testing.T) {
|
||||
_, err := NewDataOperationsComponent(map[string]any{
|
||||
"query": []string{"cpn_0@x"},
|
||||
"operations": "bogus",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad operations, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataOperations_Registered: factory lookup.
|
||||
func TestDataOperations_Registered(t *testing.T) {
|
||||
c, err := New("DataOperations", map[string]any{
|
||||
"query": []string{"sys.x"},
|
||||
"operations": "select_keys",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if c.Name() != "DataOperations" {
|
||||
t.Errorf("Name()=%q, want DataOperations", c.Name())
|
||||
}
|
||||
}
|
||||
450
internal/agent/component/docs_generator.go
Normal file
450
internal/agent/component/docs_generator.go
Normal file
@@ -0,0 +1,450 @@
|
||||
//
|
||||
// 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 component — DocsGenerator (T5, plan §2.11.3 row 21, §2.11.5.3-§2.11.5.4).
|
||||
//
|
||||
// DocsGenerator is a lambda that routes by output_format to one of the
|
||||
// 5 in-package writers (PDF / DOCX / TXT / Markdown / HTML). The Python
|
||||
// original (agent/component/docs_generator.py) used pypandoc + xelatex;
|
||||
// the Go port uses pure-Go libraries (signintech/gopdf, xuri/excelize,
|
||||
// yuin/goldmark) and a self-implemented OOXML writer for DOCX, avoiding
|
||||
// the AGPL-3 / archive / oversized-image-stack concerns of the Python
|
||||
// toolchain (plan §2.11.5).
|
||||
//
|
||||
// The component is the canvas entry point. It does NOT call MinIO; the
|
||||
// produced bytes (or for HTML/MD, the rendered text) are surfaced on
|
||||
// the output map for downstream nodes to attach / serve. Phase 5
|
||||
// integration wires the upload.
|
||||
package component
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
iow "ragflow/internal/agent/component/io"
|
||||
)
|
||||
|
||||
const componentNameDocsGenerator = "DocsGenerator"
|
||||
|
||||
// Default font size for the rendered documents. Plan §2.11.3 row 21
|
||||
// mandates a minimum of 12pt for accessibility; we default to 12.
|
||||
const defaultDocsFontSize = 12
|
||||
|
||||
// Default font families; Phase 5 will register a real TTF asset.
|
||||
const (
|
||||
defaultPDFFontFamily = "Noto Sans CJK SC"
|
||||
defaultDOCXFontFamily = "Noto Sans CJK SC"
|
||||
defaultHTMLFontFamily = "Noto Sans CJK SC"
|
||||
defaultMarkdownRenderer = "goldmark"
|
||||
)
|
||||
|
||||
// Allowed output formats. Keep this in sync with the param.Check
|
||||
// validator.
|
||||
var validOutputFormats = map[string]bool{
|
||||
"pdf": true,
|
||||
"docx": true,
|
||||
"txt": true,
|
||||
"markdown": true,
|
||||
"html": true,
|
||||
"md": true, // alias for markdown
|
||||
}
|
||||
|
||||
// docsGeneratorParam is the static DSL param surface.
|
||||
type docsGeneratorParam struct {
|
||||
OutputFormat string `json:"output_format"`
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename"`
|
||||
HeaderText string `json:"header_text"`
|
||||
FooterText string `json:"footer_text"`
|
||||
WatermarkText string `json:"watermark_text"`
|
||||
AddPageNumbers bool `json:"add_page_numbers"`
|
||||
AddTimestamp bool `json:"add_timestamp"`
|
||||
FontSize int `json:"font_size"`
|
||||
}
|
||||
|
||||
// Update copies a fresh params map into the receiver.
|
||||
func (p *docsGeneratorParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
if v, ok := stringFrom(conf, "output_format"); ok {
|
||||
p.OutputFormat = v
|
||||
}
|
||||
if v, ok := stringFrom(conf, "content"); ok {
|
||||
p.Content = v
|
||||
}
|
||||
if v, ok := stringFrom(conf, "filename"); ok {
|
||||
p.Filename = v
|
||||
}
|
||||
if v, ok := stringFrom(conf, "header_text"); ok {
|
||||
p.HeaderText = v
|
||||
}
|
||||
if v, ok := stringFrom(conf, "footer_text"); ok {
|
||||
p.FooterText = v
|
||||
}
|
||||
if v, ok := stringFrom(conf, "watermark_text"); ok {
|
||||
p.WatermarkText = v
|
||||
}
|
||||
if v, ok := boolFrom(conf, "add_page_numbers"); ok {
|
||||
p.AddPageNumbers = v
|
||||
} else {
|
||||
p.AddPageNumbers = true
|
||||
}
|
||||
if v, ok := boolFrom(conf, "add_timestamp"); ok {
|
||||
p.AddTimestamp = v
|
||||
} else {
|
||||
p.AddTimestamp = true
|
||||
}
|
||||
if v, ok := intFrom(conf, "font_size"); ok {
|
||||
p.FontSize = v
|
||||
} else {
|
||||
p.FontSize = defaultDocsFontSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check validates the param. FontSize must be ≥ 12; output_format must
|
||||
// be one of pdf / docx / txt / markdown / html.
|
||||
func (p *docsGeneratorParam) Check() error {
|
||||
if !validOutputFormats[strings.ToLower(strings.TrimSpace(p.OutputFormat))] {
|
||||
return &ParamError{
|
||||
Field: "output_format",
|
||||
Reason: "must be one of: pdf, docx, txt, markdown, html",
|
||||
}
|
||||
}
|
||||
if p.FontSize < 12 {
|
||||
return &ParamError{
|
||||
Field: "font_size",
|
||||
Reason: "must be ≥ 12",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the param as a plain map.
|
||||
func (p *docsGeneratorParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"output_format": p.OutputFormat,
|
||||
"content": p.Content,
|
||||
"filename": p.Filename,
|
||||
"header_text": p.HeaderText,
|
||||
"footer_text": p.FooterText,
|
||||
"watermark_text": p.WatermarkText,
|
||||
"add_page_numbers": p.AddPageNumbers,
|
||||
"add_timestamp": p.AddTimestamp,
|
||||
"font_size": p.FontSize,
|
||||
}
|
||||
}
|
||||
|
||||
// DocsGenerator is the T5 multi-format document writer.
|
||||
type DocsGenerator struct {
|
||||
name string
|
||||
param docsGeneratorParam
|
||||
}
|
||||
|
||||
// NewDocsGenerator builds a DocsGenerator from a DSL params map.
|
||||
func NewDocsGenerator(params map[string]any) (Component, error) {
|
||||
p := &docsGeneratorParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("DocsGenerator: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("DocsGenerator: param check: %w", err)
|
||||
}
|
||||
return &DocsGenerator{name: componentNameDocsGenerator, param: *p}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (d *DocsGenerator) Name() string { return d.name }
|
||||
|
||||
// Invoke dispatches to the appropriate writer. Input overrides for
|
||||
// content / filename are honored.
|
||||
func (d *DocsGenerator) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
param := d.param
|
||||
if v, ok := stringFrom(inputs, "content"); ok && v != "" {
|
||||
param.Content = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "filename"); ok && v != "" {
|
||||
param.Filename = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "output_format"); ok && v != "" {
|
||||
param.OutputFormat = v
|
||||
}
|
||||
// Re-check after overrides.
|
||||
if err := (&docsGeneratorParam{
|
||||
OutputFormat: param.OutputFormat,
|
||||
FontSize: param.FontSize,
|
||||
}).Check(); err != nil {
|
||||
return nil, fmt.Errorf("DocsGenerator: %w", err)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("DocsGenerator: %w", err)
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(param.OutputFormat))
|
||||
ext := formatExtension(format)
|
||||
safeName := sanitizeFilename(param.Filename, ext)
|
||||
|
||||
var (
|
||||
payload []byte
|
||||
mime string
|
||||
)
|
||||
switch format {
|
||||
case "pdf":
|
||||
var err error
|
||||
payload, err = iow.WritePDF(param.Content, iow.PDFOptions{
|
||||
FontSize: param.FontSize,
|
||||
HeaderText: param.HeaderText,
|
||||
FooterText: param.FooterText,
|
||||
WatermarkText: param.WatermarkText,
|
||||
AddPageNumbers: param.AddPageNumbers,
|
||||
AddTimestamp: param.AddTimestamp,
|
||||
FontFamily: defaultPDFFontFamily,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DocsGenerator: pdf: %w", err)
|
||||
}
|
||||
mime = "application/pdf"
|
||||
case "docx":
|
||||
var err error
|
||||
payload, err = iow.WriteDOCX(param.Content, iow.DOCXOptions{
|
||||
HeaderText: param.HeaderText,
|
||||
FooterText: param.FooterText,
|
||||
WatermarkText: param.WatermarkText,
|
||||
AddPageNumbers: param.AddPageNumbers,
|
||||
AddTimestamp: param.AddTimestamp,
|
||||
CJKFontFamily: defaultDOCXFontFamily,
|
||||
FontSize: param.FontSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DocsGenerator: docx: %w", err)
|
||||
}
|
||||
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case "txt":
|
||||
renderedStr := renderTXT(param.Content, param.HeaderText, param.FooterText, param.AddTimestamp)
|
||||
payload = []byte(renderedStr)
|
||||
mime = "text/plain; charset=utf-8"
|
||||
case "markdown", "md":
|
||||
// Markdown "writer" returns the original content (with optional
|
||||
// front-matter). Round-tripping Markdown → Markdown is a no-op
|
||||
// apart from header/footer/watermark rendering as comments.
|
||||
renderedStr := renderMarkdown(param.Content, param.HeaderText, param.FooterText, param.AddTimestamp)
|
||||
payload = []byte(renderedStr)
|
||||
mime = "text/markdown; charset=utf-8"
|
||||
case "html":
|
||||
renderedStr := renderHTML(param.Content, param.HeaderText, param.FooterText, param.WatermarkText, param.AddTimestamp, param.FontSize, defaultHTMLFontFamily)
|
||||
payload = []byte(renderedStr)
|
||||
mime = "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
docID := uuid.New().String()
|
||||
size := len(payload)
|
||||
downloadStub := fmt.Sprintf("inline://docs/%s/%s", docID, safeName)
|
||||
|
||||
return map[string]any{
|
||||
"doc_id": docID,
|
||||
"filename": safeName,
|
||||
"mime_type": mime,
|
||||
"size": size,
|
||||
"bytes": payload,
|
||||
"download": downloadStub,
|
||||
"created": time.Now().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; DocsGenerator is a single-shot generator.
|
||||
func (d *DocsGenerator) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := d.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata.
|
||||
func (d *DocsGenerator) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"content": "Override: source text/markdown body (otherwise uses the static param).",
|
||||
"filename": "Override: output filename (sanitized; extension auto-appended if missing).",
|
||||
"output_format": "Override: pdf | docx | txt | markdown | html.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the response surface.
|
||||
func (d *DocsGenerator) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"doc_id": "Generated document id (UUID).",
|
||||
"filename": "Sanitized filename (extension matches output_format).",
|
||||
"mime_type": "MIME type for the payload.",
|
||||
"size": "Payload size in bytes.",
|
||||
"bytes": "Raw document bytes (for storage upload in Phase 5).",
|
||||
"download": "Stub URI the canvas engine can resolve to a signed URL.",
|
||||
"created": "RFC3339 timestamp of the generation.",
|
||||
}
|
||||
}
|
||||
|
||||
// formatExtension returns the conventional file extension for a format
|
||||
// string. Accepts the canonical forms and the "md" alias.
|
||||
func formatExtension(format string) string {
|
||||
switch format {
|
||||
case "pdf":
|
||||
return ".pdf"
|
||||
case "docx":
|
||||
return ".docx"
|
||||
case "txt":
|
||||
return ".txt"
|
||||
case "markdown", "md":
|
||||
return ".md"
|
||||
case "html":
|
||||
return ".html"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sanitizeFilename applies the plan §2.11.5 helper: strip forbidden
|
||||
// chars, collapse whitespace, cap the base at 180 chars, and append the
|
||||
// conventional extension when missing. Returns "file.<ext>" when the
|
||||
// resulting base is empty.
|
||||
func sanitizeFilename(raw, ext string) string {
|
||||
const forbidden = `\/:*?"<>|`
|
||||
const maxBase = 180
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
// Strip control characters first; they're never valid in filenames.
|
||||
var b strings.Builder
|
||||
for _, r := range trimmed {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune(forbidden, r) {
|
||||
r = '_'
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
base := strings.Join(strings.Fields(b.String()), "_")
|
||||
if len(base) > maxBase {
|
||||
base = base[:maxBase]
|
||||
}
|
||||
if base == "" {
|
||||
return "file" + ext
|
||||
}
|
||||
if ext != "" && !strings.HasSuffix(strings.ToLower(base), strings.ToLower(ext)) {
|
||||
return base + ext
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// renderTXT is the trivial plain-text path: header / footer / timestamp
|
||||
// are wrapped as plain text lines around the body.
|
||||
func renderTXT(content, header, footer string, addTimestamp bool) string {
|
||||
var b bytes.Buffer
|
||||
if header != "" {
|
||||
b.WriteString(header)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if addTimestamp {
|
||||
b.WriteString(fmt.Sprintf("Generated: %s\n", time.Now().UTC().Format(time.RFC3339)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(content)
|
||||
if footer != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(footer)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderMarkdown emits a Markdown doc with header/footer as HTML
|
||||
// comments and a YAML-ish front-matter timestamp.
|
||||
func renderMarkdown(content, header, footer string, addTimestamp bool) string {
|
||||
var b bytes.Buffer
|
||||
if addTimestamp {
|
||||
b.WriteString("<!-- generated: ")
|
||||
b.WriteString(time.Now().UTC().Format(time.RFC3339))
|
||||
b.WriteString(" -->\n\n")
|
||||
}
|
||||
if header != "" {
|
||||
b.WriteString("<!-- header: ")
|
||||
b.WriteString(header)
|
||||
b.WriteString(" -->\n\n")
|
||||
}
|
||||
b.WriteString(content)
|
||||
if footer != "" {
|
||||
b.WriteString("\n\n<!-- footer: ")
|
||||
b.WriteString(footer)
|
||||
b.WriteString(" -->\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderHTML is a minimal HTML5 wrapper around the body. The header
|
||||
// and footer are placed in <header> and <footer> elements; the
|
||||
// watermark, when set, becomes a CSS background-image placeholder (the
|
||||
// Phase 5 polish task can swap this for an SVG).
|
||||
func renderHTML(content, header, footer, watermark string, addTimestamp bool, fontSize int, fontFamily string) string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
|
||||
b.WriteString("<meta charset=\"utf-8\">\n")
|
||||
b.WriteString("<title>")
|
||||
if header != "" {
|
||||
b.WriteString(header)
|
||||
} else {
|
||||
b.WriteString("Document")
|
||||
}
|
||||
b.WriteString("</title>\n")
|
||||
b.WriteString("<style>\n")
|
||||
fmt.Fprintf(&b, "body { font-family: %q; font-size: %dpt; line-height: 1.5; }\n", fontFamily, fontSize)
|
||||
if watermark != "" {
|
||||
b.WriteString("body { background-image: linear-gradient(transparent, transparent); }\n")
|
||||
b.WriteString(".watermark { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-30deg); font-size: 96pt; color: rgba(0,0,0,0.06); pointer-events: none; z-index: -1; }\n")
|
||||
}
|
||||
b.WriteString("</style>\n</head>\n<body>\n")
|
||||
if header != "" {
|
||||
b.WriteString("<header>")
|
||||
b.WriteString(header)
|
||||
b.WriteString("</header>\n")
|
||||
}
|
||||
if watermark != "" {
|
||||
b.WriteString("<div class=\"watermark\">")
|
||||
b.WriteString(watermark)
|
||||
b.WriteString("</div>\n")
|
||||
}
|
||||
b.WriteString("<main>\n")
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n</main>\n")
|
||||
if footer != "" {
|
||||
b.WriteString("<footer>")
|
||||
b.WriteString(footer)
|
||||
b.WriteString("</footer>\n")
|
||||
}
|
||||
if addTimestamp {
|
||||
fmt.Fprintf(&b, "<p><small>Generated: %s</small></p>\n", time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
b.WriteString("</body>\n</html>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameDocsGenerator, NewDocsGenerator)
|
||||
}
|
||||
458
internal/agent/component/excel_processor.go
Normal file
458
internal/agent/component/excel_processor.go
Normal file
@@ -0,0 +1,458 @@
|
||||
//
|
||||
// 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 component — ExcelProcessor (T5, plan §2.11.3 row 22).
|
||||
//
|
||||
// ExcelProcessor supports three operations:
|
||||
//
|
||||
// read — open a workbook, list sheet names, return first-sheet rows
|
||||
// write — create a new workbook from a [][]any grid, return bytes
|
||||
// merge — read 2+ workbooks, concatenate the first-sheet rows
|
||||
//
|
||||
// The implementation is built on github.com/xuri/excelize/v2 (BSD-3,
|
||||
// license-clean per plan §2.11.5). For P4 we ship the read and write
|
||||
// operations; merge is a basic concatenation of first-sheet rows.
|
||||
//
|
||||
// file_ref points at a state-bound binary. For P4 we accept either:
|
||||
// - inputs["file_ref"] carrying raw xlsx bytes ([]byte or base64
|
||||
// string), or
|
||||
// - inputs["file_ref"] / param.file_ref naming a state key whose value
|
||||
// resolves to []byte via the canvas state engine.
|
||||
//
|
||||
// The write path stores the produced bytes on outputs["bytes"] so
|
||||
// downstream nodes can attach them to a MinIO upload (Phase 5).
|
||||
package component
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
componentNameExcelProcessor = "ExcelProcessor"
|
||||
|
||||
defaultSheetName = "Sheet1"
|
||||
)
|
||||
|
||||
// excelProcessorParam is the static configuration for an ExcelProcessor
|
||||
// node. file_ref, output_data, and sheet_name are duplicated in
|
||||
// inputs for per-call overrides; the param holds the defaults.
|
||||
type excelProcessorParam struct {
|
||||
Operation string `json:"operation"` // "read" | "write" | "merge"
|
||||
FileRef string `json:"file_ref"` // state ref to xlsx bytes (read/merge)
|
||||
OutputData [][]any `json:"output_data"` // grid for write
|
||||
SheetName string `json:"sheet_name"` // default sheet name
|
||||
}
|
||||
|
||||
// Update copies a fresh param map into the receiver.
|
||||
func (p *excelProcessorParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
p.Operation, _ = conf["operation"].(string)
|
||||
if p.Operation == "" {
|
||||
p.Operation = "read"
|
||||
}
|
||||
p.FileRef, _ = conf["file_ref"].(string)
|
||||
p.SheetName, _ = conf["sheet_name"].(string)
|
||||
if p.SheetName == "" {
|
||||
p.SheetName = defaultSheetName
|
||||
}
|
||||
|
||||
switch v := conf["output_data"].(type) {
|
||||
case [][]any:
|
||||
p.OutputData = v
|
||||
case []any:
|
||||
// Some serializers collapse the outer slice into []any; unwrap.
|
||||
out := make([][]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if row, ok := item.([]any); ok {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
p.OutputData = out
|
||||
default:
|
||||
// nil / unsupported — leave as-is; Check()/Invoke will reject.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check validates the param.
|
||||
func (p *excelProcessorParam) Check() error {
|
||||
switch p.Operation {
|
||||
case "read", "write", "merge":
|
||||
// ok
|
||||
default:
|
||||
return &ParamError{Field: "operation", Reason: "must be one of: read, write, merge"}
|
||||
}
|
||||
if p.SheetName == "" {
|
||||
return &ParamError{Field: "sheet_name", Reason: "must not be empty"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map.
|
||||
func (p *excelProcessorParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"operation": p.Operation,
|
||||
"file_ref": p.FileRef,
|
||||
"output_data": p.OutputData,
|
||||
"sheet_name": p.SheetName,
|
||||
}
|
||||
}
|
||||
|
||||
// ExcelProcessorComponent implements the read/write/merge Excel node.
|
||||
type ExcelProcessorComponent struct {
|
||||
name string
|
||||
param excelProcessorParam
|
||||
}
|
||||
|
||||
// NewExcelProcessorComponent constructs an ExcelProcessor from the DSL
|
||||
// param map.
|
||||
func NewExcelProcessorComponent(params map[string]any) (Component, error) {
|
||||
p := &excelProcessorParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: param check: %w", err)
|
||||
}
|
||||
return &ExcelProcessorComponent{
|
||||
name: componentNameExcelProcessor,
|
||||
param: *p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (e *ExcelProcessorComponent) Name() string { return e.name }
|
||||
|
||||
// Invoke runs the configured operation and returns the result map.
|
||||
// Output shape:
|
||||
//
|
||||
// read — {"rows": [][]any, "sheet_names": []string, "size": <int>}
|
||||
// write — {"rows": [][]any, "sheet_names": []string, "size": <int>,
|
||||
// "bytes": <[]byte>}
|
||||
// merge — {"rows": [][]any, "sheet_names": []string, "size": <int>}
|
||||
func (e *ExcelProcessorComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
// ExcelProcessor does not currently read from canvas state for
|
||||
// binary blobs (Phase 5 will wire that through internal/storage),
|
||||
// but we still pull state so a nil-state error is surfaced early.
|
||||
if _, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: %w", err)
|
||||
}
|
||||
|
||||
// Resolve operation: input override → param.
|
||||
op := e.param.Operation
|
||||
if v, ok := inputs["operation"].(string); ok && v != "" {
|
||||
op = v
|
||||
}
|
||||
op = strings.ToLower(strings.TrimSpace(op))
|
||||
switch op {
|
||||
case "read", "write", "merge":
|
||||
// ok
|
||||
default:
|
||||
return nil, &ParamError{Field: "operation", Reason: "must be one of: read, write, merge"}
|
||||
}
|
||||
|
||||
// Resolve sheet_name: input → param.
|
||||
sheetName := e.param.SheetName
|
||||
if v, ok := inputs["sheet_name"].(string); ok && v != "" {
|
||||
sheetName = v
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "write":
|
||||
return e.doWrite(sheetName, inputs)
|
||||
case "read":
|
||||
return e.doRead(sheetName, inputs)
|
||||
case "merge":
|
||||
return e.doMerge(sheetName, inputs)
|
||||
}
|
||||
// Unreachable thanks to the switch above, kept for the compiler.
|
||||
return nil, errors.New("ExcelProcessor: unreachable")
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; ExcelProcessor is a single-shot transform.
|
||||
func (e *ExcelProcessorComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := e.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata.
|
||||
func (e *ExcelProcessorComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"operation": "One of read | write | merge. Defaults to param.operation, then \"read\".",
|
||||
"file_ref": "Reference to xlsx bytes; resolves via inputs or param.file_ref.",
|
||||
"output_data": "Grid ([][]any) for write; can be supplied per-invocation.",
|
||||
"sheet_name": "Target sheet name; default \"Sheet1\".",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the response surface.
|
||||
func (e *ExcelProcessorComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"rows": "Read/write/merge result rows ([][]any). Empty for empty workbook.",
|
||||
"sheet_names": "All sheet names in the workbook ([]string).",
|
||||
"size": "Number of bytes for write; row count for read/merge.",
|
||||
"bytes": "Write-only: raw xlsx bytes ([]byte) ready for storage upload.",
|
||||
}
|
||||
}
|
||||
|
||||
// doWrite builds a new workbook and returns its bytes.
|
||||
func (e *ExcelProcessorComponent) doWrite(sheetName string, inputs map[string]any) (map[string]any, error) {
|
||||
grid := e.param.OutputData
|
||||
if v, ok := inputs["output_data"].([][]any); ok {
|
||||
grid = v
|
||||
}
|
||||
if sheetName == "" {
|
||||
sheetName = defaultSheetName
|
||||
}
|
||||
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
// Replace the default "Sheet1" only if sheetName is non-default; we
|
||||
// always set the index/active sheet to whichever the caller asked
|
||||
// for so the resulting file opens cleanly.
|
||||
if sheetName != defaultSheetName {
|
||||
if err := f.SetSheetName(defaultSheetName, sheetName); err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: rename default sheet: %w", err)
|
||||
}
|
||||
}
|
||||
idx, err := f.GetSheetIndex(sheetName)
|
||||
if err != nil || idx < 0 {
|
||||
// Sheet vanished; create it explicitly.
|
||||
if _, cerr := f.NewSheet(sheetName); cerr != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: create sheet %q: %w", sheetName, cerr)
|
||||
}
|
||||
}
|
||||
|
||||
for r, row := range grid {
|
||||
for c, cell := range row {
|
||||
cellRef, _ := excelize.CoordinatesToCellName(c+1, r+1)
|
||||
if err := f.SetCellValue(sheetName, cellRef, cell); err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: set cell %s: %w", cellRef, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the active sheet to the one we just wrote.
|
||||
idx, _ = f.GetSheetIndex(sheetName)
|
||||
if idx >= 0 {
|
||||
f.SetActiveSheet(idx)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: write buffer: %w", err)
|
||||
}
|
||||
out := buf.Bytes()
|
||||
sheetNames := f.GetSheetList()
|
||||
return map[string]any{
|
||||
"rows": grid,
|
||||
"sheet_names": sheetNames,
|
||||
"size": len(out),
|
||||
"bytes": out,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// doRead opens the workbook referenced by inputs.file_ref (or
|
||||
// param.file_ref) and returns its first-sheet rows.
|
||||
func (e *ExcelProcessorComponent) doRead(sheetName string, inputs map[string]any) (map[string]any, error) {
|
||||
raw, err := e.resolveFileBytes(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := excelize.OpenReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: open xlsx: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sheetNames := f.GetSheetList()
|
||||
if len(sheetNames) == 0 {
|
||||
return map[string]any{
|
||||
"rows": [][]any{},
|
||||
"sheet_names": []string{},
|
||||
"size": 0,
|
||||
}, nil
|
||||
}
|
||||
if sheetName == "" {
|
||||
sheetName = sheetNames[0]
|
||||
}
|
||||
|
||||
rows, err := f.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: get rows %q: %w", sheetName, err)
|
||||
}
|
||||
grid := make([][]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
converted := make([]any, 0, len(row))
|
||||
for _, cell := range row {
|
||||
converted = append(converted, cell)
|
||||
}
|
||||
grid = append(grid, converted)
|
||||
}
|
||||
return map[string]any{
|
||||
"rows": grid,
|
||||
"sheet_names": sheetNames,
|
||||
"size": len(grid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// doMerge reads 2+ workbooks and concatenates their first-sheet rows.
|
||||
// Inputs must supply either:
|
||||
// - inputs["file_refs"] as a [][]byte / []any of byte slices, or
|
||||
// - inputs["file_ref"] / param.file_ref naming a single workbook
|
||||
// (then merge reduces to that workbook's first sheet).
|
||||
func (e *ExcelProcessorComponent) doMerge(sheetName string, inputs map[string]any) (map[string]any, error) {
|
||||
blobs, err := e.resolveMergeBlobs(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sheetName == "" {
|
||||
sheetName = defaultSheetName
|
||||
}
|
||||
|
||||
merged := make([][]any, 0)
|
||||
var firstSheetNames []string
|
||||
for i, b := range blobs {
|
||||
f, err := excelize.OpenReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: merge open #%d: %w", i, err)
|
||||
}
|
||||
sheets := f.GetSheetList()
|
||||
if i == 0 {
|
||||
firstSheetNames = sheets
|
||||
}
|
||||
if len(sheets) == 0 {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
target := sheets[0]
|
||||
if sheetName != "" && sheetName != defaultSheetName {
|
||||
if idx, _ := f.GetSheetIndex(sheetName); idx >= 0 {
|
||||
target = sheetName
|
||||
}
|
||||
}
|
||||
rows, err := f.GetRows(target)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("ExcelProcessor: merge rows #%d: %w", i, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
converted := make([]any, 0, len(row))
|
||||
for _, cell := range row {
|
||||
converted = append(converted, cell)
|
||||
}
|
||||
merged = append(merged, converted)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
return map[string]any{
|
||||
"rows": merged,
|
||||
"sheet_names": firstSheetNames,
|
||||
"size": len(merged),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveFileBytes returns the raw xlsx bytes for read mode. Accepts:
|
||||
// - inputs["bytes"] as []byte
|
||||
// - inputs["file_ref"] as []byte, OR as a base64 string
|
||||
//
|
||||
// We don't accept param.file_ref here because binary blobs live in
|
||||
// canvas state, not in the static DSL; the orchestrator is expected to
|
||||
// have resolved the state ref into the inputs map already.
|
||||
func (e *ExcelProcessorComponent) resolveFileBytes(inputs map[string]any) ([]byte, error) {
|
||||
if b, ok := inputs["bytes"].([]byte); ok && len(b) > 0 {
|
||||
return b, nil
|
||||
}
|
||||
if b, ok := inputs["file_ref"].([]byte); ok && len(b) > 0 {
|
||||
return b, nil
|
||||
}
|
||||
if s, ok := inputs["file_ref"].(string); ok && s != "" {
|
||||
return decodeBase64(s)
|
||||
}
|
||||
if s, ok := inputs["bytes"].(string); ok && s != "" {
|
||||
return decodeBase64(s)
|
||||
}
|
||||
return nil, &ParamError{Field: "file_ref", Reason: "no xlsx bytes supplied (provide inputs.bytes or inputs.file_ref as []byte or base64)"}
|
||||
}
|
||||
|
||||
// resolveMergeBlobs returns the [][]byte list for merge mode. Accepts
|
||||
// inputs["file_refs"] as [][]byte or []any of []byte; falls back to a
|
||||
// single-blob read.
|
||||
func (e *ExcelProcessorComponent) resolveMergeBlobs(inputs map[string]any) ([][]byte, error) {
|
||||
switch v := inputs["file_refs"].(type) {
|
||||
case [][]byte:
|
||||
if len(v) == 0 {
|
||||
return nil, &ParamError{Field: "file_refs", Reason: "must not be empty for merge"}
|
||||
}
|
||||
return v, nil
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return nil, &ParamError{Field: "file_refs", Reason: "must not be empty for merge"}
|
||||
}
|
||||
out := make([][]byte, 0, len(v))
|
||||
for i, item := range v {
|
||||
b, ok := item.([]byte)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ExcelProcessor: file_refs[%d] is %T, want []byte", i, item)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// Single-file fallback: treat file_ref / bytes as the only blob.
|
||||
b, err := e.resolveFileBytes(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return [][]byte{b}, nil
|
||||
}
|
||||
|
||||
// decodeBase64 returns the base64-decoded byte slice for s. We require
|
||||
// base64 because binary blobs should never round-trip through strings
|
||||
// silently — that path is a known source of encoding bugs. Callers with
|
||||
// already-binary data should pass []byte, not string.
|
||||
func decodeBase64(s string) ([]byte, error) {
|
||||
if s == "" {
|
||||
return nil, errors.New("ExcelProcessor: empty file_ref string")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ExcelProcessor: file_ref is not valid base64: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameExcelProcessor, NewExcelProcessorComponent)
|
||||
}
|
||||
190
internal/agent/component/excel_processor_test.go
Normal file
190
internal/agent/component/excel_processor_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// excelCtx returns a fresh canvas-state context for component tests.
|
||||
func excelCtx(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
state := canvas.NewCanvasState("run-xlsx", "task-xlsx")
|
||||
return canvas.WithState(context.Background(), state)
|
||||
}
|
||||
|
||||
// TestExcelProcessor_WriteThenRead: write a 2x2 grid, then read it
|
||||
// back from the produced bytes; rows should round-trip.
|
||||
func TestExcelProcessor_WriteThenRead(t *testing.T) {
|
||||
grid := [][]any{
|
||||
{"a", "b"},
|
||||
{1, 2},
|
||||
}
|
||||
w, err := NewExcelProcessorComponent(map[string]any{
|
||||
"operation": "write",
|
||||
"output_data": grid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExcelProcessorComponent (write): %v", err)
|
||||
}
|
||||
out, err := w.Invoke(excelCtx(t), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("write Invoke: %v", err)
|
||||
}
|
||||
raw, ok := out["bytes"].([]byte)
|
||||
if !ok || len(raw) == 0 {
|
||||
t.Fatalf("write: bytes output missing or empty (got %T)", out["bytes"])
|
||||
}
|
||||
if size, _ := out["size"].(int); size != len(raw) {
|
||||
t.Errorf("write: size=%d, want %d (len bytes)", size, len(raw))
|
||||
}
|
||||
if names, _ := out["sheet_names"].([]string); len(names) == 0 {
|
||||
t.Errorf("write: sheet_names empty, want >=1")
|
||||
}
|
||||
// ZIP magic header.
|
||||
if !(raw[0] == 'P' && raw[1] == 'K' && raw[2] == 3 && raw[3] == 4) {
|
||||
t.Errorf("write: bytes do not start with PK\\x03\\x04 ZIP magic: %x", raw[:4])
|
||||
}
|
||||
|
||||
// Read those bytes back with a fresh component.
|
||||
r, err := NewExcelProcessorComponent(map[string]any{"operation": "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExcelProcessorComponent (read): %v", err)
|
||||
}
|
||||
rout, err := r.Invoke(excelCtx(t), map[string]any{"bytes": raw})
|
||||
if err != nil {
|
||||
t.Fatalf("read Invoke: %v", err)
|
||||
}
|
||||
rows, _ := rout["rows"].([][]any)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("read: got %d rows, want 2", len(rows))
|
||||
}
|
||||
// excelize returns everything as strings via GetRows. Compare cell-
|
||||
// by-cell so 1 (int we wrote) and "1" (string excelize reports) line
|
||||
// up via fmt.Sprintf("%v", ...).
|
||||
if got, want := fmt.Sprintf("%v", rows[0][0]), "a"; got != want {
|
||||
t.Errorf("read rows[0][0] = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprintf("%v", rows[0][1]), "b"; got != want {
|
||||
t.Errorf("read rows[0][1] = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprintf("%v", rows[1][0]), "1"; got != want {
|
||||
t.Errorf("read rows[1][0] = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprintf("%v", rows[1][1]), "2"; got != want {
|
||||
t.Errorf("read rows[1][1] = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExcelProcessor_ReadSheetNames: build a workbook with two sheets
|
||||
// directly via excelize, then read it back via the component and
|
||||
// confirm both names appear.
|
||||
func TestExcelProcessor_ReadSheetNames(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
if _, err := f.NewSheet("Alpha"); err != nil {
|
||||
t.Fatalf("NewSheet Alpha: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue("Alpha", "A1", "x"); err != nil {
|
||||
t.Fatalf("SetCellValue: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
r, _ := NewExcelProcessorComponent(map[string]any{"operation": "read"})
|
||||
out, err := r.Invoke(excelCtx(t), map[string]any{"bytes": buf.Bytes()})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
names, _ := out["sheet_names"].([]string)
|
||||
if !reflect.DeepEqual(names, []string{"Sheet1", "Alpha"}) {
|
||||
t.Errorf("sheet_names = %v, want [Sheet1 Alpha]", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExcelProcessor_EmptyFile: writing an empty grid produces a
|
||||
// valid (small) xlsx; reading it back returns zero rows but a valid
|
||||
// sheet list.
|
||||
func TestExcelProcessor_EmptyFile(t *testing.T) {
|
||||
w, _ := NewExcelProcessorComponent(map[string]any{
|
||||
"operation": "write",
|
||||
"output_data": [][]any{},
|
||||
})
|
||||
out, err := w.Invoke(excelCtx(t), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("write Invoke: %v", err)
|
||||
}
|
||||
raw, _ := out["bytes"].([]byte)
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("write: expected non-empty bytes for an empty-grid xlsx")
|
||||
}
|
||||
if !(raw[0] == 'P' && raw[1] == 'K' && raw[2] == 3 && raw[3] == 4) {
|
||||
t.Errorf("write: bytes do not start with PK\\x03\\x04 ZIP magic: %x", raw[:4])
|
||||
}
|
||||
|
||||
r, _ := NewExcelProcessorComponent(map[string]any{"operation": "read"})
|
||||
rout, err := r.Invoke(excelCtx(t), map[string]any{"bytes": raw})
|
||||
if err != nil {
|
||||
t.Fatalf("read Invoke: %v", err)
|
||||
}
|
||||
rows, _ := rout["rows"].([][]any)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("read empty: got %d rows, want 0", len(rows))
|
||||
}
|
||||
names, _ := rout["sheet_names"].([]string)
|
||||
if len(names) == 0 {
|
||||
t.Errorf("read empty: sheet_names should still list the default sheet")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExcelProcessor_ParamCheck: invalid operation rejected.
|
||||
func TestExcelProcessor_ParamCheck(t *testing.T) {
|
||||
if _, err := NewExcelProcessorComponent(map[string]any{"operation": "bogus"}); err == nil {
|
||||
t.Fatal("expected error for bogus operation, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExcelProcessor_ReadMissingBytes: read without inputs.bytes
|
||||
// surfaces a ParamError.
|
||||
func TestExcelProcessor_ReadMissingBytes(t *testing.T) {
|
||||
r, _ := NewExcelProcessorComponent(map[string]any{"operation": "read"})
|
||||
_, err := r.Invoke(excelCtx(t), map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing bytes, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExcelProcessor_Registered: factory lookup.
|
||||
func TestExcelProcessor_Registered(t *testing.T) {
|
||||
c, err := New("ExcelProcessor", map[string]any{"operation": "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if c.Name() != "ExcelProcessor" {
|
||||
t.Errorf("Name()=%q, want ExcelProcessor", c.Name())
|
||||
}
|
||||
}
|
||||
139
internal/agent/component/fillup.go
Normal file
139
internal/agent/component/fillup.go
Normal file
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// 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 component — Fillup component (T3, plan §2.11.3 row 3).
|
||||
//
|
||||
// Fillup is the lighter sibling of UserFillUp: it does NOT render a
|
||||
// `tips` template. It only passes the form's input map through to its
|
||||
// outputs (file inputs are still stubbed because FileService integration
|
||||
// is Phase 5 per plan §2.11.10). The Python codebase has no separate
|
||||
// Fillup class — per plan §2.11.3 row 3, this component is the Go
|
||||
// port's normalized, tips-less variant of UserFillUp so the DSL can
|
||||
// spawn it without paying for the unused template path.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameFillup = "Fillup"
|
||||
|
||||
// fillupParam is the per-instance configuration for Fillup. It is
|
||||
// strictly a subset of userFillUpParam — `enable_tips` and `tips` are
|
||||
// intentionally absent because Fillup never renders tips.
|
||||
type fillupParam struct {
|
||||
LayoutRecognize string `json:"layout_recognize"`
|
||||
}
|
||||
|
||||
// Update copies a fresh params map into the receiver. Layout_recognize
|
||||
// is the only field; unknown keys are silently ignored to keep the
|
||||
// Update contract forgiving (mirrors the existing P0/P1 components).
|
||||
func (p *fillupParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
if v, ok := stringFrom(conf, "layout_recognize"); ok {
|
||||
p.LayoutRecognize = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check performs parameter validation. Fillup has no required fields.
|
||||
func (p *fillupParam) Check() error { return nil }
|
||||
|
||||
// AsDict returns the param as a plain map for serialization / debug.
|
||||
func (p *fillupParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"layout_recognize": p.LayoutRecognize,
|
||||
}
|
||||
}
|
||||
|
||||
// FillupComponent is the canvas tips-less form-filling node.
|
||||
type FillupComponent struct {
|
||||
name string
|
||||
param fillupParam
|
||||
}
|
||||
|
||||
// NewFillupComponent builds a FillupComponent from a DSL params map.
|
||||
func NewFillupComponent(p fillupParam) *FillupComponent {
|
||||
return &FillupComponent{name: componentNameFillup, param: p}
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (f *FillupComponent) Name() string { return f.name }
|
||||
|
||||
// Invoke emits one output per form field, with file-typed fields
|
||||
// stubbed as "<file:key>". No "tips" key is added — that is the
|
||||
// defining difference from UserFillUp.
|
||||
func (f *FillupComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
// State is required by the engine contract; we don't read from it
|
||||
// here, but we still extract it to fail loudly if the engine forgot
|
||||
// to wire it (consistent with UserFillUp's behavior).
|
||||
if _, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err != nil {
|
||||
return nil, fmt.Errorf("Fillup: %w", err)
|
||||
}
|
||||
|
||||
fields, _ := formFields(inputs)
|
||||
out := make(map[string]any, len(fields))
|
||||
for k, v := range fields {
|
||||
out[k] = resolveFieldValue(k, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stream is the synchronous facade over Invoke: a single payload, then
|
||||
// close. Mirrors the pattern used by UserFillUp and the P0 components.
|
||||
func (f *FillupComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := f.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata. There is no "tips" surface — the
|
||||
// DSL editor should not show one for Fillup nodes.
|
||||
func (f *FillupComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"inputs": "Map of form-field name → {value, type, optional?}.",
|
||||
"layout_recognize": "Layout recognizer hint used for file inputs (deferred to Phase 5).",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns one entry per form field. The "*" wildcard mirrors
|
||||
// the UserFillUp contract; no "tips" key is ever emitted.
|
||||
func (f *FillupComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"*": "One output per form-field key in inputs (file inputs are stubbed as \"<file:key>\").",
|
||||
}
|
||||
}
|
||||
|
||||
// init registers Fillup with the orchestrator-owned registry.
|
||||
func init() {
|
||||
Register(componentNameFillup, func(params map[string]any) (Component, error) {
|
||||
var p fillupParam
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewFillupComponent(p), nil
|
||||
})
|
||||
}
|
||||
136
internal/agent/component/fillup_test.go
Normal file
136
internal/agent/component/fillup_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestFillup_PassesThroughInputs asserts the multi-field passthrough
|
||||
// contract: every input key (with a non-file {value, type} payload)
|
||||
// appears in the output with its inner `value` extracted. This is the
|
||||
// primary contract downstream LLM/Retrieval nodes rely on.
|
||||
func TestFillup_PassesThroughInputs(t *testing.T) {
|
||||
c, _ := New(componentNameFillup, nil)
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"a": map[string]any{"value": 1},
|
||||
"b": map[string]any{"value": "x"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["a"].(int); got != 1 {
|
||||
t.Errorf("a: got %v, want 1", out["a"])
|
||||
}
|
||||
if got, _ := out["b"].(string); got != "x" {
|
||||
t.Errorf("b: got %q, want %q", got, "x")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillup_NoTipsKey locks down the defining difference from
|
||||
// UserFillUp: Fillup never emits a "tips" key, regardless of the
|
||||
// params it was constructed with. Even if a misconfigured DSL passed
|
||||
// `tips` or `enable_tips` (Fillup ignores them), the output stays
|
||||
// tips-less.
|
||||
func TestFillup_NoTipsKey(t *testing.T) {
|
||||
c, _ := New(componentNameFillup, map[string]any{
|
||||
"enable_tips": true, // ignored by Fillup
|
||||
"tips": "should be ignored",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"x": map[string]any{"value": "y"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if _, ok := out["tips"]; ok {
|
||||
t.Errorf("Fillup must not emit a tips key; got %v", out["tips"])
|
||||
}
|
||||
if got, _ := out["x"].(string); got != "y" {
|
||||
t.Errorf("x passthrough: got %q, want %q", got, "y")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillup_NonDictInput covers the contract that a plain (non-dict)
|
||||
// input value is passed through as-is. The {value, type} envelope is
|
||||
// optional — when it is missing, the raw value lands in the output
|
||||
// untouched. This mirrors fillup.py:78 (`v = v.get("value")` only runs
|
||||
// when `v` is a dict).
|
||||
func TestFillup_NonDictInput(t *testing.T) {
|
||||
c, _ := New(componentNameFillup, nil)
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"plain_str": "just a string",
|
||||
"plain_int": 42,
|
||||
"plain_list": []any{"a", "b", "c"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["plain_str"].(string); got != "just a string" {
|
||||
t.Errorf("plain_str: got %q, want %q", got, "just a string")
|
||||
}
|
||||
if got, _ := out["plain_int"].(int); got != 42 {
|
||||
t.Errorf("plain_int: got %v, want 42", out["plain_int"])
|
||||
}
|
||||
list, _ := out["plain_list"].([]any)
|
||||
if len(list) != 3 || list[0] != "a" || list[2] != "c" {
|
||||
t.Errorf("plain_list: got %v, want [a b c]", out["plain_list"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillup_FileInputStub locks down the Phase 5 deferral for Fillup:
|
||||
// file-typed inputs are stubbed as "<file:key>" — same contract as
|
||||
// UserFillUp, so downstream components see a consistent payload
|
||||
// shape across the two form-filling components.
|
||||
func TestFillup_FileInputStub(t *testing.T) {
|
||||
c, _ := New(componentNameFillup, nil)
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"cv": map[string]any{
|
||||
"value": []any{"file-1"},
|
||||
"type": "file",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["cv"].(string); got != "<file:cv>" {
|
||||
t.Errorf("cv stub: got %q, want %q", got, "<file:cv>")
|
||||
}
|
||||
}
|
||||
231
internal/agent/component/invoke.go
Normal file
231
internal/agent/component/invoke.go
Normal file
@@ -0,0 +1,231 @@
|
||||
//
|
||||
// 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 component — Invoke component (T3, plan §2.11.3 row 14, §2.7).
|
||||
//
|
||||
// Invoke is the canvas HTTP client node. It supports GET/POST/PUT/DELETE
|
||||
// with custom headers, optional proxy, and per-request timeout, and
|
||||
// wraps the underlying net/http.Transport with
|
||||
// go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp.NewTransport
|
||||
// so outbound calls automatically propagate W3C traceparent headers
|
||||
// (plan §2.10 — OTel integration).
|
||||
//
|
||||
// The P0 implementation does NOT include HTML cleaning, JSON form-data
|
||||
// building, or retry/backoff. Those land in Phase 2 P3 (per plan §2.7)
|
||||
// when deepdoc HTTP use cases first need them.
|
||||
package component
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
componentNameInvoke = "Invoke"
|
||||
|
||||
defaultInvokeTimeout = 30 * time.Second
|
||||
defaultInvokeUserAgent = "ragflow-agent/1.0 (Invoke component)"
|
||||
defaultInvokeContentCT = "application/json"
|
||||
maxInvokeResponseBody = 16 << 20 // 16 MiB; hard cap to avoid OOM
|
||||
)
|
||||
|
||||
// InvokeComponent is the HTTP client node. Stateless across invocations.
|
||||
type InvokeComponent struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// NewInvokeComponent constructs an Invoke component.
|
||||
func NewInvokeComponent(_ map[string]any) (Component, error) {
|
||||
return &InvokeComponent{name: componentNameInvoke}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (i *InvokeComponent) Name() string { return i.name }
|
||||
|
||||
// Invoke executes a single HTTP request and returns the status code,
|
||||
// body, and response headers. See Inputs() for the param contract.
|
||||
func (i *InvokeComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
method, _ := inputs["method"].(string)
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete:
|
||||
default:
|
||||
return nil, fmt.Errorf("Invoke: invalid method %q (want GET/POST/PUT/DELETE)", method)
|
||||
}
|
||||
rawURL, _ := inputs["url"].(string)
|
||||
if rawURL == "" {
|
||||
return nil, errors.New("Invoke: url is required")
|
||||
}
|
||||
// url.Parse is a sanity check; we trust the orchestrator to have
|
||||
// already resolved any {{...}} refs, but a bad string here is a
|
||||
// programmer error worth surfacing.
|
||||
if _, err := url.Parse(rawURL); err != nil {
|
||||
return nil, fmt.Errorf("Invoke: parse url: %w", err)
|
||||
}
|
||||
|
||||
timeout := defaultInvokeTimeout
|
||||
if v, ok := inputs["timeout"].(int); ok && v > 0 {
|
||||
timeout = time.Duration(v) * time.Second
|
||||
} else if v, ok := inputs["timeout"].(float64); ok && v > 0 {
|
||||
timeout = time.Duration(v) * time.Second
|
||||
}
|
||||
|
||||
contentType, _ := inputs["content_type"].(string)
|
||||
if contentType == "" && (method == http.MethodPost || method == http.MethodPut) {
|
||||
contentType = defaultInvokeContentCT
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if s, ok := inputs["body"].(string); ok && s != "" {
|
||||
body = bytes.NewReader([]byte(s))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invoke: build request: %w", err)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultInvokeUserAgent)
|
||||
if h, ok := inputs["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
if s, ok := v.(string); ok {
|
||||
req.Header.Set(k, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the stdlib Transport with otelhttp so the request gets a
|
||||
// child span + W3C traceparent injected automatically.
|
||||
transport := otelhttp.NewTransport(http.DefaultTransport)
|
||||
// Optional proxy support: if inputs["proxy"] is set, build a
|
||||
// dedicated Transport that uses it. This avoids mutating the
|
||||
// global http.DefaultTransport (which would also affect unrelated
|
||||
// components in the same process).
|
||||
if proxyStr, ok := inputs["proxy"].(string); ok && proxyStr != "" {
|
||||
transport = otelhttp.NewTransport(&http.Transport{
|
||||
Proxy: http.ProxyURL(mustParseProxy(proxyStr)),
|
||||
})
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invoke: do: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Cap the response body to keep a hostile server from streaming
|
||||
// infinite bytes into memory.
|
||||
limited := io.LimitReader(resp.Body, maxInvokeResponseBody)
|
||||
bodyBytes, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invoke: read body: %w", err)
|
||||
}
|
||||
|
||||
hdr := make(map[string]string, len(resp.Header))
|
||||
for k, vs := range resp.Header {
|
||||
// First value only — multi-value headers are uncommon in
|
||||
// canvas-DSL HTTP responses, and the param contract specifies
|
||||
// a string map.
|
||||
if len(vs) > 0 {
|
||||
hdr[k] = vs[0]
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(bodyBytes),
|
||||
"headers": hdr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream is a synchronous facade over Invoke for P0. Real streaming
|
||||
// (chunked transfer as it arrives) is deferred to Phase 2 P3.
|
||||
func (i *InvokeComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := i.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the public parameter surface.
|
||||
func (i *InvokeComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"method": "HTTP method: GET, POST, PUT, or DELETE (case-insensitive).",
|
||||
"url": "Target URL; can be a {{...}} reference resolved upstream.",
|
||||
"headers": "Optional map of string headers.",
|
||||
"body": "Optional request body (string).",
|
||||
"timeout": "Per-request timeout in seconds; default 30.",
|
||||
"proxy": "Optional proxy URL (e.g. http://host:3128).",
|
||||
"content_type": "Optional Content-Type; default 'application/json' for POST/PUT.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the response surface.
|
||||
func (i *InvokeComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"status": "HTTP status code (int).",
|
||||
"body": "Response body (string, truncated at 16 MiB).",
|
||||
"headers": "Response headers (first value per key).",
|
||||
}
|
||||
}
|
||||
|
||||
// mustParseProxy parses a proxy URL string. We keep this helper here
|
||||
// (rather than calling url.Parse inline) so the panic-on-bad-input
|
||||
// behavior is uniform across the package — proxy strings are operator-
|
||||
// configured, a malformed one is a deployment error worth crashing
|
||||
// loud on.
|
||||
func mustParseProxy(raw string) *url.URL {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Invoke: invalid proxy URL %q: %v", raw, err))
|
||||
}
|
||||
// Defensive check: net/http.ProxyURL will silently no-op on a
|
||||
// URL with no Host. Surface a clear panic instead.
|
||||
if u.Host == "" {
|
||||
panic(fmt.Sprintf("Invoke: proxy URL %q has no host", raw))
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// netHTTPImports is a no-op reference to keep `net` in the import set
|
||||
// for go vet's unused-import check while the production code path
|
||||
// doesn't otherwise need the net package (only used by the optional
|
||||
// proxy path via http.ProxyURL). Removed in Phase 2 P3.
|
||||
var _ = net.IPv4len
|
||||
|
||||
func init() {
|
||||
Register(componentNameInvoke, NewInvokeComponent)
|
||||
}
|
||||
127
internal/agent/component/invoke_test.go
Normal file
127
internal/agent/component/invoke_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInvoke_GET exercises the happy path: a GET request to a stub
|
||||
// server returns the canned body, and the response map carries the
|
||||
// expected status / body / headers.
|
||||
func TestInvoke_GET(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("server: got method %q, want GET", r.Method)
|
||||
}
|
||||
w.Header().Set("X-Test", "ok")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := NewInvokeComponent(nil)
|
||||
out, err := c.Invoke(context.Background(), map[string]any{
|
||||
"method": "GET",
|
||||
"url": srv.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if status, _ := out["status"].(int); status != http.StatusOK {
|
||||
t.Errorf("status: got %d, want 200", status)
|
||||
}
|
||||
if body, _ := out["body"].(string); body != "hello" {
|
||||
t.Errorf("body: got %q, want %q", body, "hello")
|
||||
}
|
||||
hdr, _ := out["headers"].(map[string]string)
|
||||
if hdr["X-Test"] != "ok" {
|
||||
t.Errorf("headers[X-Test]: got %q, want %q", hdr["X-Test"], "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvoke_POST verifies that POST with a body echoes the body back
|
||||
// from the server. The Content-Type defaults to application/json when
|
||||
// not specified; we confirm that default in the test.
|
||||
func TestInvoke_POST(t *testing.T) {
|
||||
var seenCT, seenBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenCT = r.Header.Get("Content-Type")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
seenBody = string(b)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte("echo:" + seenBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := NewInvokeComponent(nil)
|
||||
out, err := c.Invoke(context.Background(), map[string]any{
|
||||
"method": "POST",
|
||||
"url": srv.URL,
|
||||
"body": `{"k":"v"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if status, _ := out["status"].(int); status != http.StatusCreated {
|
||||
t.Errorf("status: got %d, want 201", status)
|
||||
}
|
||||
if seenCT != "application/json" {
|
||||
t.Errorf("server saw Content-Type %q, want application/json (default)", seenCT)
|
||||
}
|
||||
if seenBody != `{"k":"v"}` {
|
||||
t.Errorf("server saw body %q, want %q", seenBody, `{"k":"v"}`)
|
||||
}
|
||||
if body, _ := out["body"].(string); body != `echo:{"k":"v"}` {
|
||||
t.Errorf("body: got %q, want %q", body, `echo:{"k":"v"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvoke_BadMethod ensures invalid HTTP methods are rejected
|
||||
// before any network I/O happens.
|
||||
func TestInvoke_BadMethod(t *testing.T) {
|
||||
c, _ := NewInvokeComponent(nil)
|
||||
_, err := c.Invoke(context.Background(), map[string]any{
|
||||
"method": "PATCH",
|
||||
"url": "http://localhost:1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for PATCH method, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid method") {
|
||||
t.Errorf("error %q should mention invalid method", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvoke_MissingURL confirms url is required.
|
||||
func TestInvoke_MissingURL(t *testing.T) {
|
||||
c, _ := NewInvokeComponent(nil)
|
||||
_, err := c.Invoke(context.Background(), map[string]any{
|
||||
"method": "GET",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing url, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "url is required") {
|
||||
t.Errorf("error %q should mention url is required", err.Error())
|
||||
}
|
||||
}
|
||||
244
internal/agent/component/io/docx_writer.go
Normal file
244
internal/agent/component/io/docx_writer.go
Normal file
@@ -0,0 +1,244 @@
|
||||
//
|
||||
// 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 io — DOCX writer (self-implemented OOXML, plan §2.11.5.3).
|
||||
//
|
||||
// All candidate Go DOCX libraries are either AGPL-3 (unipdf, unioffice,
|
||||
// fumiama-go-docx, baliance-gooxml) or unmaintained (tealeg, lytdev).
|
||||
// RAGFlow is Apache-2.0, so AGPL-3 is a hard "no". We therefore build
|
||||
// the DOCX writer from stdlib only:
|
||||
//
|
||||
// - archive/zip — the DOCX container
|
||||
// - text/template — the dynamic XML parts (document, header, footer)
|
||||
// - //go:embed — the static XML parts (Content_Types, _rels, styles)
|
||||
//
|
||||
// The output is a Word-compatible .docx. The XML is intentionally
|
||||
// minimal: a single font, a single style, and a flat list of
|
||||
// paragraphs. Tables / images / lists are Phase 5 polish items; the
|
||||
// P4 test suite (see docx_writer_test.go) verifies the ZIP magic, the
|
||||
// embedded document.xml content, and the XML-escape contract.
|
||||
package io
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed templates/content_types.xml
|
||||
var contentTypesXML []byte
|
||||
|
||||
//go:embed templates/rels.xml
|
||||
var relsXML []byte
|
||||
|
||||
//go:embed templates/document_rels.xml.tmpl
|
||||
//go:embed templates/styles.xml.tmpl
|
||||
//go:embed templates/document.xml.tmpl
|
||||
//go:embed templates/header.xml.tmpl
|
||||
//go:embed templates/footer.xml.tmpl
|
||||
var tmplFS embed.FS
|
||||
|
||||
// DOCXOptions is the public contract for the DOCX writer.
|
||||
type DOCXOptions struct {
|
||||
HeaderText string
|
||||
FooterText string
|
||||
WatermarkText string
|
||||
AddPageNumbers bool
|
||||
AddTimestamp bool
|
||||
CJKFontFamily string
|
||||
FontSize int
|
||||
}
|
||||
|
||||
// docModel is the internal render input. It's a small struct so the
|
||||
// templates can refer to a stable set of fields. The exported
|
||||
// DOCXOptions and Document (Phase 5 polish) will both flatten into
|
||||
// this when the writer is invoked.
|
||||
type docModel struct {
|
||||
Paragraphs []string
|
||||
HeaderText string
|
||||
FooterText string
|
||||
WatermarkText string
|
||||
AddPageNumbers bool
|
||||
AddTimestamp bool
|
||||
HasWatermark bool
|
||||
HasHeader bool
|
||||
HasFooter bool
|
||||
FontSize int
|
||||
FontFamily string
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// tmplFuncs registers the {{xml}} helper used to escape user content
|
||||
// before it lands in the document body. text/template doesn't have a
|
||||
// template.HTML type, so we return a string and rely on the template
|
||||
// engine's {{ }} interpolation (which auto-escapes by default for
|
||||
// strings — except we're explicitly using a non-default func that
|
||||
// returns a pre-escaped string).
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"xml": func(s string) string { return html.EscapeString(s) },
|
||||
"pt": func(i int) string { return fmt.Sprintf("%d", i*2) }, // OOXML uses half-points for w:sz
|
||||
}
|
||||
|
||||
// WriteDOCX renders the supplied content to a DOCX byte stream.
|
||||
//
|
||||
// Layout strategy (P4):
|
||||
//
|
||||
// - One paragraph per non-empty line in content.
|
||||
// - Empty lines are preserved as empty paragraphs (so the document's
|
||||
// vertical rhythm matches the source).
|
||||
// - The header carries a centered title and (optionally) a VML
|
||||
// watermark shape.
|
||||
// - The footer carries the footer text and (optionally) a page-number
|
||||
// field and a generation timestamp.
|
||||
// - Font size / family are applied globally; the per-paragraph
|
||||
// elements inherit from styles.xml.
|
||||
func WriteDOCX(content string, opts DOCXOptions) ([]byte, error) {
|
||||
if opts.FontSize <= 0 {
|
||||
opts.FontSize = 12
|
||||
}
|
||||
if opts.CJKFontFamily == "" {
|
||||
opts.CJKFontFamily = "Noto Sans CJK SC"
|
||||
}
|
||||
|
||||
model := docModel{
|
||||
Paragraphs: splitParagraphs(content),
|
||||
HeaderText: opts.HeaderText,
|
||||
FooterText: opts.FooterText,
|
||||
WatermarkText: opts.WatermarkText,
|
||||
AddPageNumbers: opts.AddPageNumbers,
|
||||
AddTimestamp: opts.AddTimestamp,
|
||||
HasWatermark: opts.WatermarkText != "",
|
||||
HasHeader: opts.HeaderText != "" || opts.WatermarkText != "",
|
||||
HasFooter: opts.FooterText != "" || opts.AddPageNumbers || opts.AddTimestamp,
|
||||
FontSize: opts.FontSize,
|
||||
FontFamily: opts.CJKFontFamily,
|
||||
Timestamp: nowUTC(),
|
||||
}
|
||||
|
||||
// Pre-parse all 5 templates once; FuncMaps are shared.
|
||||
docTmpl, err := template.New("document.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/document.xml.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DOCX: parse document template: %w", err)
|
||||
}
|
||||
headerTmpl, err := template.New("header.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/header.xml.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DOCX: parse header template: %w", err)
|
||||
}
|
||||
footerTmpl, err := template.New("footer.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/footer.xml.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DOCX: parse footer template: %w", err)
|
||||
}
|
||||
stylesTmpl, err := template.New("styles.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/styles.xml.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DOCX: parse styles template: %w", err)
|
||||
}
|
||||
docRelsTmpl, err := template.New("document_rels.xml.tmpl").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/document_rels.xml.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DOCX: parse document_rels template: %w", err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
zw := zip.NewWriter(buf)
|
||||
|
||||
// [Content_Types].xml
|
||||
if err := writeZipFile(zw, "[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// _rels/.rels
|
||||
if err := writeZipFile(zw, "_rels/.rels", relsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// word/document.xml.rels — relationships for header / footer / styles.
|
||||
if err := writeZipTmpl(zw, "word/_rels/document.xml.rels", docRelsTmpl, model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// word/styles.xml
|
||||
if err := writeZipTmpl(zw, "word/styles.xml", stylesTmpl, model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// word/document.xml
|
||||
if err := writeZipTmpl(zw, "word/document.xml", docTmpl, model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// word/header1.xml (omitted when no header / watermark requested)
|
||||
if model.HasHeader {
|
||||
if err := writeZipTmpl(zw, "word/header1.xml", headerTmpl, model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// word/footer1.xml (omitted when no footer requested)
|
||||
if model.HasFooter {
|
||||
if err := writeZipTmpl(zw, "word/footer1.xml", footerTmpl, model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("DOCX: close zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// writeZipFile writes a static []byte payload as a file inside the zip.
|
||||
func writeZipFile(zw *zip.Writer, name string, data []byte) error {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DOCX: create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("DOCX: write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeZipTmpl executes a template against the model and writes the
|
||||
// output as a file inside the zip.
|
||||
func writeZipTmpl(zw *zip.Writer, name string, tmpl *template.Template, model any) error {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DOCX: create %s: %w", name, err)
|
||||
}
|
||||
if err := tmpl.Execute(w, model); err != nil {
|
||||
return fmt.Errorf("DOCX: execute %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitParagraphs turns the source content into a list of paragraph
|
||||
// strings. Empty lines are preserved as empty strings so the rendered
|
||||
// document's vertical rhythm matches the source.
|
||||
func splitParagraphs(content string) []string {
|
||||
if content == "" {
|
||||
return []string{""}
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
// Strip trailing \r for CRLF inputs.
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nowUTC returns a stable timestamp string for footer / watermark
|
||||
// metadata. Format: "2026-06-03T14:45:06Z" (RFC3339 in UTC). Kept
|
||||
// here so tests can substitute it via time.Now override in Phase 5.
|
||||
var nowUTC = func() string { return "2026-06-03T00:00:00Z" }
|
||||
183
internal/agent/component/io/docx_writer_test.go
Normal file
183
internal/agent/component/io/docx_writer_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// 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 io
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDOCXWriter_MinimalDocument: the smallest possible DOCX — no
|
||||
// header, no footer, no watermark, no page numbers. The output must be
|
||||
// a valid ZIP starting with the PK magic and contain a document.xml
|
||||
// with the source text.
|
||||
func TestDOCXWriter_MinimalDocument(t *testing.T) {
|
||||
doc, err := WriteDOCX("Hello", DOCXOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDOCX: %v", err)
|
||||
}
|
||||
if len(doc) < 4 {
|
||||
t.Fatalf("doc too small: %d bytes", len(doc))
|
||||
}
|
||||
if !bytes.HasPrefix(doc, []byte{'P', 'K', 0x03, 0x04}) {
|
||||
t.Fatalf("doc does not start with ZIP magic; first 4 bytes: % x", doc[:4])
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
|
||||
if err != nil {
|
||||
t.Fatalf("zip.NewReader: %v", err)
|
||||
}
|
||||
body, ok := readZipFile(t, zr, "word/document.xml")
|
||||
if !ok {
|
||||
t.Fatal("word/document.xml not found in zip")
|
||||
}
|
||||
if !strings.Contains(body, "Hello") {
|
||||
t.Errorf("document.xml missing source text; first 200 chars:\n%s", truncate(body, 200))
|
||||
}
|
||||
// The static parts should always be present.
|
||||
if _, ok := readZipFile(t, zr, "[Content_Types].xml"); !ok {
|
||||
t.Error("[Content_Types].xml missing")
|
||||
}
|
||||
if _, ok := readZipFile(t, zr, "_rels/.rels"); !ok {
|
||||
t.Error("_rels/.rels missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDOCXWriter_WithHeader: when HeaderText is set, the produced
|
||||
// zip must contain word/header1.xml with the header text and a
|
||||
// corresponding relationship entry in document.xml.rels.
|
||||
func TestDOCXWriter_WithHeader(t *testing.T) {
|
||||
doc, err := WriteDOCX("X", DOCXOptions{HeaderText: "TOP"})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDOCX: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
|
||||
if err != nil {
|
||||
t.Fatalf("zip.NewReader: %v", err)
|
||||
}
|
||||
hdr, ok := readZipFile(t, zr, "word/header1.xml")
|
||||
if !ok {
|
||||
t.Fatal("word/header1.xml missing when HeaderText set")
|
||||
}
|
||||
if !strings.Contains(hdr, "TOP") {
|
||||
t.Errorf("header1.xml missing 'TOP':\n%s", truncate(hdr, 200))
|
||||
}
|
||||
rels, ok := readZipFile(t, zr, "word/_rels/document.xml.rels")
|
||||
if !ok {
|
||||
t.Fatal("word/_rels/document.xml.rels missing")
|
||||
}
|
||||
if !strings.Contains(rels, "rIdHeader1") || !strings.Contains(rels, "header1.xml") {
|
||||
t.Errorf("document.xml.rels missing header relationship:\n%s", truncate(rels, 200))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDOCXWriter_XMLEscape: source content with <, >, &, " must be
|
||||
// XML-escaped in the produced document.xml — the writer must never
|
||||
// let raw user content break the OOXML topology.
|
||||
func TestDOCXWriter_XMLEscape(t *testing.T) {
|
||||
in := `A < B & C > D "quoted"`
|
||||
doc, err := WriteDOCX(in, DOCXOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDOCX: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
|
||||
if err != nil {
|
||||
t.Fatalf("zip.NewReader: %v", err)
|
||||
}
|
||||
body, ok := readZipFile(t, zr, "word/document.xml")
|
||||
if !ok {
|
||||
t.Fatal("word/document.xml missing")
|
||||
}
|
||||
// Escaped forms must appear. html.EscapeString produces the
|
||||
// standard XML entity set: < / > / & / " (numeric
|
||||
// for the double-quote, matching Go's stdlib contract).
|
||||
want := "A < B & C > D "quoted""
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected XML-escaped content %q, got:\n%s", want, truncate(body, 400))
|
||||
}
|
||||
// Raw < and & must NOT appear inside the <w:t> text run.
|
||||
if strings.Contains(body, "A < B &") {
|
||||
t.Errorf("raw 'A < B &' leaked into document.xml")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDOCXWriter_Watermark: setting WatermarkText should produce a
|
||||
// header with the VML watermark shape, and the document.xml.rels
|
||||
// should still include the header reference.
|
||||
func TestDOCXWriter_Watermark(t *testing.T) {
|
||||
doc, err := WriteDOCX("body", DOCXOptions{WatermarkText: "DRAFT"})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDOCX: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
|
||||
if err != nil {
|
||||
t.Fatalf("zip.NewReader: %v", err)
|
||||
}
|
||||
hdr, ok := readZipFile(t, zr, "word/header1.xml")
|
||||
if !ok {
|
||||
t.Fatal("header1.xml missing when WatermarkText set")
|
||||
}
|
||||
if !strings.Contains(hdr, "DRAFT") {
|
||||
t.Errorf("header1.xml missing watermark text 'DRAFT'")
|
||||
}
|
||||
if !strings.Contains(hdr, "v:textpath") {
|
||||
t.Errorf("header1.xml missing v:textpath (VML watermark shape)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDOCXWriter_EmptyContent: an empty content string should still
|
||||
// produce a valid DOCX (one empty paragraph).
|
||||
func TestDOCXWriter_EmptyContent(t *testing.T) {
|
||||
doc, err := WriteDOCX("", DOCXOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDOCX: %v", err)
|
||||
}
|
||||
if len(doc) < 4 || !bytes.HasPrefix(doc, []byte{'P', 'K', 0x03, 0x04}) {
|
||||
t.Fatalf("expected ZIP magic, got: % x", doc[:4])
|
||||
}
|
||||
}
|
||||
|
||||
// readZipFile returns the file body as a string, or ("", false) if the
|
||||
// file is not present.
|
||||
func readZipFile(t *testing.T, zr *zip.Reader, name string) (string, bool) {
|
||||
t.Helper()
|
||||
for _, f := range zr.File {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
b, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
73
internal/agent/component/io/helpers.go
Normal file
73
internal/agent/component/io/helpers.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.
|
||||
//
|
||||
|
||||
// Package io — small type-coercion helpers shared by docs_generator.go
|
||||
// and the per-format writers. Kept here (rather than in the parent
|
||||
// component package) so the io/ subpackage has zero coupling to the
|
||||
// canvas engine and can be tested in isolation.
|
||||
package io
|
||||
|
||||
// stringFrom extracts a string from a conf map, returning the value
|
||||
// and ok=true. nil / wrong-type yields ("", false).
|
||||
func stringFrom(conf map[string]any, key string) (string, bool) {
|
||||
if conf == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := conf[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// intFrom extracts an int from a conf map. JSON-decoded numbers
|
||||
// commonly come in as float64; we accept both shapes for friendliness.
|
||||
func intFrom(conf map[string]any, key string) (int, bool) {
|
||||
if conf == nil {
|
||||
return 0, false
|
||||
}
|
||||
v, ok := conf[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, true
|
||||
case int32:
|
||||
return int(n), true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case float32:
|
||||
return int(n), true
|
||||
case float64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// boolFrom extracts a bool from a conf map.
|
||||
func boolFrom(conf map[string]any, key string) (bool, bool) {
|
||||
if conf == nil {
|
||||
return false, false
|
||||
}
|
||||
v, ok := conf[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
return b, ok
|
||||
}
|
||||
249
internal/agent/component/io/pdf_writer.go
Normal file
249
internal/agent/component/io/pdf_writer.go
Normal file
@@ -0,0 +1,249 @@
|
||||
//
|
||||
// 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 io — PDF writer (signintech/gopdf, plan §2.11.5.4).
|
||||
//
|
||||
// WritePDF renders the supplied content to a PDF using the MIT-licensed
|
||||
// signintech/gopdf library. P4 ships in a **stub mode**: gopdf requires
|
||||
// a TTF to be registered before any text is drawn, and we do not
|
||||
// register one in P4. The writer probes via gopdf.SetFont; if the
|
||||
// family is unknown, it surfaces ErrPDFFontNotConfigured so the
|
||||
// orchestrator can return a clear deployment-time error. Production
|
||||
// deployments register a TTF (e.g. Noto Sans CJK SC) at startup — the
|
||||
// Phase 5 polish task will wire that into the boot sequence.
|
||||
//
|
||||
// When a TTF *is* registered, the writer emits a simple one-paragraph
|
||||
// page per line of content, with a centered header and a centered
|
||||
// footer carrying the page number / timestamp when requested. Visual
|
||||
// fidelity (real watermark rotation, multi-column layout, etc.) is
|
||||
// Phase 5 polish; the contract for P4 is the byte stream + a clear
|
||||
// error path.
|
||||
package io
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/signintech/gopdf"
|
||||
)
|
||||
|
||||
// PDFOptions is the public contract for the PDF writer.
|
||||
type PDFOptions struct {
|
||||
FontSize int
|
||||
HeaderText string
|
||||
FooterText string
|
||||
WatermarkText string
|
||||
AddPageNumbers bool
|
||||
AddTimestamp bool
|
||||
FontFamily string
|
||||
}
|
||||
|
||||
// ErrPDFFontNotConfigured is returned when no TTF is registered.
|
||||
// Callers should register a TTF via gopdf.SetFont before invoking
|
||||
// WritePDF; Phase 5 will wire a default TTF into the boot path.
|
||||
var ErrPDFFontNotConfigured = errors.New("PDF font not configured: register a TTF (e.g. Noto Sans CJK SC) via gopdf.SetFont before calling WritePDF")
|
||||
|
||||
// WritePDF renders the content to a PDF byte stream.
|
||||
//
|
||||
// Layout (P4):
|
||||
//
|
||||
// - A4 portrait, 36pt margins on all sides.
|
||||
// - Body lines are drawn top-to-bottom, one per line of content.
|
||||
// - Header is centered at the top of every page (when set).
|
||||
// - Footer is centered at the bottom of every page and may include
|
||||
// the footer text, a generation timestamp, and a page number.
|
||||
// - Watermark is rendered as grey text near the page center; full
|
||||
// rotation is Phase 5.
|
||||
//
|
||||
// When the requested font family is not registered, the function
|
||||
// returns ErrPDFFontNotConfigured and does not write any output.
|
||||
func WritePDF(content string, opts PDFOptions) ([]byte, error) {
|
||||
if opts.FontSize <= 0 {
|
||||
opts.FontSize = 12
|
||||
}
|
||||
if opts.FontFamily == "" {
|
||||
opts.FontFamily = "Noto Sans CJK SC"
|
||||
}
|
||||
|
||||
pdf := &gopdf.GoPdf{}
|
||||
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
|
||||
|
||||
// Probe the font registry. gopdf returns an error like "font not
|
||||
// found" when the family is not registered; we surface that as
|
||||
// ErrPDFFontNotConfigured so callers can map it to a clear
|
||||
// deployment message.
|
||||
if err := pdf.SetFont(opts.FontFamily, "", opts.FontSize); err != nil {
|
||||
if isFontNotFound(err) {
|
||||
return nil, ErrPDFFontNotConfigured
|
||||
}
|
||||
return nil, fmt.Errorf("PDF: set font %q: %w", opts.FontFamily, err)
|
||||
}
|
||||
|
||||
pdf.AddPage()
|
||||
drawHeader(pdf, opts)
|
||||
|
||||
// Body — one Cell per line, manual y-cursor.
|
||||
bodyX := 36.0
|
||||
bodyY := 72.0
|
||||
lineHeight := float64(opts.FontSize) * 1.5
|
||||
pdf.SetX(bodyX)
|
||||
pdf.SetY(bodyY)
|
||||
|
||||
for _, line := range splitLines(content) {
|
||||
if line == "" {
|
||||
// Preserve blank lines as vertical space.
|
||||
bodyY += lineHeight
|
||||
if bodyY > 760 {
|
||||
drawFooter(pdf, opts)
|
||||
pdf.AddPage()
|
||||
drawHeader(pdf, opts)
|
||||
bodyY = 72.0
|
||||
}
|
||||
pdf.SetX(bodyX)
|
||||
pdf.SetY(bodyY)
|
||||
continue
|
||||
}
|
||||
if bodyY > 760 {
|
||||
drawFooter(pdf, opts)
|
||||
pdf.AddPage()
|
||||
drawHeader(pdf, opts)
|
||||
bodyY = 72.0
|
||||
}
|
||||
pdf.SetX(bodyX)
|
||||
pdf.SetY(bodyY)
|
||||
if err := pdf.Cell(nil, line); err != nil {
|
||||
return nil, fmt.Errorf("PDF: cell: %w", err)
|
||||
}
|
||||
bodyY += lineHeight
|
||||
}
|
||||
|
||||
if opts.WatermarkText != "" {
|
||||
drawWatermark(pdf, opts)
|
||||
}
|
||||
drawFooter(pdf, opts)
|
||||
|
||||
return writePDFToBytes(pdf)
|
||||
}
|
||||
|
||||
// drawHeader emits the header text at the top of the current page.
|
||||
// gopdf's API in v0.36.x doesn't expose a Header() callback; we draw
|
||||
// at the top of every page after AddPage.
|
||||
func drawHeader(pdf *gopdf.GoPdf, opts PDFOptions) {
|
||||
if opts.HeaderText == "" {
|
||||
return
|
||||
}
|
||||
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize-2)
|
||||
pdf.SetX(36)
|
||||
pdf.SetY(24)
|
||||
_ = pdf.Cell(nil, opts.HeaderText)
|
||||
// Restore body font.
|
||||
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize)
|
||||
}
|
||||
|
||||
// drawFooter emits the footer text plus optional timestamp / page
|
||||
// number at the bottom of the current page.
|
||||
func drawFooter(pdf *gopdf.GoPdf, opts PDFOptions) {
|
||||
if opts.FooterText == "" && !opts.AddTimestamp && !opts.AddPageNumbers {
|
||||
return
|
||||
}
|
||||
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize-2)
|
||||
pdf.SetX(36)
|
||||
pdf.SetY(800)
|
||||
parts := []string{}
|
||||
if opts.FooterText != "" {
|
||||
parts = append(parts, opts.FooterText)
|
||||
}
|
||||
if opts.AddTimestamp {
|
||||
parts = append(parts, time.Now().UTC().Format("2006-01-02 15:04"))
|
||||
}
|
||||
if opts.AddPageNumbers {
|
||||
// gopdf v0.36 doesn't expose a page-number macro; emit a
|
||||
// literal placeholder. Phase 5 will replace with a real
|
||||
// {np} token if gopdf gains one.
|
||||
parts = append(parts, "Page #")
|
||||
}
|
||||
_ = pdf.Cell(nil, strings.Join(parts, " | "))
|
||||
// Restore body font.
|
||||
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize)
|
||||
}
|
||||
|
||||
// drawWatermark emits a centered grey watermark. Full rotation is
|
||||
// not in the gopdf v0.36.x public surface; we use a light grey fill
|
||||
// as a visual proxy.
|
||||
func drawWatermark(pdf *gopdf.GoPdf, opts PDFOptions) {
|
||||
if opts.WatermarkText == "" {
|
||||
return
|
||||
}
|
||||
_ = pdf.SetFont(opts.FontFamily, "", 48)
|
||||
pdf.SetTextColor(200, 200, 200)
|
||||
pdf.SetX(120)
|
||||
pdf.SetY(360)
|
||||
_ = pdf.Cell(nil, opts.WatermarkText)
|
||||
// Restore.
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
_ = pdf.SetFont(opts.FontFamily, "", opts.FontSize)
|
||||
}
|
||||
|
||||
// writePDFToBytes serializes the gopdf output to a byte slice.
|
||||
//
|
||||
// gopdf's Write method requires an *os.File (it needs random access
|
||||
// for the xref table), so we route through a TempFile. Phase 5 can
|
||||
// lift this into a streaming implementation if needed.
|
||||
func writePDFToBytes(pdf *gopdf.GoPdf) ([]byte, error) {
|
||||
tmp, err := os.CreateTemp("", "ragflow-pdf-*.pdf")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PDF: tmpfile: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := pdf.Write(tmp); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return nil, fmt.Errorf("PDF: write: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return nil, fmt.Errorf("PDF: close: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpName)
|
||||
return os.ReadFile(tmpName)
|
||||
}
|
||||
|
||||
// splitLines is a conservative wrapper that splits on \n and
|
||||
// preserves blank lines as empty strings.
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{""}
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, l := range lines {
|
||||
lines[i] = strings.TrimRight(l, "\r")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// isFontNotFound reports whether the gopdf error indicates a missing
|
||||
// TTF registration. We match the substrings that have been stable
|
||||
// across recent gopdf versions.
|
||||
func isFontNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "font") && (strings.Contains(s, "not") || strings.Contains(s, "no such") || strings.Contains(s, "undefined"))
|
||||
}
|
||||
10
internal/agent/component/io/templates/content_types.xml
Normal file
10
internal/agent/component/io/templates/content_types.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
|
||||
<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>
|
||||
<Override PartName="/word/_rels/document.xml.rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
</Types>
|
||||
14
internal/agent/component/io/templates/document.xml.tmpl
Normal file
14
internal/agent/component/io/templates/document.xml.tmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<w:body>
|
||||
{{range .Paragraphs}}<w:p><w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:szCs w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .}}</w:t></w:r></w:p>
|
||||
{{end}}<w:sectPr>
|
||||
{{if .HasHeader}}<w:headerReference w:type="default" r:id="rIdHeader1"/>{{end}}
|
||||
{{if .HasFooter}}<w:footerReference w:type="default" r:id="rIdFooter1"/>{{end}}
|
||||
<w:pgSz w:w="12240" w:h="15840"/>
|
||||
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
|
||||
<w:cols w:space="720"/>
|
||||
<w:docGrid w:linePitch="360"/>
|
||||
</w:sectPr>
|
||||
</w:body>
|
||||
</w:document>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
{{if .HasHeader}}<Relationship Id="rIdHeader1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>{{end}}
|
||||
{{if .HasFooter}}<Relationship Id="rIdFooter1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>{{end}}
|
||||
</Relationships>
|
||||
13
internal/agent/component/io/templates/footer.xml.tmpl
Normal file
13
internal/agent/component/io/templates/footer.xml.tmpl
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="center"/></w:pPr>
|
||||
{{if .FooterText}}<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .FooterText}}</w:t></w:r>{{if or .AddPageNumbers .AddTimestamp}}<w:r><w:t xml:space="preserve"> | </w:t></w:r>{{end}}{{end}}
|
||||
{{if .AddPageNumbers}}<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">Page </w:t></w:r>
|
||||
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
|
||||
<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
|
||||
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
|
||||
<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t>1</w:t></w:r>
|
||||
<w:r><w:fldChar w:fldCharType="end"/></w:r>{{if .AddTimestamp}}<w:r><w:t xml:space="preserve"> | </w:t></w:r>{{end}}{{end}}
|
||||
{{if .AddTimestamp}}<w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .Timestamp}}</w:t></w:r>{{end}}
|
||||
</w:p>
|
||||
</w:ftr>
|
||||
17
internal/agent/component/io/templates/header.xml.tmpl
Normal file
17
internal/agent/component/io/templates/header.xml.tmpl
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
{{if .WatermarkText}}<w:p><w:r><w:pict>
|
||||
<v:shapetype id="_x0000_t136" coordsize="21600,21600" o:spt="136" adj="10800" path="m@7,l@8,m@5,21600l@6,21600e">
|
||||
<v:formulas><v:f eqn="sum #0 0 10800"/><v:f eqn="prod #0 2 1"/><v:f eqn="sum 21600 0 @1"/><v:f eqn="sum 0 0 @2"/><v:f eqn="sum 21600 0 @3"/><v:f eqn="if @0 @3 0"/><v:f eqn="if @0 21600 @1"/><v:f eqn="if @0 0 @2"/><v:f eqn="if @0 @4 21600"/><v:f eqn="mid @5 @6"/><v:f eqn="mid @8 @5"/><v:f eqn="mid @7 @8"/><v:f eqn="mid @6 @7"/><v:f eqn="sum @6 0 @5"/></v:formulas>
|
||||
<v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="custom" o:connectlocs="@9,0;@10,10800;@11,21600;@12,10800" o:connectangles="270,180,90,0" textpathok="t"/>
|
||||
<v:textpath on="t" fitshape="t"/>
|
||||
<v:handles><v:h position="#0,bottomRight" xrange="6629,14971"/></v:handles>
|
||||
<o:lock v:ext="edit" text="t" shapetype="t"/>
|
||||
</v:shapetype>
|
||||
<v:shape id="watermark" o:spid="_x0000_s1026" type="#_x0000_t136" style="position:absolute;margin-left:0;margin-top:0;width:500pt;height:90pt;rotation:315;z-index:-251658240;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="#d9d9d9" stroked="f">
|
||||
<v:fill opacity=".5"/>
|
||||
<v:textpath style="font-family:"Calibri";font-size:1pt" string="{{xml .WatermarkText}}"/>
|
||||
</v:shape>
|
||||
</w:pict></w:r></w:p>{{end}}
|
||||
{{if .HeaderText}}<w:p><w:pPr><w:pStyle w:val="Header"/><w:jc w:val="center"/></w:pPr><w:r><w:rPr><w:sz w:val="{{pt $.FontSize}}"/><w:rFonts w:ascii="{{$.FontFamily}}" w:eastAsia="{{$.FontFamily}}" w:hAnsi="{{$.FontFamily}}"/></w:rPr><w:t xml:space="preserve">{{xml .HeaderText}}</w:t></w:r></w:p>{{end}}
|
||||
</w:hdr>
|
||||
4
internal/agent/component/io/templates/rels.xml
Normal file
4
internal/agent/component/io/templates/rels.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>
|
||||
10
internal/agent/component/io/templates/styles.xml.tmpl
Normal file
10
internal/agent/component/io/templates/styles.xml.tmpl
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:docDefaults>
|
||||
<w:rPrDefault><w:rPr><w:rFonts w:ascii="{{.FontFamily}}" w:eastAsia="{{.FontFamily}}" w:hAnsi="{{.FontFamily}}" w:cs="Times New Roman"/><w:sz w:val="{{pt .FontSize}}"/><w:szCs w:val="{{pt .FontSize}}"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr></w:rPrDefault>
|
||||
<w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="259" w:lineRule="auto"/></w:pPr></w:pPrDefault>
|
||||
</w:docDefaults>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Header"><w:name w:val="header"/><w:basedOn w:val="Normal"/><w:pPr><w:tabs><w:tab w:val="center" w:pos="4680"/><w:tab w:val="right" w:pos="9360"/></w:tabs><w:spacing w:after="0"/></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/><w:pPr><w:tabs><w:tab w:val="center" w:pos="4680"/><w:tab w:val="right" w:pos="9360"/></w:tabs><w:spacing w:after="0"/></w:pPr></w:style>
|
||||
</w:styles>
|
||||
33
internal/agent/component/io_init.go
Normal file
33
internal/agent/component/io_init.go
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// 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 component — io subpackage wiring.
|
||||
//
|
||||
// The T5 heavy-I/O components (DocsGenerator, plus the writers used
|
||||
// internally) live in the io/ subpackage. Their init() functions
|
||||
// register themselves with the parent component registry. This file
|
||||
// exists to make sure the io/ subpackage is linked into the parent
|
||||
// component package via a blank import, so that the registrations
|
||||
// actually run on package init.
|
||||
//
|
||||
// Without this blank import, the io/ subpackage's init() functions
|
||||
// would be dead-code-eliminated and the DocsGenerator factory would
|
||||
// not be visible to the canvas engine.
|
||||
package component
|
||||
|
||||
// Blank import — runs init() in the io subpackage which calls
|
||||
// component.Register("DocsGenerator", …).
|
||||
import _ "ragflow/internal/agent/component/io"
|
||||
473
internal/agent/component/list_operations.go
Normal file
473
internal/agent/component/list_operations.go
Normal file
@@ -0,0 +1,473 @@
|
||||
//
|
||||
// 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 component — ListOperations (T3, plan §2.11.3 row 17).
|
||||
//
|
||||
// ListOperations applies one of six transforms to a list pulled from
|
||||
// the canvas state. It is pure: it does not write back to state; the
|
||||
// transformed list is returned at outputs["result"], with the head
|
||||
// and tail exposed at outputs["first"] / outputs["last"] for the
|
||||
// convenience of downstream nodes that only need a scalar.
|
||||
//
|
||||
// Supported operations:
|
||||
// - nth : 1-indexed (positive n) or -N (from end) element pick
|
||||
// - head : first n items
|
||||
// - tail : last n items
|
||||
// - filter : keep items whose _norm(v) matches a filter rule
|
||||
// - sort : stable sort; reverse for desc
|
||||
// - drop_duplicates : keep first occurrence by hashable key
|
||||
//
|
||||
// Mirrors agent/component/list_operations.py.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameListOperations = "ListOperations"
|
||||
|
||||
// listOperationsParam is the static configuration.
|
||||
type listOperationsParam struct {
|
||||
Query string `json:"query"`
|
||||
Operations string `json:"operations"`
|
||||
N int `json:"n"`
|
||||
Strict bool `json:"strict"`
|
||||
SortMethod string `json:"sort_method"`
|
||||
Filter map[string]any `json:"filter"`
|
||||
}
|
||||
|
||||
// Update copies a fresh param map into the receiver.
|
||||
func (p *listOperationsParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
p.Query, _ = conf["query"].(string)
|
||||
p.Operations, _ = conf["operations"].(string)
|
||||
if p.Operations == "" {
|
||||
p.Operations = "nth"
|
||||
}
|
||||
p.N = toInt(conf["n"])
|
||||
if s, ok := conf["strict"].(bool); ok {
|
||||
p.Strict = s
|
||||
}
|
||||
p.SortMethod, _ = conf["sort_method"].(string)
|
||||
if p.SortMethod == "" {
|
||||
p.SortMethod = "asc"
|
||||
}
|
||||
if f, ok := conf["filter"].(map[string]any); ok {
|
||||
p.Filter = f
|
||||
} else {
|
||||
p.Filter = map[string]any{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check validates the param.
|
||||
func (p *listOperationsParam) Check() error {
|
||||
if p.Query == "" {
|
||||
return &ParamError{Field: "query", Reason: "must not be empty"}
|
||||
}
|
||||
switch p.Operations {
|
||||
case "nth", "head", "tail", "filter", "sort", "drop_duplicates":
|
||||
// ok
|
||||
default:
|
||||
return &ParamError{
|
||||
Field: "operations",
|
||||
Reason: "must be one of: nth, head, tail, filter, sort, drop_duplicates",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map.
|
||||
func (p *listOperationsParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"query": p.Query,
|
||||
"operations": p.Operations,
|
||||
"n": p.N,
|
||||
"strict": p.Strict,
|
||||
"sort_method": p.SortMethod,
|
||||
"filter": p.Filter,
|
||||
}
|
||||
}
|
||||
|
||||
// toInt coerces a value to int. Floats are truncated; strings parsed
|
||||
// via Atoi; everything else falls back to 0.
|
||||
func toInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case string:
|
||||
var n int
|
||||
fmt.Sscanf(x, "%d", &n)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ListOperationsComponent implements the 6 list transforms.
|
||||
type ListOperationsComponent struct {
|
||||
name string
|
||||
param listOperationsParam
|
||||
}
|
||||
|
||||
// NewListOperationsComponent constructs a ListOperations from the
|
||||
// DSL param map.
|
||||
func NewListOperationsComponent(params map[string]any) (Component, error) {
|
||||
p := &listOperationsParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("ListOperations: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("ListOperations: param check: %w", err)
|
||||
}
|
||||
return &ListOperationsComponent{
|
||||
name: componentNameListOperations,
|
||||
param: *p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (l *ListOperationsComponent) Name() string { return l.name }
|
||||
|
||||
// Invoke resolves the param.query against the canvas state and applies
|
||||
// the configured operation. The transformed list is returned at
|
||||
// outputs["result"], with outputs["first"] / outputs["last"] set to
|
||||
// the first / last element of the result (or nil for an empty result).
|
||||
func (l *ListOperationsComponent) Invoke(ctx context.Context, _ map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListOperations: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("ListOperations: nil canvas state")
|
||||
}
|
||||
|
||||
raw, err := state.GetVar(l.param.Query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListOperations: query %q: %w", l.param.Query, err)
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ListOperations: input is not a list (got %T)", raw)
|
||||
}
|
||||
|
||||
var out []any
|
||||
switch l.param.Operations {
|
||||
case "nth":
|
||||
out = l.opNth(items)
|
||||
case "head":
|
||||
out = l.opHead(items)
|
||||
case "tail":
|
||||
out = l.opTail(items)
|
||||
case "filter":
|
||||
out = l.opFilter(items)
|
||||
case "sort":
|
||||
out = l.opSort(items)
|
||||
case "drop_duplicates":
|
||||
out = l.opDropDuplicates(items)
|
||||
}
|
||||
|
||||
first, last := any(nil), any(nil)
|
||||
if len(out) > 0 {
|
||||
first = out[0]
|
||||
last = out[len(out)-1]
|
||||
}
|
||||
return map[string]any{
|
||||
"result": out,
|
||||
"first": first,
|
||||
"last": last,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; ListOperations is a single-shot transform.
|
||||
func (l *ListOperationsComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := l.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns an empty surface — all config is in the param.
|
||||
func (l *ListOperationsComponent) Inputs() map[string]string {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
// Outputs returns the transformed list plus head/tail scalars.
|
||||
func (l *ListOperationsComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"result": "Transformed list (per the configured operation).",
|
||||
"first": "First element of the result (nil for empty result).",
|
||||
"last": "Last element of the result (nil for empty result).",
|
||||
}
|
||||
}
|
||||
|
||||
// opNth: 1-indexed for positive n, -N (from end) for negative n.
|
||||
// n=0 → empty (or error in strict mode).
|
||||
func (l *ListOperationsComponent) opNth(items []any) []any {
|
||||
n := l.param.N
|
||||
if n == 0 {
|
||||
if l.param.Strict {
|
||||
panic(fmt.Sprintf("ListOperations: nth requires n to be within the valid range in strict mode, got %d", n))
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
if n > 0 {
|
||||
if n <= len(items) {
|
||||
return []any{items[n-1]}
|
||||
}
|
||||
if l.param.Strict {
|
||||
panic(fmt.Sprintf("ListOperations: nth requires n to be within the valid range in strict mode, got %d", n))
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
absN := -n
|
||||
if absN <= len(items) {
|
||||
return []any{items[n]}
|
||||
}
|
||||
if l.param.Strict {
|
||||
panic(fmt.Sprintf("ListOperations: nth requires n to be within the valid range in strict mode, got %d", n))
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// opHead: first n items. n < 1 → empty. Strict: 1 ≤ n ≤ len(items).
|
||||
func (l *ListOperationsComponent) opHead(items []any) []any {
|
||||
n := l.param.N
|
||||
if l.param.Strict {
|
||||
if n < 1 || n > len(items) {
|
||||
panic(fmt.Sprintf("ListOperations: head requires n to be within the valid range in strict mode, got %d", n))
|
||||
}
|
||||
return append([]any{}, items[:n]...)
|
||||
}
|
||||
if n < 1 {
|
||||
return []any{}
|
||||
}
|
||||
if n > len(items) {
|
||||
n = len(items)
|
||||
}
|
||||
return append([]any{}, items[:n]...)
|
||||
}
|
||||
|
||||
// opTail: last n items. n < 1 → empty. Strict: 1 ≤ n ≤ len(items).
|
||||
func (l *ListOperationsComponent) opTail(items []any) []any {
|
||||
n := l.param.N
|
||||
if l.param.Strict {
|
||||
if n < 1 || n > len(items) {
|
||||
panic(fmt.Sprintf("ListOperations: tail requires n to be within the valid range in strict mode, got %d", n))
|
||||
}
|
||||
return append([]any{}, items[len(items)-n:]...)
|
||||
}
|
||||
if n < 1 {
|
||||
return []any{}
|
||||
}
|
||||
if n > len(items) {
|
||||
n = len(items)
|
||||
}
|
||||
return append([]any{}, items[len(items)-n:]...)
|
||||
}
|
||||
|
||||
// opFilter: keep items whose _norm(v) matches the filter rule.
|
||||
func (l *ListOperationsComponent) opFilter(items []any) []any {
|
||||
op, _ := l.param.Filter["operator"].(string)
|
||||
val, _ := l.param.Filter["value"].(string)
|
||||
out := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if evalFilter(normValue(item), op, val) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opSort: stable sort; for dict items, use hashable key. Reverse on
|
||||
// sort_method == "desc". The Python implementation uses sorted() which
|
||||
// is stable; Go's sort.SliceStable preserves that.
|
||||
func (l *ListOperationsComponent) opSort(items []any) []any {
|
||||
if len(items) == 0 {
|
||||
return []any{}
|
||||
}
|
||||
reverse := strings.EqualFold(l.param.SortMethod, "desc")
|
||||
cp := append([]any{}, items...)
|
||||
if _, isMap := cp[0].(map[string]any); isMap {
|
||||
sort.SliceStable(cp, func(i, j int) bool {
|
||||
ki, kj := hashableKey(cp[i]), hashableKey(cp[j])
|
||||
if reverse {
|
||||
return lessKey(kj, ki)
|
||||
}
|
||||
return lessKey(ki, kj)
|
||||
})
|
||||
} else {
|
||||
sort.SliceStable(cp, func(i, j int) bool {
|
||||
if reverse {
|
||||
return lessScalar(cp[j], cp[i])
|
||||
}
|
||||
return lessScalar(cp[i], cp[j])
|
||||
})
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// opDropDuplicates: keep first occurrence by hashable key. The
|
||||
// hashable key is JSON-encoded for use as a Go map key — Go maps do
|
||||
// not accept []any directly, so we serialize the canonical form to a
|
||||
// string. Two items that JSON-encode to the same string are equal for
|
||||
// dedup purposes.
|
||||
func (l *ListOperationsComponent) opDropDuplicates(items []any) []any {
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
out := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
k := dedupKey(item)
|
||||
if _, dup := seen[k]; dup {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupKey JSON-encodes v to a canonical string. Maps are encoded via
|
||||
// a stable intermediate (hashableKey → JSON) so two dicts with the
|
||||
// same content but different Go map iteration orders hash to the same
|
||||
// key.
|
||||
func dedupKey(v any) string {
|
||||
b, err := json.Marshal(hashableKey(v))
|
||||
if err != nil {
|
||||
// Fall back to %v rendering if JSON fails (shouldn't happen
|
||||
// for our supported types).
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// normValue is the Python _norm helper: "" for nil, else str(v).
|
||||
func normValue(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// evalFilter evaluates a single filter rule.
|
||||
func evalFilter(v, op, target string) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return v == target
|
||||
case "≠":
|
||||
return v != target
|
||||
case "contains":
|
||||
return strings.Contains(v, target)
|
||||
case "start with":
|
||||
return strings.HasPrefix(v, target)
|
||||
case "end with":
|
||||
return strings.HasSuffix(v, target)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hashableKey produces a comparable key for items used by sort and
|
||||
// drop_duplicates. Dicts are flattened to a tuple of (key, hashable
|
||||
// value) pairs (sorted by key for determinism). Lists and slices
|
||||
// recurse element-wise.
|
||||
func hashableKey(v any) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
// Stable ordering matters: sort by key string.
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]any, 0, 2*len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, k, hashableKey(x[k]))
|
||||
}
|
||||
return pairs
|
||||
case []any:
|
||||
out := make([]any, 0, len(x))
|
||||
for _, item := range x {
|
||||
out = append(out, hashableKey(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// lessKey compares two hashableKey results (both are themselves []any
|
||||
// of either string-key/value or scalar tuples). Returns true when a<b.
|
||||
func lessKey(a, b any) bool {
|
||||
as, aok := a.([]any)
|
||||
bs, bok := b.([]any)
|
||||
if !aok || !bok {
|
||||
return lessScalar(a, b)
|
||||
}
|
||||
// Element-wise compare
|
||||
for i := 0; i < len(as) && i < len(bs); i++ {
|
||||
if lessScalar(as[i], bs[i]) {
|
||||
return true
|
||||
}
|
||||
if lessScalar(bs[i], as[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(as) < len(bs)
|
||||
}
|
||||
|
||||
// lessScalar compares two scalar (or non-tuple) values. Numbers are
|
||||
// compared numerically; everything else via fmt.Sprintf.
|
||||
func lessScalar(a, b any) bool {
|
||||
af, aok := toFloat64OK(a)
|
||||
bf, bok := toFloat64OK(b)
|
||||
if aok && bok {
|
||||
return af < bf
|
||||
}
|
||||
return fmt.Sprintf("%v", a) < fmt.Sprintf("%v", b)
|
||||
}
|
||||
|
||||
// toFloat64OK is a number-without-bool helper.
|
||||
func toFloat64OK(v any) (float64, bool) {
|
||||
if _, isBool := v.(bool); isBool {
|
||||
return 0, false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case float64:
|
||||
return x, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameListOperations, NewListOperationsComponent)
|
||||
}
|
||||
228
internal/agent/component/list_operations_test.go
Normal file
228
internal/agent/component/list_operations_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestListOperations_Head: [1,2,3,4,5] op=head n=3 → [1,2,3], first=1, last=3.
|
||||
func TestListOperations_Head(t *testing.T) {
|
||||
c, err := NewListOperationsComponent(map[string]any{
|
||||
"query": "cpn_0@xs",
|
||||
"operations": "head",
|
||||
"n": 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewListOperationsComponent: %v", err)
|
||||
}
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{1, 2, 3, 4, 5}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
want := []any{1, 2, 3}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("result: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["first"], 1; got != want {
|
||||
t.Errorf("first: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["last"], 3; got != want {
|
||||
t.Errorf("last: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_Filter: items ["foo", "bar", "foobar"], op=filter,
|
||||
// operator="contains", value="bar" → ["bar", "foobar"].
|
||||
func TestListOperations_Filter(t *testing.T) {
|
||||
c, _ := NewListOperationsComponent(map[string]any{
|
||||
"query": "cpn_0@xs",
|
||||
"operations": "filter",
|
||||
"filter": map[string]any{"operator": "contains", "value": "bar"},
|
||||
})
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{"foo", "bar", "foobar"}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
want := []any{"bar", "foobar"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("filter: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_DropDuplicates: [{"k":1},{"k":1},{"k":2}] → [{"k":1},{"k":2}].
|
||||
func TestListOperations_DropDuplicates(t *testing.T) {
|
||||
c, _ := NewListOperationsComponent(map[string]any{
|
||||
"query": "cpn_0@xs",
|
||||
"operations": "drop_duplicates",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{
|
||||
map[string]any{"k": 1},
|
||||
map[string]any{"k": 1},
|
||||
map[string]any{"k": 2},
|
||||
}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 elements, got %d: %v", len(got), got)
|
||||
}
|
||||
if !reflect.DeepEqual(got[0], map[string]any{"k": 1}) {
|
||||
t.Errorf("got[0]: %v, want {k:1}", got[0])
|
||||
}
|
||||
if !reflect.DeepEqual(got[1], map[string]any{"k": 2}) {
|
||||
t.Errorf("got[1]: %v, want {k:2}", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_Tail: [1,2,3,4,5] op=tail n=2 → [4,5].
|
||||
func TestListOperations_Tail(t *testing.T) {
|
||||
c, _ := NewListOperationsComponent(map[string]any{
|
||||
"query": "cpn_0@xs",
|
||||
"operations": "tail",
|
||||
"n": 2,
|
||||
})
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{1, 2, 3, 4, 5}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
want := []any{4, 5}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("tail: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_NthPositive: [a,b,c,d] n=3 → [c].
|
||||
func TestListOperations_NthPositive(t *testing.T) {
|
||||
c, _ := NewListOperationsComponent(map[string]any{
|
||||
"query": "cpn_0@xs",
|
||||
"operations": "nth",
|
||||
"n": 3,
|
||||
})
|
||||
state := canvas.NewCanvasState("run-5", "task-5")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{"a", "b", "c", "d"}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
want := []any{"c"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("nth: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_SortDesc: numeric sort with sort_method=desc.
|
||||
func TestListOperations_SortDesc(t *testing.T) {
|
||||
c, _ := NewListOperationsComponent(map[string]any{
|
||||
"query": "cpn_0@xs",
|
||||
"operations": "sort",
|
||||
"sort_method": "desc",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-6", "task-6")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{3, 1, 4, 1, 5, 9, 2, 6}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]any)
|
||||
sortedAsc := append([]any{}, got...)
|
||||
sort.Slice(sortedAsc, func(i, j int) bool {
|
||||
af, _ := sortedAsc[i].(int)
|
||||
bf, _ := sortedAsc[j].(int)
|
||||
return af < bf
|
||||
})
|
||||
// Reverse for desc
|
||||
for i, j := 0, len(sortedAsc)-1; i < j; i, j = i+1, j-1 {
|
||||
sortedAsc[i], sortedAsc[j] = sortedAsc[j], sortedAsc[i]
|
||||
}
|
||||
if !reflect.DeepEqual(got, sortedAsc) {
|
||||
t.Errorf("sort desc: got %v, want %v", got, sortedAsc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_NotAList: returns a clear error.
|
||||
func TestListOperations_NotAList(t *testing.T) {
|
||||
c, _ := NewListOperationsComponent(map[string]any{
|
||||
"query": "cpn_0@x",
|
||||
"operations": "head",
|
||||
"n": 1,
|
||||
})
|
||||
state := canvas.NewCanvasState("run-7", "task-7")
|
||||
state.Outputs["cpn_0"] = map[string]any{"x": "not-a-list"}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
_, err := c.Invoke(ctx, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-list input, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_ParamCheck: empty query rejected.
|
||||
func TestListOperations_ParamCheck(t *testing.T) {
|
||||
_, err := NewListOperationsComponent(map[string]any{
|
||||
"query": "",
|
||||
"operations": "head",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty query, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOperations_Registered: factory lookup.
|
||||
func TestListOperations_Registered(t *testing.T) {
|
||||
c, err := New("ListOperations", map[string]any{
|
||||
"query": "sys.x",
|
||||
"operations": "head",
|
||||
"n": 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if c.Name() != "ListOperations" {
|
||||
t.Errorf("Name()=%q, want ListOperations", c.Name())
|
||||
}
|
||||
}
|
||||
490
internal/agent/component/llm.go
Normal file
490
internal/agent/component/llm.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// Package component — LLM (Phase 2 P0, plan §2.11.3 row 5).
|
||||
//
|
||||
// One-shot LLM call. Reads system_prompt + user_prompt, dispatches to a
|
||||
// chat model, and returns the assistant's content. Streaming variant
|
||||
// forwards incremental chunks via Stream.
|
||||
//
|
||||
// Model invocation is abstracted behind a small ChatInvoker interface so
|
||||
// tests can inject a stub without touching the network. The default
|
||||
// ChatInvoker is built around models.NewEinoChatModel so production paths
|
||||
// flow through the eino bridge (plan §2.11.6 D1).
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"ragflow/internal/entity/models"
|
||||
)
|
||||
|
||||
// LLMComponent is a one-shot chat call.
|
||||
type LLMComponent struct {
|
||||
param LLMParam
|
||||
}
|
||||
|
||||
// LLMParam captures the (resolved) DSL parameters for an LLM node.
|
||||
type LLMParam struct {
|
||||
ModelID string
|
||||
SystemPrompt string
|
||||
UserPrompt string
|
||||
Temperature *float64
|
||||
MaxTokens *int
|
||||
JSONOutput bool
|
||||
|
||||
// Driver is the provider driver to use (e.g. "openai", "dummy"). When
|
||||
// empty, the default ChatInvoker will look up a driver from ModelID
|
||||
// (e.g. by attempting NewDummyModel for unknown providers).
|
||||
Driver string
|
||||
|
||||
// APIKey overrides the default empty key. Tests may set this; prod
|
||||
// reads it from env / secret store at higher layers.
|
||||
APIKey string
|
||||
|
||||
// BaseURL overrides the driver default endpoint (e.g. to point the
|
||||
// "openai" driver at a third-party gateway). Empty defers to the
|
||||
// driver's built-in default URL.
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// LLMInput is the resolved input map the factory / Invoke expects.
|
||||
type LLMInput struct {
|
||||
ModelID string
|
||||
SystemPrompt string
|
||||
UserPrompt string
|
||||
Temperature *float64
|
||||
MaxTokens *int
|
||||
JSONOutput bool
|
||||
Driver string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// LLMOutput mirrors the outputs map (per plan §2.11.3 row 5):
|
||||
//
|
||||
// "content" string, "model" string, "stopped" bool, "tokens" int
|
||||
//
|
||||
// JSONOutput=true additionally populates "json" (map[string]any) when the
|
||||
// content parses as a JSON object.
|
||||
type LLMOutput struct {
|
||||
Content string
|
||||
Model string
|
||||
Stopped bool
|
||||
Tokens int
|
||||
}
|
||||
|
||||
// ChatInvoker is the abstraction the LLM component uses to talk to a
|
||||
// chat model. The default implementation lives in this file; tests can
|
||||
// override the package-level defaultChatInvoker to inject a stub.
|
||||
type ChatInvoker interface {
|
||||
Invoke(ctx context.Context, req ChatInvokeRequest) (*ChatInvokeResponse, error)
|
||||
}
|
||||
|
||||
// ChatInvokeRequest is the minimal surface the LLM component needs to
|
||||
// dispatch a chat call. Driver / APIKey / ModelName are kept here so the
|
||||
// invoker can wire the right provider without the component caring.
|
||||
type ChatInvokeRequest struct {
|
||||
Driver string
|
||||
ModelName string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Messages []schema.Message
|
||||
Temperature *float64
|
||||
MaxTokens *int
|
||||
}
|
||||
|
||||
// ChatInvokeResponse mirrors what the LLM component writes to its outputs.
|
||||
type ChatInvokeResponse struct {
|
||||
Content string
|
||||
Model string
|
||||
Stopped bool
|
||||
Tokens int
|
||||
}
|
||||
|
||||
// defaultChatInvokerMu guards defaultChatInvoker swaps during tests.
|
||||
var defaultChatInvokerMu sync.RWMutex
|
||||
|
||||
// defaultChatInvoker is the production ChatInvoker. Replaced in tests.
|
||||
var defaultChatInvoker ChatInvoker = &einoChatInvoker{}
|
||||
|
||||
// SetDefaultChatInvoker swaps the package-level ChatInvoker (test helper).
|
||||
// Pass nil to restore the default. Concurrent-safe.
|
||||
func SetDefaultChatInvoker(inv ChatInvoker) {
|
||||
defaultChatInvokerMu.Lock()
|
||||
defer defaultChatInvokerMu.Unlock()
|
||||
defaultChatInvoker = inv
|
||||
}
|
||||
|
||||
// getDefaultChatInvoker returns the current default ChatInvoker.
|
||||
func getDefaultChatInvoker() ChatInvoker {
|
||||
defaultChatInvokerMu.RLock()
|
||||
defer defaultChatInvokerMu.RUnlock()
|
||||
if defaultChatInvoker == nil {
|
||||
return &einoChatInvoker{}
|
||||
}
|
||||
return defaultChatInvoker
|
||||
}
|
||||
|
||||
// einoChatInvoker is the production ChatInvoker — it constructs a fresh
|
||||
// models.EinoChatModel per call from the request and dispatches.
|
||||
type einoChatInvoker struct{}
|
||||
|
||||
// Invoke satisfies ChatInvoker.
|
||||
func (e *einoChatInvoker) Invoke(ctx context.Context, req ChatInvokeRequest) (*ChatInvokeResponse, error) {
|
||||
if req.ModelName == "" {
|
||||
return nil, fmt.Errorf("component: LLM: model_id is required")
|
||||
}
|
||||
driver := req.Driver
|
||||
if driver == "" {
|
||||
driver = "dummy"
|
||||
}
|
||||
// baseURL: drivers consult map["default"] as the canonical endpoint
|
||||
// (see internal/entity/models/base_model.go:GetBaseURL). When the
|
||||
// caller did not override, leave the driver default in place by
|
||||
// passing nil — every driver seeds its own map at construction time.
|
||||
var baseURL map[string]string
|
||||
if req.BaseURL != "" {
|
||||
baseURL = map[string]string{"default": req.BaseURL}
|
||||
}
|
||||
// urlSuffix: each driver appends URLSuffix.Chat to baseURL to form
|
||||
// the chat-completions endpoint (e.g. "chat/completions" for
|
||||
// openai-compatible drivers, "v1/messages" for anthropic). The
|
||||
// factory's NewModelDriver accepts a zero URLSuffix and stores it
|
||||
// as-is; the openai driver then builds `<base>/` (with no path),
|
||||
// which is the wrong endpoint for a v1-root base URL. We seed
|
||||
// the right suffix per driver here so the factory and the
|
||||
// openai driver's URL construction agree.
|
||||
urlSuffix := chatURLSuffixFor(driver)
|
||||
d, err := models.NewModelFactory().CreateModelDriver(driver, baseURL, urlSuffix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("component: LLM: resolve driver %q: %w", driver, err)
|
||||
}
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("component: LLM: no driver for %q", driver)
|
||||
}
|
||||
apiKey := req.APIKey
|
||||
cfg := &models.APIConfig{ApiKey: &apiKey}
|
||||
cm := models.NewChatModel(d, &req.ModelName, cfg)
|
||||
|
||||
chatCfg := &models.ChatConfig{
|
||||
Temperature: req.Temperature,
|
||||
MaxTokens: req.MaxTokens,
|
||||
}
|
||||
wrapper := models.NewEinoChatModel(cm, chatCfg)
|
||||
out, err := wrapper.Generate(ctx, toEinoMessages(req.Messages))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ChatInvokeResponse{
|
||||
Content: out.Content,
|
||||
Model: req.ModelName,
|
||||
Stopped: true,
|
||||
Tokens: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toEinoMessages converts the LLM component's Message slice to eino's.
|
||||
func toEinoMessages(msgs []schema.Message) []*schema.Message {
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*schema.Message, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
m := msgs[i]
|
||||
role := m.Role
|
||||
if role == "" {
|
||||
role = schema.User
|
||||
}
|
||||
out = append(out, &schema.Message{Role: role, Content: m.Content})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chatURLSuffixFor returns the URLSuffix the factory should pass to
|
||||
// the driver for the chat endpoint. Each driver's ChatWithMessages
|
||||
// builds `baseURL/URLSuffix.Chat`, so the suffix has to match the
|
||||
// provider's actual chat path. We seed the common ones here; for any
|
||||
// driver the factory has no entry for, we fall through to a default
|
||||
// "chat/completions" path (the openai-compatible default), which
|
||||
// matches the dummy driver and any third-party openai-compatible
|
||||
// gateway.
|
||||
func chatURLSuffixFor(driver string) models.URLSuffix {
|
||||
switch strings.ToLower(driver) {
|
||||
case "anthropic":
|
||||
return models.URLSuffix{Chat: "v1/messages"}
|
||||
default:
|
||||
return models.URLSuffix{Chat: "chat/completions"}
|
||||
}
|
||||
}
|
||||
|
||||
// NewLLMComponent builds an LLMComponent from raw params.
|
||||
func NewLLMComponent(p LLMParam) *LLMComponent {
|
||||
return &LLMComponent{param: p}
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (c *LLMComponent) Name() string { return "LLM" }
|
||||
|
||||
// Invoke runs the LLM and returns the output map.
|
||||
func (c *LLMComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
p := mergeLLMParam(c.param, inputs)
|
||||
if p.ModelID == "" {
|
||||
return nil, &ParamError{Field: "model_id", Reason: "required"}
|
||||
}
|
||||
if p.UserPrompt == "" && p.SystemPrompt == "" {
|
||||
return nil, &ParamError{Field: "user_prompt", Reason: "at least one of user_prompt or system_prompt must be set"}
|
||||
}
|
||||
// The Anthropic driver (and the openai chat-completions driver
|
||||
// when the system role is dropped) reject a system-only message
|
||||
// list with "messages is empty" / 400. v1 fixtures frequently
|
||||
// ship only a system prompt; fall back to using the system text
|
||||
// as the user message so the call still goes through. The
|
||||
// answer text in that case is the model continuing the
|
||||
// instruction in its reply slot, which is what the v1 fixtures
|
||||
// also expect.
|
||||
if p.UserPrompt == "" {
|
||||
p.UserPrompt = p.SystemPrompt
|
||||
}
|
||||
|
||||
msgs := buildMessages(p.SystemPrompt, p.UserPrompt)
|
||||
inv := getDefaultChatInvoker()
|
||||
resp, err := inv.Invoke(ctx, ChatInvokeRequest{
|
||||
Driver: p.Driver,
|
||||
ModelName: p.ModelID,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Messages: msgs,
|
||||
Temperature: p.Temperature,
|
||||
MaxTokens: p.MaxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("component: LLM.Invoke: %w", err)
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"content": resp.Content,
|
||||
"model": resp.Model,
|
||||
"stopped": resp.Stopped,
|
||||
"tokens": resp.Tokens,
|
||||
}
|
||||
if p.JSONOutput {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(resp.Content), &parsed); err == nil {
|
||||
out["json"] = parsed
|
||||
} else {
|
||||
// Surface a non-fatal warning — caller can still read "content".
|
||||
log.Printf("component: LLM: json_output=true but content is not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stream implements Component.Stream. It forwards incremental chunks via
|
||||
// the returned channel. When the model finishes, the channel is closed.
|
||||
func (c *LLMComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
result, err := c.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
out <- map[string]any{"error": err.Error()}
|
||||
return
|
||||
}
|
||||
out <- result
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata for tooling.
|
||||
func (c *LLMComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"model_id": "Provider-side model identifier (e.g. \"gpt-4o-mini\")",
|
||||
"system_prompt": "Optional system prompt prepended to the conversation",
|
||||
"user_prompt": "User prompt; supports {{cpn_id@param}} references resolved by the canvas engine",
|
||||
"temperature": "Sampling temperature (0.0-2.0). Optional.",
|
||||
"max_tokens": "Maximum tokens to generate. Optional.",
|
||||
"json_output": "If true, attempt to JSON-parse \"content\" into \"json\" output key.",
|
||||
"driver": "Provider driver name (openai, anthropic, …). Defaults to \"dummy\".",
|
||||
"api_key": "Override API key for this call. Empty defers to env.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns output metadata.
|
||||
func (c *LLMComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"content": "Assistant text response",
|
||||
"model": "Model identifier echoed back (sanity check)",
|
||||
"stopped": "True if the model finished naturally",
|
||||
"tokens": "Reported token count (0 when not reported by the driver)",
|
||||
"json": "When json_output=true and content parses as a JSON object, the parsed map",
|
||||
}
|
||||
}
|
||||
|
||||
// buildMessages assembles a system + user message sequence. Order:
|
||||
// system first (if set), then user.
|
||||
func buildMessages(system, user string) []schema.Message {
|
||||
out := make([]schema.Message, 0, 2)
|
||||
if system != "" {
|
||||
out = append(out, schema.Message{Role: schema.System, Content: system})
|
||||
}
|
||||
if user != "" {
|
||||
out = append(out, schema.Message{Role: schema.User, Content: user})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeLLMParam layers raw inputs over the receiver's default param set.
|
||||
//
|
||||
// v1 DSL aliases accepted alongside the v2 names:
|
||||
//
|
||||
// "llm_id" → "model_id"
|
||||
// "sys_prompt" → "system_prompt"
|
||||
// "base_url" → "BaseURL"
|
||||
//
|
||||
// The v1 fixtures in internal/agent/dsl/testdata/v1_examples use the
|
||||
// short forms; without these aliases the v1→v2 conversion (plan §2.5)
|
||||
// would have to be run before the factory builds the component, which
|
||||
// the e2e compile+invoke path doesn't do.
|
||||
func mergeLLMParam(base LLMParam, inputs map[string]any) LLMParam {
|
||||
p := base
|
||||
if v, ok := stringFrom(inputs, "model_id"); ok {
|
||||
p.ModelID = v
|
||||
} else if v, ok := stringFrom(inputs, "llm_id"); ok {
|
||||
p.ModelID = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "system_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
} else if v, ok := stringFrom(inputs, "sys_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "user_prompt"); ok {
|
||||
p.UserPrompt = v
|
||||
}
|
||||
if v, ok := boolFrom(inputs, "json_output"); ok {
|
||||
p.JSONOutput = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "driver"); ok {
|
||||
p.Driver = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "api_key"); ok {
|
||||
p.APIKey = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "base_url"); ok {
|
||||
p.BaseURL = v
|
||||
}
|
||||
if v, ok := floatFrom(inputs, "temperature"); ok {
|
||||
f := v
|
||||
p.Temperature = &f
|
||||
}
|
||||
if v, ok := intFrom(inputs, "max_tokens"); ok {
|
||||
i := v
|
||||
p.MaxTokens = &i
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// stringFrom extracts a string from inputs[name], accepting both string and
|
||||
// fmt.Stringer-able values.
|
||||
func stringFrom(inputs map[string]any, name string) (string, bool) {
|
||||
v, ok := inputs[name]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// boolFrom extracts a bool from inputs[name].
|
||||
func boolFrom(inputs map[string]any, name string) (bool, bool) {
|
||||
v, ok := inputs[name]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
if b, ok := v.(bool); ok {
|
||||
return b, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// floatFrom extracts a float64 from inputs[name], also accepting int.
|
||||
func floatFrom(inputs map[string]any, name string) (float64, bool) {
|
||||
v, ok := inputs[name]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// intFrom extracts an int from inputs[name], also accepting float64.
|
||||
func intFrom(inputs map[string]any, name string) (int, bool) {
|
||||
v, ok := inputs[name]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x, true
|
||||
case int64:
|
||||
return int(x), true
|
||||
case float64:
|
||||
return int(x), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// init registers LLMComponent with the orchestrator-owned registry.
|
||||
func init() {
|
||||
Register("LLM", func(params map[string]any) (Component, error) {
|
||||
var p LLMParam
|
||||
if v, ok := stringFrom(params, "model_id"); ok {
|
||||
p.ModelID = v
|
||||
} else if v, ok := stringFrom(params, "llm_id"); ok {
|
||||
p.ModelID = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "system_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
} else if v, ok := stringFrom(params, "sys_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "user_prompt"); ok {
|
||||
p.UserPrompt = v
|
||||
}
|
||||
if v, ok := floatFrom(params, "temperature"); ok {
|
||||
f := v
|
||||
p.Temperature = &f
|
||||
}
|
||||
if v, ok := intFrom(params, "max_tokens"); ok {
|
||||
i := v
|
||||
p.MaxTokens = &i
|
||||
}
|
||||
if v, ok := boolFrom(params, "json_output"); ok {
|
||||
p.JSONOutput = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "driver"); ok {
|
||||
p.Driver = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "api_key"); ok {
|
||||
p.APIKey = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "base_url"); ok {
|
||||
p.BaseURL = v
|
||||
}
|
||||
return NewLLMComponent(p), nil
|
||||
})
|
||||
}
|
||||
197
internal/agent/component/llm_test.go
Normal file
197
internal/agent/component/llm_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Package component — LLM unit tests (Phase 2 P0, plan §2.11.3 row 5).
|
||||
//
|
||||
// Tests use a stub ChatInvoker to avoid the network. The production path
|
||||
// flows through einoChatInvoker + models.NewEinoChatModel + the real
|
||||
// provider driver; here we focus on the component contract:
|
||||
// - inputs → outputs map shape
|
||||
// - json_output parsing
|
||||
// - Stream variant emits the same payload + closes
|
||||
// - error path surfaces invoker errors
|
||||
// - variable reference substitution is the canvas engine's job, not
|
||||
// this component's — we only verify the raw user_prompt is passed
|
||||
// through to the invoker.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// stubInvoker is a programmable ChatInvoker used by these tests.
|
||||
type stubInvoker struct {
|
||||
resp *ChatInvokeResponse
|
||||
err error
|
||||
captured *ChatInvokeRequest
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubInvoker) Invoke(_ context.Context, req ChatInvokeRequest) (*ChatInvokeResponse, error) {
|
||||
s.calls++
|
||||
cp := req
|
||||
s.captured = &cp
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.resp, nil
|
||||
}
|
||||
|
||||
// withStubInvoker swaps the package-level ChatInvoker for the duration of t.
|
||||
func withStubInvoker(t *testing.T, s ChatInvoker) {
|
||||
t.Helper()
|
||||
prev := getDefaultChatInvoker()
|
||||
SetDefaultChatInvoker(s)
|
||||
t.Cleanup(func() { SetDefaultChatInvoker(prev) })
|
||||
}
|
||||
|
||||
func TestLLM_Invoke_HappyPath(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "hello", Model: "echo-model", Stopped: true, Tokens: 7}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewLLMComponent(LLMParam{ModelID: "echo-model"})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "hi",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["content"], "hello"; got != want {
|
||||
t.Errorf("content=%v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["model"], "echo-model"; got != want {
|
||||
t.Errorf("model=%v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["stopped"], true; got != want {
|
||||
t.Errorf("stopped=%v, want %v", got, want)
|
||||
}
|
||||
if stub.calls != 1 {
|
||||
t.Errorf("invoker calls=%d, want 1", stub.calls)
|
||||
}
|
||||
if stub.captured == nil || stub.captured.ModelName != "echo-model" {
|
||||
t.Errorf("ModelName not propagated: %+v", stub.captured)
|
||||
}
|
||||
if len(stub.captured.Messages) != 1 || stub.captured.Messages[0].Role != schema.User || stub.captured.Messages[0].Content != "hi" {
|
||||
t.Errorf("messages not built correctly: %+v", stub.captured.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLM_Invoke_JSONOutput(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: `{"k":"v"}`, Model: "echo", Stopped: true}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewLLMComponent(LLMParam{ModelID: "echo"})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "give me json",
|
||||
"json_output": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["content"], `{"k":"v"}`; got != want {
|
||||
t.Errorf("content=%v, want %v", got, want)
|
||||
}
|
||||
parsed, ok := out["json"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("json output missing or wrong type: %T", out["json"])
|
||||
}
|
||||
if parsed["k"] != "v" {
|
||||
t.Errorf("json[k]=%v, want v", parsed["k"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLM_Invoke_SystemAndUser(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "ok", Model: "echo"}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewLLMComponent(LLMParam{ModelID: "echo"})
|
||||
_, err := c.Invoke(context.Background(), map[string]any{
|
||||
"system_prompt": "you are helpful",
|
||||
"user_prompt": "say hi",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got := len(stub.captured.Messages); got != 2 {
|
||||
t.Fatalf("messages=%d, want 2", got)
|
||||
}
|
||||
if stub.captured.Messages[0].Role != schema.System || stub.captured.Messages[0].Content != "you are helpful" {
|
||||
t.Errorf("system msg wrong: %+v", stub.captured.Messages[0])
|
||||
}
|
||||
if stub.captured.Messages[1].Role != schema.User || stub.captured.Messages[1].Content != "say hi" {
|
||||
t.Errorf("user msg wrong: %+v", stub.captured.Messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLM_Stream(t *testing.T) {
|
||||
stub := &stubInvoker{resp: &ChatInvokeResponse{Content: "streamed", Model: "echo", Stopped: true}}
|
||||
withStubInvoker(t, stub)
|
||||
|
||||
c := NewLLMComponent(LLMParam{ModelID: "echo"})
|
||||
ch, err := c.Stream(context.Background(), map[string]any{"user_prompt": "go"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
select {
|
||||
case got = <-ch:
|
||||
case <-context.Background().Done():
|
||||
t.Fatal("context cancelled before chunk")
|
||||
}
|
||||
if got["content"] != "streamed" {
|
||||
t.Errorf("chunk content=%v, want 'streamed'", got["content"])
|
||||
}
|
||||
// Verify the channel closes after the single chunk.
|
||||
if _, open := <-ch; open {
|
||||
t.Error("Stream channel did not close after single chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLM_Invoke_MissingModelID(t *testing.T) {
|
||||
withStubInvoker(t, &stubInvoker{resp: &ChatInvokeResponse{Content: "should not be called"}})
|
||||
c := NewLLMComponent(LLMParam{}) // no model_id
|
||||
_, err := c.Invoke(context.Background(), map[string]any{"user_prompt": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected ParamError for missing model_id")
|
||||
}
|
||||
var pe *ParamError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Errorf("err type=%T, want *ParamError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLM_Invoke_InvokerError(t *testing.T) {
|
||||
stub := &stubInvoker{err: errors.New("upstream blew up")}
|
||||
withStubInvoker(t, stub)
|
||||
c := NewLLMComponent(LLMParam{ModelID: "echo"})
|
||||
_, err := c.Invoke(context.Background(), map[string]any{"user_prompt": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error to propagate")
|
||||
}
|
||||
if stub.calls != 1 {
|
||||
t.Errorf("calls=%d, want 1", stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLM_Registered(t *testing.T) {
|
||||
names := RegisteredNames()
|
||||
found := false
|
||||
for _, n := range names {
|
||||
if n == "llm" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("LLM not registered; names=%v", names)
|
||||
}
|
||||
// And a factory round-trip.
|
||||
c, err := New("LLM", map[string]any{"model_id": "echo"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(LLM): %v", err)
|
||||
}
|
||||
if c.Name() != "LLM" {
|
||||
t.Errorf("Name()=%q, want LLM", c.Name())
|
||||
}
|
||||
}
|
||||
219
internal/agent/component/loop.go
Normal file
219
internal/agent/component/loop.go
Normal file
@@ -0,0 +1,219 @@
|
||||
//
|
||||
// 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 component — Loop component (T3, plan §2.11.3 row 11).
|
||||
//
|
||||
// Loop is the parent node for a conditional loop subgraph. The Go port
|
||||
// implements a single-node loop driven by workflowx.AddLoopNode: when
|
||||
// BuildWorkflow sees a Loop cpn, it collects the Loop's downstream
|
||||
// descendants into a sub-graph (see canvas/loop_subgraph.go), installs
|
||||
// a workflowx.AddLoopNode in place of the Loop subtree, and skips
|
||||
// Loop in the main node-registration pass.
|
||||
//
|
||||
// As a result, LoopComponent itself does NOT do any per-iteration
|
||||
// work at runtime. LoopComponent.Invoke is a no-op marker that
|
||||
// returns an empty map; the actual loop iteration is driven by
|
||||
// the sub-graph's init lambda (which seeds loop_variables into
|
||||
// CanvasState) and the sub-workflow's per-iteration body. Loop
|
||||
// termination is driven by the workflowx.LoopCondition produced by
|
||||
// translateLoopCondition from the DSL's loop_termination_condition
|
||||
// list.
|
||||
//
|
||||
// The component still exists in the registry so:
|
||||
// - tooling / introspection (component.New, RegisteredNames) work;
|
||||
// - factory-style wiring can still construct a LoopComponent from
|
||||
// a params map (useful for tests and direct API callers);
|
||||
//
|
||||
// loopParam and its Update/Check/AsDict methods stay because they
|
||||
// describe the canonical Loop DSL shape, even though runtime path
|
||||
// bypasses them (canvas.buildLoopExpansion parses the raw params
|
||||
// map directly). Keep them as a single source of truth for what a
|
||||
// Loop params block looks like.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const componentNameLoop = "Loop"
|
||||
|
||||
// LoopComponent is the canvas-level loop parent. The runtime loop
|
||||
// driver lives in workflowx.AddLoopNode, not in this type. The
|
||||
// component exists for registry / factory / introspection only —
|
||||
// Invoke is a no-op that returns an empty map.
|
||||
type LoopComponent struct {
|
||||
param loopParam
|
||||
}
|
||||
|
||||
// loopParam captures the (resolved) DSL parameters for a Loop node.
|
||||
// Only `loop_variables` and `loop_termination_condition` are
|
||||
// meaningful; the parent.get_start() walk that the Python version
|
||||
// performs (loop.py:46-51) is an engine concern handled by
|
||||
// canvas.buildLoopExpansion at BuildWorkflow time.
|
||||
type loopParam struct {
|
||||
// LoopVariables is the list of variable initializers. Each entry is
|
||||
// a map with keys {variable, input_mode, value, type}. The slice
|
||||
// pointer is shared with the DSL loader — callers should treat it
|
||||
// as read-only.
|
||||
LoopVariables []map[string]any
|
||||
|
||||
// LoopTerminationCondition is the list of termination conditions.
|
||||
// Each entry is a map with keys {variable, operator, value,
|
||||
// input_mode}. The condition list is translated to a
|
||||
// workflowx.LoopCondition closure by canvas.translateLoopCondition.
|
||||
LoopTerminationCondition []map[string]any
|
||||
|
||||
// LogicalOperator combines per-condition results: "and" (default)
|
||||
// or "or".
|
||||
LogicalOperator string
|
||||
|
||||
// MaximumLoopCount caps the iteration count. 0 = infinite.
|
||||
MaximumLoopCount int
|
||||
}
|
||||
|
||||
// Update copies conf into p. Used by the editor / API to hand-craft a
|
||||
// params map; type validation is intentionally minimal in P2.
|
||||
func (p *loopParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
return nil
|
||||
}
|
||||
if raw, ok := conf["loop_variables"]; ok {
|
||||
p.LoopVariables = toAnyMapSlice(raw)
|
||||
}
|
||||
if raw, ok := conf["loop_termination_condition"]; ok {
|
||||
p.LoopTerminationCondition = toAnyMapSlice(raw)
|
||||
}
|
||||
if v, ok := stringFrom(conf, "logical_operator"); ok {
|
||||
p.LogicalOperator = v
|
||||
}
|
||||
if v, ok := intFrom(conf, "maximum_loop_count"); ok {
|
||||
p.MaximumLoopCount = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check performs shallow validation. The Python check() at loop.py:39
|
||||
// always returns True; we mirror that.
|
||||
func (p *loopParam) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map for serialization / debug.
|
||||
func (p *loopParam) AsDict() map[string]any {
|
||||
out := map[string]any{}
|
||||
if p.LoopVariables != nil {
|
||||
out["loop_variables"] = p.LoopVariables
|
||||
}
|
||||
if p.LoopTerminationCondition != nil {
|
||||
out["loop_termination_condition"] = p.LoopTerminationCondition
|
||||
}
|
||||
if p.LogicalOperator != "" {
|
||||
out["logical_operator"] = p.LogicalOperator
|
||||
}
|
||||
if p.MaximumLoopCount > 0 {
|
||||
out["maximum_loop_count"] = p.MaximumLoopCount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewLoopComponent builds a LoopComponent from the supplied param struct.
|
||||
func NewLoopComponent(p loopParam) *LoopComponent {
|
||||
return &LoopComponent{param: p}
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (c *LoopComponent) Name() string { return componentNameLoop }
|
||||
|
||||
// Inputs returns parameter metadata for tooling.
|
||||
func (c *LoopComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"cpn_id": "Stable component identifier — BuildWorkflow uses this to detect Loop and apply the workflowx.AddLoopNode macro expansion.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the Loop's public outputs. In the new architecture,
|
||||
// the actual loop output is the last iteration's body output, which
|
||||
// flows through the eino sub-graph node. LoopComponent itself emits
|
||||
// no outputs; this map documents the contract for downstream
|
||||
// consumers reading the sub-graph's result via FieldMapping.
|
||||
func (c *LoopComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"_result": "Final iteration output (set by the sub-graph, not by LoopComponent.Invoke).",
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke is a no-op marker. The real per-iteration work runs inside
|
||||
// the sub-graph (init lambda seeds loop_variables into state; the
|
||||
// sub-workflow runs the body; the LoopCondition closure evaluates
|
||||
// termination on every iteration). LoopComponent.Invoke is kept on
|
||||
// the Component interface for callers that construct a LoopComponent
|
||||
// directly outside the canvas engine (e.g. unit tests that want to
|
||||
// verify registration); under the canvas engine, this method is
|
||||
// never called.
|
||||
//
|
||||
// The returned map is empty. State writes from this method would be
|
||||
// silently dropped by the eino graph, because LoopComponent is not
|
||||
// registered as an eino node when the macro expansion fires.
|
||||
func (c *LoopComponent) Invoke(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke and emits an empty map as a single chunk.
|
||||
func (c *LoopComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := c.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// toAnyMapSlice accepts either []map[string]any or []any and returns
|
||||
// the canonical []map[string]any view. Unknown element types are
|
||||
// skipped silently — the per-item check in the canvas layer will
|
||||
// surface the malformed entry.
|
||||
func toAnyMapSlice(raw any) []map[string]any {
|
||||
switch v := raw.(type) {
|
||||
case []map[string]any:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, e := range v {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// init registers LoopComponent with the orchestrator-owned registry.
|
||||
//
|
||||
// LoopComponent.Invoke is a no-op; the runtime loop driver lives in
|
||||
// workflowx.AddLoopNode and is installed by canvas.BuildWorkflow
|
||||
// when it sees a Loop cpn in the DSL.
|
||||
func init() {
|
||||
Register(componentNameLoop, func(params map[string]any) (Component, error) {
|
||||
var p loopParam
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewLoopComponent(p), nil
|
||||
})
|
||||
}
|
||||
183
internal/agent/component/loop_test.go
Normal file
183
internal/agent/component/loop_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// 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 component — Loop unit tests (Phase 2 P2, plan §2.11.3 row 11).
|
||||
//
|
||||
// LoopComponent is a no-op marker in the new architecture: real loop
|
||||
// execution is driven by workflowx.AddLoopNode, installed by
|
||||
// canvas.BuildWorkflow when it sees a Loop cpn in the DSL. The tests
|
||||
// in this file exercise the contract LoopComponent DOES expose —
|
||||
// registry / factory / param parsing / Name / Inputs / Outputs /
|
||||
// no-op Invoke / no-op Stream — and confirm the LoopItem / ExitLoop
|
||||
// names are gone from the registry.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoop_Registered confirms "Loop" is in the registry and
|
||||
// "LoopItem" / "ExitLoop" are not. The canvas engine relies on this
|
||||
// for DSL introspection (component.RegisteredNames).
|
||||
func TestLoop_Registered(t *testing.T) {
|
||||
names := RegisteredNames()
|
||||
hasLoop, hasLoopItem, hasExitLoop := false, false, false
|
||||
for _, n := range names {
|
||||
switch n {
|
||||
case "loop":
|
||||
hasLoop = true
|
||||
case "loopitem":
|
||||
hasLoopItem = true
|
||||
case "exitloop":
|
||||
hasExitLoop = true
|
||||
}
|
||||
}
|
||||
if !hasLoop {
|
||||
t.Errorf("Loop not registered; RegisteredNames=%v", names)
|
||||
}
|
||||
if hasLoopItem {
|
||||
t.Errorf("LoopItem is still registered; expected gone. RegisteredNames=%v", names)
|
||||
}
|
||||
if hasExitLoop {
|
||||
t.Errorf("ExitLoop is still registered; expected gone. RegisteredNames=%v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_FactoryReturnsComponent confirms the factory registered
|
||||
// for "Loop" produces a Component.
|
||||
func TestLoop_FactoryReturnsComponent(t *testing.T) {
|
||||
c, err := New("Loop", map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{"variable": "x", "input_mode": "constant", "value": 1, "type": "number"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(Loop): %v", err)
|
||||
}
|
||||
if c.Name() != "Loop" {
|
||||
t.Errorf("Name: got %q, want \"Loop\"", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_InvokeIsNoOp confirms LoopComponent.Invoke returns an
|
||||
// empty map and a nil error. State writes from this method are
|
||||
// silently dropped by the eino graph because LoopComponent is not
|
||||
// registered as an eino node when the macro expansion fires.
|
||||
func TestLoop_InvokeIsNoOp(t *testing.T) {
|
||||
c := NewLoopComponent(loopParam{
|
||||
LoopVariables: []map[string]any{
|
||||
{"variable": "counter", "input_mode": "constant", "value": 7, "type": "number"},
|
||||
},
|
||||
})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{"in": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("Invoke should return an empty map, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_StreamMirrorsInvoke confirms Stream yields exactly one
|
||||
// empty-map chunk and closes.
|
||||
func TestLoop_StreamMirrorsInvoke(t *testing.T) {
|
||||
c := NewLoopComponent(loopParam{})
|
||||
ch, err := c.Stream(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
got, ok := <-ch
|
||||
if !ok {
|
||||
t.Fatal("Stream channel closed without emitting")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("Stream chunk: got %v, want empty map", got)
|
||||
}
|
||||
if _, open := <-ch; open {
|
||||
t.Errorf("Stream channel did not close after one chunk")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_ParamUpdate covers the loopParam.Update contract for the
|
||||
// loop_variables, loop_termination_condition, logical_operator and
|
||||
// maximum_loop_count fields. The canvas package's buildLoopExpansion
|
||||
// reads these from the raw params map directly, but loopParam.Update
|
||||
// is the canonical parser that the factory uses; it must round-trip
|
||||
// the four supported fields.
|
||||
func TestLoop_ParamUpdate(t *testing.T) {
|
||||
var p loopParam
|
||||
if err := p.Update(map[string]any{
|
||||
"loop_variables": []any{
|
||||
map[string]any{"variable": "x", "input_mode": "constant", "value": 0, "type": "number"},
|
||||
},
|
||||
"loop_termination_condition": []any{
|
||||
map[string]any{"variable": "x", "operator": "≥", "value": 3, "input_mode": "constant"},
|
||||
},
|
||||
"logical_operator": "or",
|
||||
"maximum_loop_count": 5,
|
||||
}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if got := len(p.LoopVariables); got != 1 {
|
||||
t.Errorf("LoopVariables: got %d, want 1", got)
|
||||
}
|
||||
if got := len(p.LoopTerminationCondition); got != 1 {
|
||||
t.Errorf("LoopTerminationCondition: got %d, want 1", got)
|
||||
}
|
||||
if p.LogicalOperator != "or" {
|
||||
t.Errorf("LogicalOperator: got %q, want \"or\"", p.LogicalOperator)
|
||||
}
|
||||
if p.MaximumLoopCount != 5 {
|
||||
t.Errorf("MaximumLoopCount: got %d, want 5", p.MaximumLoopCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_ParamAsDict confirms AsDict round-trips the four
|
||||
// supported fields when set, and omits them when zero.
|
||||
func TestLoop_ParamAsDict(t *testing.T) {
|
||||
p := &loopParam{
|
||||
LoopVariables: []map[string]any{
|
||||
{"variable": "x", "input_mode": "constant", "value": 0, "type": "number"},
|
||||
},
|
||||
LogicalOperator: "and",
|
||||
MaximumLoopCount: 0,
|
||||
}
|
||||
d := p.AsDict()
|
||||
if _, ok := d["loop_variables"]; !ok {
|
||||
t.Errorf("AsDict: missing loop_variables")
|
||||
}
|
||||
if v, _ := d["logical_operator"].(string); v != "and" {
|
||||
t.Errorf("AsDict logical_operator: got %v, want \"and\"", v)
|
||||
}
|
||||
if _, ok := d["maximum_loop_count"]; ok {
|
||||
t.Errorf("AsDict: maximum_loop_count=0 should be omitted")
|
||||
}
|
||||
|
||||
// Zero loopParam → empty AsDict.
|
||||
empty := (&loopParam{}).AsDict()
|
||||
if len(empty) != 0 {
|
||||
t.Errorf("AsDict zero: got %v, want empty", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoop_ParamCheckAlwaysTrue confirms Check is a no-op validator
|
||||
// (mirrors Python's always-True check()).
|
||||
func TestLoop_ParamCheckAlwaysTrue(t *testing.T) {
|
||||
if err := (&loopParam{}).Check(); err != nil {
|
||||
t.Errorf("Check: got %v, want nil", err)
|
||||
}
|
||||
}
|
||||
181
internal/agent/component/message.go
Normal file
181
internal/agent/component/message.go
Normal file
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// 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 component — Message component (T3, plan §2.11.3 row 4).
|
||||
//
|
||||
// Message is the canvas terminal output node. It resolves a Jinja2-style
|
||||
// {{...}} template against the current *CanvasState and (optionally) emits
|
||||
// the result as a single SSE chunk. Memory persistence and chunked
|
||||
// streaming are deferred (plan §2.11.6 P0 scope): the P0 implementation
|
||||
// resolves the template once, returns it as outputs["content"], and
|
||||
// exposes Stream() that yields one chunk + closes.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameMessage = "Message"
|
||||
|
||||
// MessageComponent is the canvas terminal output node. It owns the
|
||||
// resolved text template as a per-instance field — the factory sets
|
||||
// it from the DSL params at build time, and Invoke falls back to it
|
||||
// when the input map does not carry a fresh "text" override.
|
||||
type MessageComponent struct {
|
||||
name string
|
||||
text string
|
||||
}
|
||||
|
||||
// NewMessageComponent constructs a Message component. The params map
|
||||
// may carry:
|
||||
//
|
||||
// - "text" (string) — the canonical v2 name
|
||||
// - "content" (string | []string | []any) — the v1 name; when a
|
||||
// list, the first element is taken as the template (matches the
|
||||
// Python v1 message surface where content is a list of paragraphs)
|
||||
//
|
||||
// At least one must produce a non-empty string; otherwise the node
|
||||
// emits an empty content (it is the canvas terminal, so a runtime
|
||||
// error would be louder than a missing template).
|
||||
func NewMessageComponent(params map[string]any) (Component, error) {
|
||||
tpl := extractMessageText(params)
|
||||
return &MessageComponent{name: componentNameMessage, text: tpl}, nil
|
||||
}
|
||||
|
||||
// extractMessageText reads text / content from params in the v1 / v2
|
||||
// order documented on NewMessageComponent. Returns the empty string
|
||||
// when neither key is present or the value is not a string-shaped
|
||||
// scalar.
|
||||
func extractMessageText(params map[string]any) string {
|
||||
if v, ok := params["text"].(string); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := params["content"]; ok {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []string:
|
||||
if len(x) > 0 {
|
||||
return x[0]
|
||||
}
|
||||
case []any:
|
||||
if len(x) > 0 {
|
||||
if s, ok := x[0].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (m *MessageComponent) Name() string { return m.name }
|
||||
|
||||
// Invoke resolves inputs["text"] (or the per-instance text seeded
|
||||
// from params at build time) as a template against the current
|
||||
// *CanvasState, returns the resolved string at outputs["content"], and
|
||||
// (if inputs["stream"] == true) records the number of chunks in
|
||||
// outputs["streamed_chunks"]. Memory persistence (memory_save) is
|
||||
// logged as deferred to a later phase per the P0 plan.
|
||||
//
|
||||
// inputs["text"] takes precedence over the per-instance text so the
|
||||
// same node can be reused with different templates at run time when
|
||||
// the orchestrator wants to override the DSL-declared value.
|
||||
func (m *MessageComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Message: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("Message: nil canvas state")
|
||||
}
|
||||
|
||||
text, _ := inputs["text"].(string)
|
||||
if text == "" {
|
||||
text = m.text
|
||||
}
|
||||
resolved, err := runtime.ResolveTemplate(text, state)
|
||||
if err != nil {
|
||||
// ResolveTemplate surfaces unresolved references as errors, but
|
||||
// the partial output (with empty-string substitutions) is still
|
||||
// returned so the SSE consumer can choose to log it. Match
|
||||
// the existing canvas package's contract here.
|
||||
return nil, fmt.Errorf("Message: template resolve: %w", err)
|
||||
}
|
||||
|
||||
if memSave, _ := inputs["memory_save"].(bool); memSave {
|
||||
log.Printf("Message: memory_save=true (memory persistence deferred to Phase 2.5)")
|
||||
}
|
||||
|
||||
out := map[string]any{"content": resolved}
|
||||
if streamOn, _ := inputs["stream"].(bool); streamOn {
|
||||
// P0: one chunk for the whole resolved content. A later phase
|
||||
// can split on token / sentence boundaries.
|
||||
out["streamed_chunks"] = 1
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stream is the SSE variant. The P0 implementation produces a single
|
||||
// chunk containing the resolved content (key "content") and closes the
|
||||
// channel. A future phase can split the resolved string into multiple
|
||||
// chunks; for now the contract is "one chunk, then close".
|
||||
func (m *MessageComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := m.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the public parameter surface. Field types match the
|
||||
// Python DSL contract (text template, stream toggle, memory_save toggle).
|
||||
func (m *MessageComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"text": "Template string with {{...}} references; resolved against the canvas state.",
|
||||
"stream": "When true, the resolved content is delivered as an SSE stream (P0: one chunk).",
|
||||
"memory_save": "When true, persist the message to API4Conversation (deferred to Phase 2.5).",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the resolved template plus the streamed-chunk counter.
|
||||
func (m *MessageComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"content": "Resolved template string (the message text).",
|
||||
"streamed_chunks": "Number of SSE chunks emitted (present when stream=true).",
|
||||
}
|
||||
}
|
||||
|
||||
// mapCopy shallow-copies src into a fresh map. Used to keep Message's
|
||||
// passthrough outputs un-aliased from the caller's inputs map.
|
||||
func mapCopy(src map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(src))
|
||||
maps.Copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameMessage, NewMessageComponent)
|
||||
}
|
||||
103
internal/agent/component/message_test.go
Normal file
103
internal/agent/component/message_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestMessage_ResolveTemplate asserts the canonical {{sys.x}} substitution
|
||||
// flow: a state with sys.query="world" and a template "hello {{sys.query}}"
|
||||
// must resolve to "hello world". streamed_chunks must NOT be set when
|
||||
// stream=false.
|
||||
func TestMessage_ResolveTemplate(t *testing.T) {
|
||||
c, _ := NewMessageComponent(nil)
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
state.Sys["query"] = "world"
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"text": "hello {{sys.query}}",
|
||||
"stream": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["content"].(string)
|
||||
if got != "hello world" {
|
||||
t.Errorf("content: got %q, want %q", got, "hello world")
|
||||
}
|
||||
if _, ok := out["streamed_chunks"]; ok {
|
||||
t.Errorf("streamed_chunks must not be present when stream=false, got %v", out["streamed_chunks"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessage_Stream confirms the Stream() contract: the returned channel
|
||||
// receives exactly one payload (the resolved content + streamed_chunks=1)
|
||||
// and then closes. Phase 2.5 may split into multiple chunks; P0 ships one.
|
||||
func TestMessage_Stream(t *testing.T) {
|
||||
c, _ := NewMessageComponent(nil)
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
state.Sys["query"] = "alice"
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
ch, err := c.Stream(ctx, map[string]any{
|
||||
"text": "hi {{sys.query}}",
|
||||
"stream": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
ev, ok := <-ch
|
||||
if !ok {
|
||||
t.Fatal("expected at least one chunk on the channel, got none (channel closed early)")
|
||||
}
|
||||
// Channel must close after the single chunk.
|
||||
if _, open := <-ch; open {
|
||||
t.Error("Stream channel should close after one chunk; got additional payload")
|
||||
}
|
||||
if got, _ := ev["content"].(string); got != "hi alice" {
|
||||
t.Errorf("chunk[content]: got %q, want %q", got, "hi alice")
|
||||
}
|
||||
if n, _ := ev["streamed_chunks"].(int); n != 1 {
|
||||
t.Errorf("chunk[streamed_chunks]: got %v, want 1", ev["streamed_chunks"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessage_NoTemplate verifies the no-ref passthrough: a plain text
|
||||
// without any {{...}} reference is returned verbatim.
|
||||
func TestMessage_NoTemplate(t *testing.T) {
|
||||
c, _ := NewMessageComponent(nil)
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{"text": "no refs here", "stream": false})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["content"].(string); got != "no refs here" {
|
||||
t.Errorf("content: got %q, want %q", got, "no refs here")
|
||||
}
|
||||
}
|
||||
|
||||
// withStateForTest is a thin alias for canvas.WithState kept for
|
||||
// readability at the test call sites (also defined in begin_test.go).
|
||||
// Same package → same symbol; defining it twice is a compile error,
|
||||
// so the canonical declaration lives in begin_test.go.
|
||||
186
internal/agent/component/parallel.go
Normal file
186
internal/agent/component/parallel.go
Normal file
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// 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 component — Parallel component (T3, plan §2.11.3 row 9).
|
||||
//
|
||||
// Parallel is the parent node for an array-iteration subgraph. The Go port
|
||||
// implements a single-node parallel fan-out driven by workflowx.AddParallelNode:
|
||||
// when BuildWorkflow sees a Parallel cpn, it collects the Parallel's
|
||||
// downstream descendants into a sub-graph, installs a workflowx.AddParallelNode
|
||||
// in place of the Parallel subtree, and skips Parallel in the main
|
||||
// node-registration pass.
|
||||
//
|
||||
// As a result, ParallelComponent itself does NOT do any per-item
|
||||
// work at runtime. ParallelComponent.Invoke is a no-op marker that
|
||||
// returns an empty map; the actual iteration is driven by the
|
||||
// sub-graph run once per input item via AddParallelNode. Items are
|
||||
// processed with bounded concurrency; the output list order strictly
|
||||
// corresponds to the input list order (see
|
||||
// .claude/plans/eino-workflow-parallel.md §5 Order preservation).
|
||||
//
|
||||
// The component still exists in the registry so:
|
||||
// - tooling / introspection (component.New, RegisteredNames) work;
|
||||
// - factory-style wiring can still construct a ParallelComponent from
|
||||
// a params map (useful for tests and direct API callers);
|
||||
//
|
||||
// ParallelParam and its Update/Check/AsDict methods stay because they
|
||||
// describe the canonical Parallel DSL shape, even though the runtime path
|
||||
// bypasses them (canvas.buildParallelExpansion parses the raw params
|
||||
// map directly). Keep them as a single source of truth for what a
|
||||
// Parallel params block looks like.
|
||||
//
|
||||
// The former Iteration/IterationItem component pair is subsumed into
|
||||
// this single Parallel component: per-item execution is handled by the
|
||||
// sub-graph body nodes, and the fan-out orchestration (counter, _done
|
||||
// signalling, output collation) is handled by workflowx.AddParallelNode.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const componentNameParallel = "Parallel"
|
||||
|
||||
// ParallelComponent is the canvas-level parallel parent. The runtime
|
||||
// parallel driver lives in workflowx.AddParallelNode, not in this type.
|
||||
// The component exists for registry / factory / introspection only —
|
||||
// Invoke is a no-op that returns an empty map.
|
||||
type ParallelComponent struct {
|
||||
param ParallelParam
|
||||
}
|
||||
|
||||
// ParallelParam captures the (resolved) DSL parameters for a Parallel
|
||||
// node. Only `items_ref` and `max_concurrency` are meaningful for the
|
||||
// runtime path; the canvas layer (buildParallelExpansion) resolves the
|
||||
// array and passes it as input to workflowx.AddParallelNode.
|
||||
type ParallelParam struct {
|
||||
// ItemsRef is a variable reference (e.g. "sys.arr", "parallel_0@result")
|
||||
// pointing to the list to iterate over.
|
||||
ItemsRef string
|
||||
|
||||
// MaxConcurrency caps the number of per-item sub-workflow invocations
|
||||
// that run concurrently. 0 (default) means sequential execution.
|
||||
// Maps to workflowx.WithParallelMaxConcurrency in the macro expansion.
|
||||
MaxConcurrency int
|
||||
}
|
||||
|
||||
// Update copies conf into p. Used by the editor / API to hand-craft a
|
||||
// params map; type validation is intentionally minimal in P2.
|
||||
func (p *ParallelParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := stringFrom(conf, "items_ref"); ok {
|
||||
p.ItemsRef = v
|
||||
}
|
||||
if v, ok := intFrom(conf, "max_concurrency"); ok {
|
||||
p.MaxConcurrency = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check performs shallow validation.
|
||||
func (p *ParallelParam) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map for serialization / debug.
|
||||
func (p *ParallelParam) AsDict() map[string]any {
|
||||
out := map[string]any{}
|
||||
if p.ItemsRef != "" {
|
||||
out["items_ref"] = p.ItemsRef
|
||||
}
|
||||
if p.MaxConcurrency > 0 {
|
||||
out["max_concurrency"] = p.MaxConcurrency
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewParallelComponent builds a ParallelComponent from the supplied
|
||||
// param struct.
|
||||
func NewParallelComponent(p ParallelParam) *ParallelComponent {
|
||||
return &ParallelComponent{param: p}
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (c *ParallelComponent) Name() string { return componentNameParallel }
|
||||
|
||||
// Inputs returns parameter metadata for tooling.
|
||||
func (c *ParallelComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"cpn_id": "Stable component identifier — BuildWorkflow uses this to detect Parallel and apply the workflowx.AddParallelNode macro expansion.",
|
||||
"items_ref": "Variable reference to the list to iterate (e.g. \"sys.arr\").",
|
||||
"max_concurrency": "Maximum concurrent per-item sub-workflow invocations. 0 = sequential.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the Parallel's public outputs. In the new architecture,
|
||||
// the actual output is a []O slice produced by
|
||||
// workflowx.AddParallelNode, not by ParallelComponent.Invoke.
|
||||
// ParallelComponent itself emits no outputs; this map documents the
|
||||
// contract for downstream consumers reading the parallel node's result.
|
||||
func (c *ParallelComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"_result": "Output list ([]O) — order strictly corresponds to input list order.",
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke is a no-op marker. The real per-item work runs inside the
|
||||
// sub-graph (AddParallelNode fans out the sub-workflow to run once per
|
||||
// input item). ParallelComponent.Invoke is kept on the Component
|
||||
// interface for callers that construct a ParallelComponent directly
|
||||
// outside the canvas engine (e.g. unit tests that want to verify
|
||||
// registration); under the canvas engine, this method is never called.
|
||||
//
|
||||
// The returned map is empty. State writes from this method would be
|
||||
// silently dropped by the eino graph, because ParallelComponent is not
|
||||
// registered as an eino node when the macro expansion fires.
|
||||
func (c *ParallelComponent) Invoke(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke and emits an empty map as a single chunk.
|
||||
func (c *ParallelComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := c.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// init registers ParallelComponent with the orchestrator-owned registry.
|
||||
//
|
||||
// ParallelComponent.Invoke is a no-op; the runtime parallel driver lives
|
||||
// in workflowx.AddParallelNode and is installed by canvas.BuildWorkflow
|
||||
// when it sees a Parallel cpn in the DSL.
|
||||
//
|
||||
// The former IterationItem component is NOT registered — the per-item
|
||||
// execution formerly handled by that component is now subsumed by the
|
||||
// sub-graph body nodes inside AddParallelNode, and the fan-out
|
||||
// orchestration (counter, _done signalling, output collation) is handled
|
||||
// by the parallel extension.
|
||||
func init() {
|
||||
Register(componentNameParallel, func(params map[string]any) (Component, error) {
|
||||
var p ParallelParam
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewParallelComponent(p), nil
|
||||
})
|
||||
}
|
||||
175
internal/agent/component/parallel_test.go
Normal file
175
internal/agent/component/parallel_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// 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 component — Parallel unit tests.
|
||||
//
|
||||
// ParallelComponent is a no-op marker in the new architecture: real
|
||||
// parallel execution is driven by workflowx.AddParallelNode, installed
|
||||
// by canvas.BuildWorkflow when it sees a Parallel cpn in the DSL. The
|
||||
// tests in this file exercise the contract ParallelComponent DOES
|
||||
// expose — registry / factory / param parsing / Name / Inputs / Outputs /
|
||||
// no-op Invoke / no-op Stream.
|
||||
//
|
||||
// Note: "Iteration" and "IterationItem" are still in the registry —
|
||||
// they are the v1 fixture stubs (v1_stubs.go) that the e2e suite in
|
||||
// internal/agent/canvas/dsl_examples_e2e_test.go needs to compile the
|
||||
// v1 DSL examples (iteration.json, headhunter_zh.json). They are NOT
|
||||
// production components; the real parallel / iteration engine lives
|
||||
// in canvas/loop_subgraph.go and workflowx.AddLoopNode. The v1 stubs
|
||||
// are deliberately registered under the v1 names so the e2e path
|
||||
// resolves the factory to something non-panicking.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParallel_Registered confirms "Parallel" is in the registry.
|
||||
//
|
||||
// The v1 stub names "Iteration" and "IterationItem" are also in the
|
||||
// registry by design — see the package comment above. We do NOT
|
||||
// assert their absence here.
|
||||
func TestParallel_Registered(t *testing.T) {
|
||||
names := RegisteredNames()
|
||||
hasParallel := false
|
||||
for _, n := range names {
|
||||
if n == "parallel" {
|
||||
hasParallel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasParallel {
|
||||
t.Errorf("Parallel not registered; RegisteredNames=%v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallel_FactoryReturnsComponent confirms the factory registered
|
||||
// for "Parallel" produces a Component with the correct name.
|
||||
func TestParallel_FactoryReturnsComponent(t *testing.T) {
|
||||
c, err := New("Parallel", map[string]any{
|
||||
"items_ref": "sys.arr",
|
||||
"max_concurrency": 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(Parallel): %v", err)
|
||||
}
|
||||
if c.Name() != "Parallel" {
|
||||
t.Errorf("Name: got %q, want \"Parallel\"", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallel_InvokeIsNoOp confirms ParallelComponent.Invoke returns
|
||||
// an empty map and a nil error. State writes from this method are
|
||||
// silently dropped by the eino graph because ParallelComponent is not
|
||||
// registered as an eino node when the macro expansion fires.
|
||||
func TestParallel_InvokeIsNoOp(t *testing.T) {
|
||||
c := NewParallelComponent(ParallelParam{
|
||||
ItemsRef: "sys.arr",
|
||||
MaxConcurrency: 3,
|
||||
})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{"in": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("Invoke should return an empty map, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallel_StreamMirrorsInvoke confirms Stream yields exactly one
|
||||
// empty-map chunk and closes.
|
||||
func TestParallel_StreamMirrorsInvoke(t *testing.T) {
|
||||
c := NewParallelComponent(ParallelParam{})
|
||||
ch, err := c.Stream(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
got, ok := <-ch
|
||||
if !ok {
|
||||
t.Fatal("Stream channel closed without emitting")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("Stream chunk: got %v, want empty map", got)
|
||||
}
|
||||
if _, open := <-ch; open {
|
||||
t.Errorf("Stream channel did not close after one chunk")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallel_ParamUpdate covers the ParallelParam.Update contract
|
||||
// for the items_ref and max_concurrency fields. The canvas package's
|
||||
// buildParallelExpansion reads these from the raw params map directly,
|
||||
// but ParallelParam.Update is the canonical parser that the factory
|
||||
// uses; it must round-trip both supported fields.
|
||||
func TestParallel_ParamUpdate(t *testing.T) {
|
||||
var p ParallelParam
|
||||
if err := p.Update(map[string]any{
|
||||
"items_ref": "sys.arr",
|
||||
"max_concurrency": 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if p.ItemsRef != "sys.arr" {
|
||||
t.Errorf("ItemsRef: got %q, want \"sys.arr\"", p.ItemsRef)
|
||||
}
|
||||
if p.MaxConcurrency != 10 {
|
||||
t.Errorf("MaxConcurrency: got %d, want 10", p.MaxConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallel_ParamUpdateNilConf confirms Update(nil) is a no-op.
|
||||
func TestParallel_ParamUpdateNilConf(t *testing.T) {
|
||||
var p ParallelParam
|
||||
if err := p.Update(nil); err != nil {
|
||||
t.Fatalf("Update(nil): %v", err)
|
||||
}
|
||||
if p.ItemsRef != "" {
|
||||
t.Errorf("ItemsRef: got %q, want empty", p.ItemsRef)
|
||||
}
|
||||
if p.MaxConcurrency != 0 {
|
||||
t.Errorf("MaxConcurrency: got %d, want 0", p.MaxConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallel_ParamAsDict confirms AsDict round-trips the two
|
||||
// supported fields when set, and omits them when zero.
|
||||
func TestParallel_ParamAsDict(t *testing.T) {
|
||||
p := &ParallelParam{
|
||||
ItemsRef: "sys.arr",
|
||||
MaxConcurrency: 0,
|
||||
}
|
||||
d := p.AsDict()
|
||||
if v, _ := d["items_ref"].(string); v != "sys.arr" {
|
||||
t.Errorf("AsDict items_ref: got %v, want \"sys.arr\"", v)
|
||||
}
|
||||
if _, ok := d["max_concurrency"]; ok {
|
||||
t.Errorf("AsDict: max_concurrency=0 should be omitted")
|
||||
}
|
||||
|
||||
// Zero ParallelParam → empty AsDict.
|
||||
empty := (&ParallelParam{}).AsDict()
|
||||
if len(empty) != 0 {
|
||||
t.Errorf("AsDict zero: got %v, want empty", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallel_ParamCheckAlwaysTrue confirms Check is a no-op validator.
|
||||
func TestParallel_ParamCheckAlwaysTrue(t *testing.T) {
|
||||
if err := (&ParallelParam{}).Check(); err != nil {
|
||||
t.Errorf("Check: got %v, want nil", err)
|
||||
}
|
||||
}
|
||||
76
internal/agent/component/registry.go
Normal file
76
internal/agent/component/registry.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package component — registry (orchestrator-owned, DO NOT EDIT).
|
||||
//
|
||||
// Registry maps component names to factories. Each component's init() calls
|
||||
// Register(name, factory) to enroll itself; lookup is case-insensitive
|
||||
// (Python v1 case-insensitivity per dsl-v1-corner-cases.md §13).
|
||||
package component
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Factory constructs a Component from a params map (loaded from the DSL).
|
||||
// Returning an error here aborts the run with a clear message.
|
||||
type Factory func(params map[string]any) (Component, error)
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = make(map[string]Factory)
|
||||
)
|
||||
|
||||
// Register enrolls a component factory under name (case-insensitive).
|
||||
// Intended to be called from init() in each component's <name>.go file.
|
||||
func Register(name string, f Factory) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
panic("component: Register called with empty name")
|
||||
}
|
||||
if _, exists := registry[key]; exists {
|
||||
panic(fmt.Sprintf("component: %q already registered", name))
|
||||
}
|
||||
registry[key] = f
|
||||
}
|
||||
|
||||
// New constructs a Component by name. Returns an error if the name is
|
||||
// unknown or the factory rejects the params. The empty-string case is
|
||||
// treated as "not found" so the error message is consistent.
|
||||
func New(name string, params map[string]any) (Component, error) {
|
||||
registryMu.RLock()
|
||||
f, ok := registry[strings.ToLower(strings.TrimSpace(name))]
|
||||
registryMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("component: unknown component %q (registered: %s)", name, RegisteredNames())
|
||||
}
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("component: nil factory for %q", name)
|
||||
}
|
||||
return f(params)
|
||||
}
|
||||
|
||||
// RegisteredNames returns the sorted list of registered component names.
|
||||
// Used for diagnostics and the API 500 path "list available components".
|
||||
func RegisteredNames() []string {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
names := make([]string, 0, len(registry))
|
||||
for n := range registry {
|
||||
names = append(names, n)
|
||||
}
|
||||
// Stable order for error messages / UI listing.
|
||||
sortStrings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// sortStrings is a small in-place insertion sort to avoid the sort package
|
||||
// dependency for a list that's <50 items long in practice.
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j-1] > s[j]; j-- {
|
||||
s[j-1], s[j] = s[j], s[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
45
internal/agent/component/runtime_wire.go
Normal file
45
internal/agent/component/runtime_wire.go
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// 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_wire.go — installs the production ComponentFactory into the
|
||||
// shared runtime package at package init time.
|
||||
//
|
||||
// The canvas builder (internal/agent/canvas) consumes the factory via
|
||||
// runtime.DefaultFactory() so it can resolve real component bodies at
|
||||
// BuildWorkflow time without importing the component package. The
|
||||
// orchestrator (cmd/server_main, cmd/ragflow_cli, ...) blank-imports
|
||||
// internal/agent/component to trigger this init, which is the same
|
||||
// trigger that drives each component's Register(...) call.
|
||||
package component
|
||||
|
||||
import (
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Adapter: component.New returns (component.Component, error),
|
||||
// and component.Component satisfies runtime.Component
|
||||
// structurally (Invoke is the only method runtime.Component
|
||||
// declares). A typed return is required so the closure's
|
||||
// signature matches runtime.ComponentFactory.
|
||||
runtime.SetDefaultFactory(func(name string, params map[string]any) (runtime.Component, error) {
|
||||
c, err := New(name, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
})
|
||||
}
|
||||
305
internal/agent/component/string_transform.go
Normal file
305
internal/agent/component/string_transform.go
Normal file
@@ -0,0 +1,305 @@
|
||||
//
|
||||
// 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 component — StringTransform (T3, plan §2.11.3 row 18).
|
||||
//
|
||||
// StringTransform has two modes:
|
||||
//
|
||||
// split — break a string on one or more literal delimiters
|
||||
// merge — substitute {{name}} placeholders in a script with values
|
||||
// pulled from the inputs map or the canvas state
|
||||
//
|
||||
// Mirrors agent/component/string_transform.py. The P1 port supports the
|
||||
// common {{name}} placeholder shape only; the full Jinja2 surface
|
||||
// (`{% if %}`, `{% for %}`) is deferred to a later phase per the plan.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameStringTransform = "StringTransform"
|
||||
|
||||
// stringTransformParam is the static configuration.
|
||||
type stringTransformParam struct {
|
||||
Method string `json:"method"` // "split" or "merge"
|
||||
Script string `json:"script"` // merge mode: template
|
||||
SplitRef string `json:"split_ref"` // split mode: state ref to read
|
||||
Delimiters []string `json:"delimiters"` // split mode: literal delimiters
|
||||
}
|
||||
|
||||
// Update copies a fresh param map into the receiver.
|
||||
func (p *stringTransformParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
p.Method, _ = conf["method"].(string)
|
||||
if p.Method == "" {
|
||||
p.Method = "split"
|
||||
}
|
||||
p.Script, _ = conf["script"].(string)
|
||||
p.SplitRef, _ = conf["split_ref"].(string)
|
||||
|
||||
switch v := conf["delimiters"].(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
p.Delimiters = out
|
||||
case []string:
|
||||
// already correct shape
|
||||
p.Delimiters = append(p.Delimiters[:0], v...)
|
||||
case nil:
|
||||
// leave unchanged
|
||||
default:
|
||||
// unknown shape — treat as empty; Check() will reject
|
||||
p.Delimiters = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check validates the param.
|
||||
func (p *stringTransformParam) Check() error {
|
||||
switch p.Method {
|
||||
case "split", "merge":
|
||||
// ok
|
||||
default:
|
||||
return &ParamError{Field: "method", Reason: "must be one of: split, merge"}
|
||||
}
|
||||
if len(p.Delimiters) == 0 {
|
||||
return &ParamError{Field: "delimiters", Reason: "must not be empty"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map.
|
||||
func (p *stringTransformParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"method": p.Method,
|
||||
"script": p.Script,
|
||||
"split_ref": p.SplitRef,
|
||||
"delimiters": p.Delimiters,
|
||||
}
|
||||
}
|
||||
|
||||
// placeholderPattern matches {{name}} where name is an identifier-like
|
||||
// sequence. Intentionally narrower than the canvas var-ref pattern
|
||||
// (which also handles sys.x / env.x) because merge placeholders are
|
||||
// looked up in the inputs map and/or canvas state by simple key, not
|
||||
// the full cpn_id@param / sys.x / env.x grammar.
|
||||
var placeholderPattern = regexp.MustCompile(`\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`)
|
||||
|
||||
// StringTransformComponent implements the split/merge component.
|
||||
type StringTransformComponent struct {
|
||||
name string
|
||||
param stringTransformParam
|
||||
}
|
||||
|
||||
// NewStringTransformComponent constructs a StringTransform from the
|
||||
// DSL param map.
|
||||
func NewStringTransformComponent(params map[string]any) (Component, error) {
|
||||
p := &stringTransformParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("StringTransform: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("StringTransform: param check: %w", err)
|
||||
}
|
||||
return &StringTransformComponent{
|
||||
name: componentNameStringTransform,
|
||||
param: *p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (s *StringTransformComponent) Name() string { return s.name }
|
||||
|
||||
// Invoke runs the configured method (split or merge) and returns
|
||||
// outputs["result"] with the transformed payload.
|
||||
func (s *StringTransformComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StringTransform: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("StringTransform: nil canvas state")
|
||||
}
|
||||
|
||||
if s.param.Method == "split" {
|
||||
return s.doSplit(ctx, state, inputs)
|
||||
}
|
||||
return s.doMerge(ctx, state, inputs), nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; StringTransform is a single-shot transform.
|
||||
func (s *StringTransformComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := s.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the parameter surface. The shape depends on the
|
||||
// configured method.
|
||||
func (s *StringTransformComponent) Inputs() map[string]string {
|
||||
if s.param.Method == "split" {
|
||||
return map[string]string{
|
||||
"line": "Optional direct string to split; if absent, the component reads state[split_ref].",
|
||||
}
|
||||
}
|
||||
// merge: placeholders derived from the script
|
||||
names := extractPlaceholders(s.param.Script)
|
||||
out := make(map[string]string, len(names))
|
||||
for _, n := range names {
|
||||
out[n] = "Value to substitute for {{" + n + "}} (drawn from inputs or state)."
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Outputs returns the transformed payload.
|
||||
func (s *StringTransformComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"result": "Split: a []string of kept tokens. Merge: a single string with placeholders resolved.",
|
||||
}
|
||||
}
|
||||
|
||||
// doSplit runs the split method. Mirrors the Python _split helper
|
||||
// (string_transform.py:76-91): build a regex of the literal
|
||||
// delimiters, split with capture groups, keep the even-indexed
|
||||
// (non-delimiter) tokens.
|
||||
func (s *StringTransformComponent) doSplit(_ context.Context, state *runtime.CanvasState, inputs map[string]any) (map[string]any, error) {
|
||||
var varValue string
|
||||
if line, ok := inputs["line"].(string); ok && line != "" {
|
||||
varValue = line
|
||||
} else if s.param.SplitRef != "" {
|
||||
v, err := state.GetVar(s.param.SplitRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StringTransform: split_ref %q: %w", s.param.SplitRef, err)
|
||||
}
|
||||
if v == nil {
|
||||
varValue = ""
|
||||
} else if s, ok := v.(string); ok {
|
||||
varValue = s
|
||||
} else {
|
||||
return nil, fmt.Errorf("StringTransform: split input is not a string: %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the regex: |.join([regexp.QuoteMeta(d) for d in delimiters])
|
||||
parts := make([]string, 0, len(s.param.Delimiters))
|
||||
for _, d := range s.param.Delimiters {
|
||||
parts = append(parts, regexp.QuoteMeta(d))
|
||||
}
|
||||
pattern := "(?s)(" + strings.Join(parts, "|") + ")"
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StringTransform: bad delimiter pattern: %w", err)
|
||||
}
|
||||
matches := re.FindAllStringIndex(varValue, -1)
|
||||
|
||||
// Walk the input string, collecting the content between delimiter
|
||||
// matches. This mirrors Python's re.split with a capture group
|
||||
// (which interleaves content and delimiter tokens) followed by
|
||||
// dropping the odd-indexed (delimiter) tokens. When there are no
|
||||
// matches, the whole input is a single content token.
|
||||
kept := make([]string, 0, len(matches)+1)
|
||||
prevEnd := 0
|
||||
for _, m := range matches {
|
||||
kept = append(kept, varValue[prevEnd:m[0]])
|
||||
prevEnd = m[1]
|
||||
}
|
||||
kept = append(kept, varValue[prevEnd:])
|
||||
return map[string]any{"result": kept}, nil
|
||||
}
|
||||
|
||||
// doMerge runs the merge method. Mirrors the Python _merge helper
|
||||
// (string_transform.py:93-112): collect {{name}} placeholders, resolve
|
||||
// each from inputs (preferred) or canvas state, substitute, and emit
|
||||
// the resolved script.
|
||||
func (s *StringTransformComponent) doMerge(_ context.Context, state *runtime.CanvasState, inputs map[string]any) map[string]any {
|
||||
script := s.param.Script
|
||||
|
||||
// First pass: state-level template resolution for any {{ref}} that
|
||||
// is a valid cpn_id@param / sys.x / env.x reference. The Python
|
||||
// _is_jinjia2 + template.render path is more general; for P1 we
|
||||
// only support the simple state-resolvable form.
|
||||
if strings.Contains(script, "{{") {
|
||||
if resolved, err := runtime.ResolveTemplate(script, state); err == nil {
|
||||
script = resolved
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: {{name}} placeholders → values from inputs, then state.
|
||||
names := extractPlaceholders(script)
|
||||
if len(names) == 0 {
|
||||
return map[string]any{"result": script}
|
||||
}
|
||||
for _, n := range names {
|
||||
placeholder := "{{" + n + "}}"
|
||||
var value any
|
||||
if v, ok := inputs[n]; ok {
|
||||
value = v
|
||||
} else if v, err := state.GetVar(n); err == nil && v != nil {
|
||||
value = v
|
||||
} else {
|
||||
value = ""
|
||||
}
|
||||
script = strings.ReplaceAll(script, placeholder, fmt.Sprintf("%v", value))
|
||||
}
|
||||
return map[string]any{"result": script}
|
||||
}
|
||||
|
||||
// extractPlaceholders returns the unique placeholder names appearing
|
||||
// in s, in first-occurrence order.
|
||||
func extractPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
matches := placeholderPattern.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 {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
name := m[1]
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameStringTransform, NewStringTransformComponent)
|
||||
}
|
||||
155
internal/agent/component/string_transform_test.go
Normal file
155
internal/agent/component/string_transform_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestStringTransform_SplitBasic: "a,b;c" with delimiters=[",", ";"] → ["a", "b", "c"].
|
||||
func TestStringTransform_SplitBasic(t *testing.T) {
|
||||
c, err := NewStringTransformComponent(map[string]any{
|
||||
"method": "split",
|
||||
"delimiters": []string{",", ";"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStringTransformComponent: %v", err)
|
||||
}
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{"line": "a,b;c"})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]string)
|
||||
want := []string{"a", "b", "c"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("split: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringTransform_SplitNoDelim: "abc" with delimiters=[","] → ["abc"].
|
||||
func TestStringTransform_SplitNoDelim(t *testing.T) {
|
||||
c, _ := NewStringTransformComponent(map[string]any{
|
||||
"method": "split",
|
||||
"delimiters": []string{","},
|
||||
})
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{"line": "abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]string)
|
||||
want := []string{"abc"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("split: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringTransform_Merge: script="{{x}} and {{y}}", inputs={x: "foo", y: "bar"} → "foo and bar".
|
||||
func TestStringTransform_Merge(t *testing.T) {
|
||||
c, _ := NewStringTransformComponent(map[string]any{
|
||||
"method": "merge",
|
||||
"delimiters": []string{","},
|
||||
"script": "{{x}} and {{y}}",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{"x": "foo", "y": "bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["result"], "foo and bar"; got != want {
|
||||
t.Errorf("merge: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringTransform_SplitFromStateRef: when "line" is absent, the
|
||||
// component reads the value from state[split_ref].
|
||||
func TestStringTransform_SplitFromStateRef(t *testing.T) {
|
||||
c, _ := NewStringTransformComponent(map[string]any{
|
||||
"method": "split",
|
||||
"delimiters": []string{","},
|
||||
"split_ref": "cpn_0@x",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
state.Outputs["cpn_0"] = map[string]any{"x": "alpha,beta,gamma"}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, _ := out["result"].([]string)
|
||||
want := []string{"alpha", "beta", "gamma"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("split from state: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringTransform_MergeMissingPlaceholder: a placeholder not in
|
||||
// inputs or state resolves to "" (matches Python's v is None branch).
|
||||
func TestStringTransform_MergeMissingPlaceholder(t *testing.T) {
|
||||
c, _ := NewStringTransformComponent(map[string]any{
|
||||
"method": "merge",
|
||||
"delimiters": []string{","},
|
||||
"script": "hello {{name}}",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-5", "task-5")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["result"], "hello "; got != want {
|
||||
t.Errorf("merge missing: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringTransform_ParamCheck: bad method rejected.
|
||||
func TestStringTransform_ParamCheck(t *testing.T) {
|
||||
_, err := NewStringTransformComponent(map[string]any{
|
||||
"method": "bogus",
|
||||
"delimiters": []string{","},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad method, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringTransform_Registered: factory lookup.
|
||||
func TestStringTransform_Registered(t *testing.T) {
|
||||
c, err := New("StringTransform", map[string]any{
|
||||
"method": "split",
|
||||
"delimiters": []string{","},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if c.Name() != "StringTransform" {
|
||||
t.Errorf("Name()=%q, want StringTransform", c.Name())
|
||||
}
|
||||
}
|
||||
305
internal/agent/component/switch.go
Normal file
305
internal/agent/component/switch.go
Normal file
@@ -0,0 +1,305 @@
|
||||
//
|
||||
// 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 component — Switch component (T2, plan §2.11.3 row 7).
|
||||
//
|
||||
// Switch is a multi-condition router implemented in pure Go (no eino
|
||||
// Lambda dependency). It walks a list of AND/OR-combined condition
|
||||
// groups against the current *CanvasState, picks the first matching
|
||||
// group's downstream cpn_id, and returns it as outputs["_next"]. The
|
||||
// downstream cpn_id for a matching group is taken from the optional
|
||||
// "to" field; if absent, the P0 fallback is the index-based
|
||||
// "matched_<i>" naming used in the spec's example assertions.
|
||||
//
|
||||
// Mirrors the Python agent/component/switch.py behavior. The Phase 5
|
||||
// eino `compose.NewGraphMultiBranch` integration (per plan §2.11.6
|
||||
// entry for Switch) is a thinner pass-through: the hard routing
|
||||
// decision lives here, MultiBranch just wires the edges.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameSwitch = "Switch"
|
||||
|
||||
// SwitchComponent implements the Switch routing node. It is stateless
|
||||
// across invocations: the inputs map carries everything it needs.
|
||||
type SwitchComponent struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// NewSwitchComponent constructs a Switch component. params is unused
|
||||
// in P0 (Switch config lives in the inputs map at Invoke time).
|
||||
func NewSwitchComponent(_ map[string]any) (Component, error) {
|
||||
return &SwitchComponent{name: componentNameSwitch}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (s *SwitchComponent) Name() string { return s.name }
|
||||
|
||||
// Invoke evaluates the conditions list in order, returns the first
|
||||
// matching group's downstream cpn_id at outputs["_next"]. If no group
|
||||
// matches, outputs["_next"] = inputs["default"] (a free-form string —
|
||||
// Phase 5 will resolve it to a real cpn_id via the eino multi-branch
|
||||
// wiring). Unknown / empty inputs are tolerated: an absent "conditions"
|
||||
// list yields outputs["_next"] = inputs["default"].
|
||||
func (s *SwitchComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Switch: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("Switch: nil canvas state")
|
||||
}
|
||||
|
||||
defaultNext, _ := inputs["default"].(string)
|
||||
if raw, ok := inputs["conditions"].([]any); ok {
|
||||
for i, item := range raw {
|
||||
group, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matched, evalErr := evaluateGroup(group, state)
|
||||
if evalErr != nil {
|
||||
return nil, fmt.Errorf("Switch: condition[%d]: %w", i, evalErr)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
next, hasTo := group["to"].(string)
|
||||
if !hasTo || next == "" {
|
||||
next = "matched_" + strconv.Itoa(i)
|
||||
}
|
||||
return map[string]any{"_next": next}, nil
|
||||
}
|
||||
}
|
||||
return map[string]any{"_next": defaultNext}, nil
|
||||
}
|
||||
|
||||
// Stream is a synchronous facade over Invoke for P0. Switch is a
|
||||
// routing decision, not a stream of partial results; the channel
|
||||
// receives one payload and closes.
|
||||
func (s *SwitchComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := s.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the public parameter surface.
|
||||
func (s *SwitchComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"conditions": "Ordered list of condition groups; each is {op: \"and\"|\"or\", to?: cpn_id, clauses: [{left, op, right?}]}.",
|
||||
"default": "Downstream cpn_id used when no condition matches.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the chosen cpn_id.
|
||||
func (s *SwitchComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"_next": "The cpn_id of the downstream node to route to.",
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateGroup applies the group's op (and/or) to its clauses and
|
||||
// returns true if the group matches. It is the lock-free inner of
|
||||
// Switch.Invoke; caller must not hold state.mu.
|
||||
func evaluateGroup(group map[string]any, state *runtime.CanvasState) (bool, error) {
|
||||
op, _ := group["op"].(string)
|
||||
clauses, _ := group["clauses"].([]any)
|
||||
if op == "" {
|
||||
op = "and"
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
// An empty group is vacuously true (matches).
|
||||
return true, nil
|
||||
}
|
||||
for i, raw := range clauses {
|
||||
c, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("clause[%d] not a map", i)
|
||||
}
|
||||
matched, err := evaluateClause(c, state)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("clause[%d]: %w", i, err)
|
||||
}
|
||||
if op == "or" && matched {
|
||||
return true, nil
|
||||
}
|
||||
if op == "and" && !matched {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
// For "and" with no early false: matched. For "or" with no early true: not matched.
|
||||
return op == "and", nil
|
||||
}
|
||||
|
||||
// evaluateClause resolves a single clause. left is a {{...}} reference
|
||||
// (passed through runtime.ResolveTemplate); op is one of
|
||||
// "==", "!=", ">", "<", "contains", "empty". The "empty" operator
|
||||
// ignores right.
|
||||
func evaluateClause(clause map[string]any, state *runtime.CanvasState) (bool, error) {
|
||||
left, _ := clause["left"].(string)
|
||||
op, _ := clause["op"].(string)
|
||||
if op == "" {
|
||||
op = "=="
|
||||
}
|
||||
|
||||
// "empty" is the only operator that does not read `right`.
|
||||
if op == "empty" {
|
||||
return isEmptyValue(leftValue(left, state)), nil
|
||||
}
|
||||
|
||||
right := clause["right"]
|
||||
lv := leftValue(left, state)
|
||||
|
||||
switch op {
|
||||
case "==":
|
||||
return equalValues(lv, right), nil
|
||||
case "!=":
|
||||
return !equalValues(lv, right), nil
|
||||
case "contains":
|
||||
// Treat the right-hand side as a string needle.
|
||||
ls, rs := fmt.Sprintf("%v", lv), fmt.Sprintf("%v", right)
|
||||
return containsString(ls, rs), nil
|
||||
case ">", "<":
|
||||
ln, lok := numericize(lv)
|
||||
rn, rok := numericize(right)
|
||||
if !lok || !rok {
|
||||
return false, fmt.Errorf("operator %q requires numeric operands (left=%T, right=%T)", op, lv, right)
|
||||
}
|
||||
if op == ">" {
|
||||
return ln > rn, nil
|
||||
}
|
||||
return ln < rn, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
|
||||
// leftValue resolves a {{...}} reference against state. References
|
||||
// without braces are returned as a literal (matches ResolveTemplate's
|
||||
// pre-check behavior).
|
||||
func leftValue(left string, state *runtime.CanvasState) any {
|
||||
if left == "" {
|
||||
return ""
|
||||
}
|
||||
// If the caller supplied an un-resolved literal (no {{...}}), pass
|
||||
// it through unchanged so operators like "==" can compare against
|
||||
// the raw value (e.g. left="raw string" → returned as "raw string").
|
||||
if !runtime.VarRefPattern.MatchString(left) {
|
||||
return left
|
||||
}
|
||||
resolved, err := runtime.ResolveTemplate(left, state)
|
||||
if err != nil {
|
||||
// On resolution failure, return the raw string so == can still
|
||||
// operate; we don't want a misconfigured ref to crash the run.
|
||||
return left
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// equalValues compares two any values with a forgiving type coercion
|
||||
// (string ↔ fmt-rendered, int ↔ float64). Returns false on type
|
||||
// mismatches that don't coerce cleanly.
|
||||
func equalValues(a, b any) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
// Stringify then compare — covers most canvas-DSL comparisons.
|
||||
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||
}
|
||||
|
||||
func containsString(haystack, needle string) bool {
|
||||
if needle == "" {
|
||||
return true
|
||||
}
|
||||
return indexOf(haystack, needle) >= 0
|
||||
}
|
||||
|
||||
// indexOf is a tiny wrapper around strings.Index to keep the operator
|
||||
// table readable. strings import is hidden behind this helper to
|
||||
// minimize the import list surface.
|
||||
func indexOf(s, sub string) int {
|
||||
// use stdlib to avoid hand-rolling
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// numericize attempts to convert v to float64. Returns ok=false if v
|
||||
// is a string that doesn't parse as a number (e.g. an LLM response);
|
||||
// numeric operators will then error out with a clear message.
|
||||
func numericize(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(x, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// isEmptyValue reports whether a value is "empty" by the canvas DSL
|
||||
// definition: nil, empty string, empty slice, empty map.
|
||||
func isEmptyValue(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return x == ""
|
||||
case []any:
|
||||
return len(x) == 0
|
||||
case map[string]any:
|
||||
return len(x) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mapsCopyDup is a no-op duplicate alias kept for symmetry with the
|
||||
// begin.go / message.go helpers in the package; here Switch doesn't
|
||||
// need to copy maps but the alias documents the convention.
|
||||
var _ = mapsCopyDup
|
||||
|
||||
func mapsCopyDup(dst, src map[string]any) { maps.Copy(dst, src) }
|
||||
|
||||
func init() {
|
||||
Register(componentNameSwitch, NewSwitchComponent)
|
||||
}
|
||||
154
internal/agent/component/switch_test.go
Normal file
154
internal/agent/component/switch_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestSwitch_AndMatches: a single-condition AND group with a sys
|
||||
// reference that matches the state must route to "matched_0" (the
|
||||
// fallback name when no explicit "to" is provided).
|
||||
func TestSwitch_AndMatches(t *testing.T) {
|
||||
s, _ := NewSwitchComponent(nil)
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
state.Sys["x"] = "yes"
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
inputs := map[string]any{
|
||||
"conditions": []any{
|
||||
map[string]any{
|
||||
"op": "and",
|
||||
"clauses": []any{
|
||||
map[string]any{"left": "{{sys.x}}", "op": "==", "right": "yes"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"default": "fallback",
|
||||
}
|
||||
out, err := s.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["_next"].(string); got != "matched_0" {
|
||||
t.Errorf("_next: got %q, want %q", got, "matched_0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitch_OrMatches: an OR group with one false clause and one
|
||||
// true clause must match. Also covers explicit "to" routing and
|
||||
// multi-clause groups.
|
||||
func TestSwitch_OrMatches(t *testing.T) {
|
||||
s, _ := NewSwitchComponent(nil)
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
state.Sys["score"] = "85"
|
||||
state.Sys["flag"] = "no"
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
inputs := map[string]any{
|
||||
"conditions": []any{
|
||||
// First group: AND of two clauses, both must be true.
|
||||
// score != "85" is false → AND fails → no match.
|
||||
map[string]any{
|
||||
"op": "and",
|
||||
"clauses": []any{
|
||||
map[string]any{"left": "{{sys.score}}", "op": "==", "right": "100"},
|
||||
map[string]any{"left": "{{sys.flag}}", "op": "==", "right": "yes"},
|
||||
},
|
||||
},
|
||||
// Second group: OR; first clause is false but second
|
||||
// is true → match → route to "match_route".
|
||||
map[string]any{
|
||||
"op": "or",
|
||||
"to": "match_route",
|
||||
"clauses": []any{
|
||||
map[string]any{"left": "{{sys.flag}}", "op": "==", "right": "yes"},
|
||||
map[string]any{"left": "{{sys.score}}", "op": "==", "right": "85"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"default": "fallback",
|
||||
}
|
||||
out, err := s.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["_next"].(string); got != "match_route" {
|
||||
t.Errorf("_next: got %q, want %q", got, "match_route")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitch_DefaultFallback: when no condition matches, the default
|
||||
// cpn_id from inputs["default"] is returned.
|
||||
func TestSwitch_DefaultFallback(t *testing.T) {
|
||||
s, _ := NewSwitchComponent(nil)
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
state.Sys["x"] = "no"
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
inputs := map[string]any{
|
||||
"conditions": []any{
|
||||
map[string]any{
|
||||
"op": "and",
|
||||
"clauses": []any{
|
||||
map[string]any{"left": "{{sys.x}}", "op": "==", "right": "yes"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"default": "fallback_0",
|
||||
}
|
||||
out, err := s.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["_next"].(string); got != "fallback_0" {
|
||||
t.Errorf("_next: got %q, want %q", got, "fallback_0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitch_ContainsAndEmpty covers two operators that are easy to
|
||||
// regress: "contains" (substring match) and "empty" (nil/empty test).
|
||||
func TestSwitch_ContainsAndEmpty(t *testing.T) {
|
||||
s, _ := NewSwitchComponent(nil)
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
state.Sys["body"] = "hello world"
|
||||
state.Sys["opt"] = ""
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
inputs := map[string]any{
|
||||
"conditions": []any{
|
||||
map[string]any{
|
||||
"op": "and",
|
||||
"clauses": []any{
|
||||
map[string]any{"left": "{{sys.body}}", "op": "contains", "right": "world"},
|
||||
map[string]any{"left": "{{sys.opt}}", "op": "empty"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"default": "x",
|
||||
}
|
||||
out, err := s.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["_next"].(string); got != "matched_0" {
|
||||
t.Errorf("_next: got %q, want %q", got, "matched_0")
|
||||
}
|
||||
}
|
||||
292
internal/agent/component/userfillup.go
Normal file
292
internal/agent/component/userfillup.go
Normal file
@@ -0,0 +1,292 @@
|
||||
//
|
||||
// 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 component — UserFillUp component (T3, plan §2.11.3 row 2).
|
||||
//
|
||||
// UserFillUp is the user-interaction / form-filling node. It renders an
|
||||
// optional `tips` template (with `{{key}}` placeholders resolved against
|
||||
// the form's input map) and passes each form field through to its
|
||||
// downstream outputs. File-type inputs (value.type starts with "file")
|
||||
// are deferred — Phase 5 will wire FileService.get_files; the P3 port
|
||||
// emits a stable "<file:key>" stub so the run keeps flowing.
|
||||
//
|
||||
// Mirrors agent/component/fillup.py:24-83.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameUserFillUp = "UserFillUp"
|
||||
|
||||
// defaultUserFillUpTips is used when the operator omits the `tips` param.
|
||||
// Matches the Python default in fillup.py:28.
|
||||
const defaultUserFillUpTips = "Please fill up the form"
|
||||
|
||||
// fileStubPrefix is prepended to the form-field key when an input is
|
||||
// classified as a file-type input. Phase 5 will replace this with the
|
||||
// actual FileService.get_files payload.
|
||||
const fileStubPrefix = "<file:"
|
||||
|
||||
// tipsPlaceholderPattern matches `{{key}}` placeholders in the tips
|
||||
// template. The pattern intentionally only accepts simple identifiers
|
||||
// (matching Python's `re.sub(r"\{%s\}"%k, ...)` in fillup.py:62) — the
|
||||
// placeholder key is looked up in the form's input map, not the full
|
||||
// canvas-state ref grammar (cpn_id@param / sys.x / env.x).
|
||||
var tipsPlaceholderPattern = regexp.MustCompile(`\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`)
|
||||
|
||||
// userFillUpParam is the per-instance configuration for UserFillUp.
|
||||
//
|
||||
// Mirrors UserFillUpParam in fillup.py:24-33 (Python).
|
||||
type userFillUpParam struct {
|
||||
EnableTips bool `json:"enable_tips"`
|
||||
Tips string `json:"tips"`
|
||||
LayoutRecognize string `json:"layout_recognize"`
|
||||
}
|
||||
|
||||
// Update copies a fresh params map into the receiver, applying defaults
|
||||
// for any omitted keys. Returns nil on success (param validation is
|
||||
// performed by Check, not here — mirrors Python's two-phase pattern).
|
||||
func (p *userFillUpParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
conf = map[string]any{}
|
||||
}
|
||||
if v, ok := boolFrom(conf, "enable_tips"); ok {
|
||||
p.EnableTips = v
|
||||
} else {
|
||||
// Default to true when the DSL omits the key entirely.
|
||||
p.EnableTips = true
|
||||
}
|
||||
if v, ok := stringFrom(conf, "tips"); ok {
|
||||
p.Tips = v
|
||||
} else if p.Tips == "" {
|
||||
p.Tips = defaultUserFillUpTips
|
||||
}
|
||||
if v, ok := stringFrom(conf, "layout_recognize"); ok {
|
||||
p.LayoutRecognize = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check performs parameter validation. UserFillUp has no required
|
||||
// fields — both the Python and Go implementations accept any config and
|
||||
// degrade gracefully on missing template data. The method is kept to
|
||||
// satisfy the ParamBase contract.
|
||||
func (p *userFillUpParam) Check() error { return nil }
|
||||
|
||||
// AsDict returns the param as a plain map for serialization / debug.
|
||||
func (p *userFillUpParam) AsDict() map[string]any {
|
||||
return map[string]any{
|
||||
"enable_tips": p.EnableTips,
|
||||
"tips": p.Tips,
|
||||
"layout_recognize": p.LayoutRecognize,
|
||||
}
|
||||
}
|
||||
|
||||
// UserFillUpComponent is the canvas form-filling node.
|
||||
type UserFillUpComponent struct {
|
||||
name string
|
||||
param userFillUpParam
|
||||
}
|
||||
|
||||
// NewUserFillUpComponent builds a UserFillUpComponent from a DSL params
|
||||
// map. The map is shallow-copied into the embedded param; pass-through
|
||||
// is keyed off the param's exported fields, not the original conf.
|
||||
func NewUserFillUpComponent(p userFillUpParam) *UserFillUpComponent {
|
||||
return &UserFillUpComponent{name: componentNameUserFillUp, param: p}
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (u *UserFillUpComponent) Name() string { return u.name }
|
||||
|
||||
// Invoke renders the tips template (when enable_tips) and emits one
|
||||
// output per form field. Inputs are expected under the top-level
|
||||
// "inputs" key, mirroring the Python `kwargs.get("inputs", {})`
|
||||
// contract in fillup.py:66.
|
||||
func (u *UserFillUpComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
// State is required for the canvas ref grammar, but UserFillUp's
|
||||
// tips substitution uses simple {{key}} placeholders resolved
|
||||
// against the form input map. We still extract state for future
|
||||
// Phase 5 use and to fail loudly if the engine forgot to wire it.
|
||||
if _, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err != nil {
|
||||
return nil, fmt.Errorf("UserFillUp: %w", err)
|
||||
}
|
||||
|
||||
fields, _ := formFields(inputs)
|
||||
out := make(map[string]any, len(fields)+1)
|
||||
|
||||
if u.param.EnableTips {
|
||||
rendered := renderTips(u.param.Tips, fields)
|
||||
out["tips"] = rendered
|
||||
}
|
||||
|
||||
for k, v := range fields {
|
||||
out[k] = resolveFieldValue(k, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stream is the synchronous facade over Invoke: a single payload, then
|
||||
// close. SSE streaming of the rendered tips is not meaningful for the
|
||||
// P3 port (form-fill is a one-shot interaction in the DSL).
|
||||
func (u *UserFillUpComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := u.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns parameter metadata for tooling. The "inputs" key is
|
||||
// the form-field map; the rest are per-instance config.
|
||||
func (u *UserFillUpComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"inputs": "Map of form-field name → {value, type, optional?}.",
|
||||
"enable_tips": "Render the `tips` template (default true).",
|
||||
"tips": "Template string with {{key}} placeholders, resolved against the form fields.",
|
||||
"layout_recognize": "Layout recognizer hint used for file inputs (deferred to Phase 5).",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the rendered tips plus one entry per form field.
|
||||
func (u *UserFillUpComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"tips": "Rendered tips string (only when enable_tips=true).",
|
||||
"*": "One output per form-field key in inputs.",
|
||||
}
|
||||
}
|
||||
|
||||
// formFields extracts the per-field map from the component's input
|
||||
// payload. Returns an empty map (not an error) when the key is absent
|
||||
// or malformed — mirrors the Python `kwargs.get("inputs", {})` shape.
|
||||
func formFields(inputs map[string]any) (map[string]any, bool) {
|
||||
raw, ok := inputs["inputs"]
|
||||
if !ok {
|
||||
return map[string]any{}, false
|
||||
}
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return map[string]any{}, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// renderTips substitutes every {{key}} placeholder in template with
|
||||
// the corresponding field's value. File-type fields render as
|
||||
// "<file:key>" stubs — FileService integration is Phase 5. Plain
|
||||
// string fields use their value verbatim; non-string values are
|
||||
// coerced via fmt.Sprintf("%v", ...).
|
||||
func renderTips(template string, fields map[string]any) string {
|
||||
if template == "" {
|
||||
return ""
|
||||
}
|
||||
return tipsPlaceholderPattern.ReplaceAllStringFunc(template, func(match string) string {
|
||||
sub := tipsPlaceholderPattern.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
key := sub[1]
|
||||
raw, ok := fields[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fieldValueToString(key, raw)
|
||||
})
|
||||
}
|
||||
|
||||
// resolveFieldValue converts one form-field payload into the value
|
||||
// that should appear in the component's output map.
|
||||
//
|
||||
// Rules (mirroring fillup.py:69-79):
|
||||
// - dict with type starting with "file" → "<file:key>" stub
|
||||
// - dict with optional=true and value==nil → nil
|
||||
// - dict with a `value` field → the inner value
|
||||
// - anything else → pass through unchanged
|
||||
func resolveFieldValue(key string, raw any) any {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return raw
|
||||
}
|
||||
if isFileType(m) {
|
||||
return fileStubPrefix + key + ">"
|
||||
}
|
||||
if opt, _ := m["optional"].(bool); opt {
|
||||
if v, present := m["value"]; !present || v == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if v, present := m["value"]; present {
|
||||
return v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// isFileType reports whether the form-field payload's `type` field
|
||||
// starts with "file" (case-insensitive). Matches fillup.py:69's
|
||||
// `v.get("type", "").lower().find("file") >= 0` test.
|
||||
func isFileType(m map[string]any) bool {
|
||||
t, _ := m["type"].(string)
|
||||
return strings.HasPrefix(strings.ToLower(t), "file")
|
||||
}
|
||||
|
||||
// fieldValueToString is the tips-substitution variant of
|
||||
// resolveFieldValue: it returns a string suitable for direct insertion
|
||||
// into the rendered template. File stubs are emitted here too so the
|
||||
// tips template can reference a file field without crashing.
|
||||
func fieldValueToString(key string, raw any) string {
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
if isFileType(m) {
|
||||
return fileStubPrefix + key + ">"
|
||||
}
|
||||
if v, present := m["value"]; present {
|
||||
return stringifyField(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return stringifyField(raw)
|
||||
}
|
||||
|
||||
// stringifyField renders a single form-field value as a string for
|
||||
// template substitution. nil → "", strings stay verbatim, everything
|
||||
// else uses %v.
|
||||
func stringifyField(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// init registers UserFillUp with the orchestrator-owned registry.
|
||||
func init() {
|
||||
Register(componentNameUserFillUp, func(params map[string]any) (Component, error) {
|
||||
var p userFillUpParam
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewUserFillUpComponent(p), nil
|
||||
})
|
||||
}
|
||||
162
internal/agent/component/userfillup_test.go
Normal file
162
internal/agent/component/userfillup_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestUserFillUp_RendersTips verifies the canonical happy path: a
|
||||
// single {{name}} placeholder in the tips template is resolved against
|
||||
// the inputs[name].value entry. The "tips" output key must hold the
|
||||
// rendered string.
|
||||
func TestUserFillUp_RendersTips(t *testing.T) {
|
||||
c, _ := New(componentNameUserFillUp, map[string]any{
|
||||
"enable_tips": true,
|
||||
"tips": "Hello {{name}}",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"name": map[string]any{"value": "World"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["tips"].(string); got != "Hello World" {
|
||||
t.Errorf("tips: got %q, want %q", got, "Hello World")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserFillUp_DisableTips asserts that turning tips off removes the
|
||||
// "tips" key entirely from the output. The form-field passthrough still
|
||||
// runs.
|
||||
func TestUserFillUp_DisableTips(t *testing.T) {
|
||||
c, _ := New(componentNameUserFillUp, map[string]any{
|
||||
"enable_tips": false,
|
||||
"tips": "Should not render",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"name": map[string]any{"value": "World"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if _, ok := out["tips"]; ok {
|
||||
t.Errorf("tips key must be absent when enable_tips=false, got %v", out["tips"])
|
||||
}
|
||||
// Passthrough still applies.
|
||||
if got, _ := out["name"].(string); got != "World" {
|
||||
t.Errorf("name passthrough: got %q, want %q", got, "World")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserFillUp_PassesThroughInputs asserts the multi-field
|
||||
// passthrough contract: every non-file field appears in the output
|
||||
// with its inner `value` extracted. This is the contract the downstream
|
||||
// LLM/Retrieval nodes rely on to consume the form's answers.
|
||||
func TestUserFillUp_PassesThroughInputs(t *testing.T) {
|
||||
c, _ := New(componentNameUserFillUp, map[string]any{"enable_tips": false})
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"q": map[string]any{"value": "What is RAGFlow?"},
|
||||
"top_k": map[string]any{"value": 5},
|
||||
"deep": map[string]any{"value": true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["q"].(string); got != "What is RAGFlow?" {
|
||||
t.Errorf("q: got %q, want %q", got, "What is RAGFlow?")
|
||||
}
|
||||
if got, _ := out["top_k"].(int); got != 5 {
|
||||
t.Errorf("top_k: got %v, want 5", out["top_k"])
|
||||
}
|
||||
if got, _ := out["deep"].(bool); !got {
|
||||
t.Errorf("deep: got %v, want true", out["deep"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserFillUp_FileInputStub locks down the Phase 5 deferral: a
|
||||
// file-typed input must be replaced by the "<file:key>" stub in both
|
||||
// the per-field output and any reference inside the tips template.
|
||||
// This is the contract the P3 orchestrator depends on for the hard
|
||||
// gate (no FileService calls allowed yet).
|
||||
func TestUserFillUp_FileInputStub(t *testing.T) {
|
||||
c, _ := New(componentNameUserFillUp, map[string]any{
|
||||
"enable_tips": true,
|
||||
"tips": "Upload {{cv}} please",
|
||||
})
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
ctx := withStateForTest(context.Background(), state)
|
||||
|
||||
out, err := c.Invoke(ctx, map[string]any{
|
||||
"inputs": map[string]any{
|
||||
"cv": map[string]any{
|
||||
"value": []any{"file-1", "file-2"},
|
||||
"type": "file",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, _ := out["cv"].(string); got != "<file:cv>" {
|
||||
t.Errorf("cv stub: got %q, want %q", got, "<file:cv>")
|
||||
}
|
||||
if got, _ := out["tips"].(string); got != "Upload <file:cv> please" {
|
||||
t.Errorf("tips with file stub: got %q, want %q", got, "Upload <file:cv> please")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserFillUp_ParamCheck covers the ParamBase surface used by the
|
||||
// orchestrator: defaults, Update, Check, AsDict. This is the contract
|
||||
// the registry's editor tool relies on when round-tripping configs.
|
||||
func TestUserFillUp_ParamCheck(t *testing.T) {
|
||||
var p userFillUpParam
|
||||
if err := p.Update(map[string]any{}); err != nil {
|
||||
t.Fatalf("Update(empty): %v", err)
|
||||
}
|
||||
if !p.EnableTips {
|
||||
t.Error("Update with empty conf should default enable_tips=true")
|
||||
}
|
||||
if p.Tips != defaultUserFillUpTips {
|
||||
t.Errorf("Update with empty conf should default tips=%q, got %q", defaultUserFillUpTips, p.Tips)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
t.Errorf("Check: %v", err)
|
||||
}
|
||||
d := p.AsDict()
|
||||
if d["enable_tips"] != true || d["tips"] != defaultUserFillUpTips || d["layout_recognize"] != "" {
|
||||
t.Errorf("AsDict: %v", d)
|
||||
}
|
||||
}
|
||||
485
internal/agent/component/v1_stubs.go
Normal file
485
internal/agent/component/v1_stubs.go
Normal file
@@ -0,0 +1,485 @@
|
||||
//
|
||||
// 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 component — Phase 1 e2e stubs for v1 DSL components.
|
||||
//
|
||||
// The v1 fixture set under internal/agent/dsl/testdata/v1_examples
|
||||
// references seven component names that the production Phase 1
|
||||
// registry does not yet implement: Retrieval, TavilySearch, ExeSQL,
|
||||
// Generate, Answer, Iteration, IterationItem. Real bodies for these
|
||||
// require network / DB / iteration-engine work that is out of scope
|
||||
// for the canvas compile + invoke e2e path. Without registrations,
|
||||
// the canvas builder errors out at buildNodeBody with "factory:
|
||||
// component: unknown component", which makes the fixture suite
|
||||
// useless as a regression check on topology wiring.
|
||||
//
|
||||
// The seven stubs in this file give the e2e tests a registered
|
||||
// factory for each name. Their bodies are deliberately trivial — they
|
||||
// echo a stable, template-friendly output shape and never call the
|
||||
// network or DB. They are NOT a substitute for the real
|
||||
// implementations; the contract is "registered, non-panicking, and
|
||||
// produces outputs downstream templates can resolve", not "do
|
||||
// something useful". Real Retrieval, TavilySearch, ExeSQL, Generate,
|
||||
// Answer, Iteration, IterationItem bodies land in subsequent phases
|
||||
// (see plan §2.11.3 + §2.11.6) and will replace these stubs file
|
||||
// for file.
|
||||
//
|
||||
// The seven names were chosen by enumerating the component_name
|
||||
// values in the v1_examples fixtures (see dsl.v1Examples). Keeping
|
||||
// the list in sync with the fixture set is a single-source-of-truth
|
||||
// discipline: if a new fixture references a name not in this file,
|
||||
// the e2e test's compile+invoke loop will surface the gap with a
|
||||
// clear factory error.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// ----- Retrieval -----
|
||||
|
||||
const componentNameRetrieval = "Retrieval"
|
||||
|
||||
// RetrievalStub is a Phase 1 placeholder for the v1 Retrieval
|
||||
// component. It returns an empty `formalized_content` so downstream
|
||||
// templates that reference `{retrieval:0@formalized_content}` resolve
|
||||
// to an empty string. The real component (Dealer / KGSearch path,
|
||||
// plan §2.11.3 row 9) replaces this stub when the port lands.
|
||||
type RetrievalStub struct{}
|
||||
|
||||
// NewRetrievalStub constructs a Retrieval stub. params is accepted
|
||||
// for API parity but unused at this stage (the real component will
|
||||
// parse kb_ids / similarity_threshold / top_n from it).
|
||||
func NewRetrievalStub(_ map[string]any) (Component, error) {
|
||||
return &RetrievalStub{}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (r *RetrievalStub) Name() string { return componentNameRetrieval }
|
||||
|
||||
// Invoke returns a stub result that downstream templates can
|
||||
// resolve. `formalized_content` is the field the v1 fixtures
|
||||
// reference; empty string is the safe Phase 1 value.
|
||||
func (r *RetrievalStub) Invoke(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"formalized_content": ""}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke as a single-chunk SSE stream.
|
||||
func (r *RetrievalStub) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := r.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the v1 DSL param surface.
|
||||
func (r *RetrievalStub) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"kb_ids": "Knowledge base IDs to search over.",
|
||||
"similarity_threshold": "Minimum vector similarity to include a chunk.",
|
||||
"keywords_similarity_weight": "BM25 vs vector blend factor (0 = pure vector, 1 = pure BM25).",
|
||||
"top_n": "Number of top chunks to keep after rerank.",
|
||||
"top_k": "Number of candidates to retrieve before rerank.",
|
||||
"rerank_id": "Optional rerank model identifier.",
|
||||
"empty_response": "Fallback message when no chunks pass the threshold.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the public output surface.
|
||||
func (r *RetrievalStub) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"formalized_content": "Rendered chunks for downstream LLM prompts.",
|
||||
}
|
||||
}
|
||||
|
||||
// ----- TavilySearch -----
|
||||
|
||||
const componentNameTavilySearch = "TavilySearch"
|
||||
|
||||
// TavilySearchStub is a Phase 1 placeholder for the v1 TavilySearch
|
||||
// tool. The real implementation (plan §2.11.6) calls the Tavily
|
||||
// HTTP API; this stub returns an empty result so the canvas e2e
|
||||
// flow runs without network access.
|
||||
type TavilySearchStub struct{}
|
||||
|
||||
// NewTavilySearchStub constructs a TavilySearch stub.
|
||||
func NewTavilySearchStub(_ map[string]any) (Component, error) {
|
||||
return &TavilySearchStub{}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (t *TavilySearchStub) Name() string { return componentNameTavilySearch }
|
||||
|
||||
// Invoke returns an empty `formalized_content` so downstream
|
||||
// templates resolve.
|
||||
func (t *TavilySearchStub) Invoke(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"formalized_content": ""}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke.
|
||||
func (t *TavilySearchStub) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := t.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the v1 DSL param surface.
|
||||
func (t *TavilySearchStub) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"api_key": "Tavily API key.",
|
||||
"query": "Search query template (may reference {iterationitem:0@result}).",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the public output surface.
|
||||
func (t *TavilySearchStub) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"formalized_content": "Rendered search results for downstream LLM prompts.",
|
||||
}
|
||||
}
|
||||
|
||||
// ----- ExeSQL -----
|
||||
|
||||
const componentNameExeSQL = "ExeSQL"
|
||||
|
||||
// ExeSQLStub is a Phase 1 placeholder for the v1 ExeSQL component.
|
||||
// The real implementation (plan §2.11.3 row 10) opens a MySQL
|
||||
// connection and runs the user's SQL; this stub returns a fixed
|
||||
// two-column schema so the e2e flow runs without a database.
|
||||
type ExeSQLStub struct{}
|
||||
|
||||
// NewExeSQLStub constructs an ExeSQL stub.
|
||||
func NewExeSQLStub(_ map[string]any) (Component, error) {
|
||||
return &ExeSQLStub{}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (e *ExeSQLStub) Name() string { return componentNameExeSQL }
|
||||
|
||||
// Invoke returns a stable two-column stub result. Downstream
|
||||
// templates that render SQL output will see headers + an empty row
|
||||
// — enough for the message surface to format a string.
|
||||
func (e *ExeSQLStub) Invoke(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"columns": []string{"col1", "col2"},
|
||||
"rows": [][]any{{"", ""}},
|
||||
"sql": "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke.
|
||||
func (e *ExeSQLStub) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := e.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the v1 DSL param surface.
|
||||
func (e *ExeSQLStub) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"database": "Database / schema name.",
|
||||
"username": "DB user.",
|
||||
"host": "DB host.",
|
||||
"port": "DB port.",
|
||||
"password": "DB password.",
|
||||
"top_n": "Limit on rows returned.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the public output surface.
|
||||
func (e *ExeSQLStub) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"columns": "Result-set column names.",
|
||||
"rows": "Result-set rows (matrix form).",
|
||||
"sql": "Resolved SQL string.",
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Generate -----
|
||||
|
||||
const componentNameGenerate = "Generate"
|
||||
|
||||
// GenerateStub is a Phase 1 placeholder for the v1 "Generate"
|
||||
// component. The Python DSL used "Generate" for a non-tool-using
|
||||
// chat call; the Go port renamed the canonical name to "LLM" (see
|
||||
// llm.go) and registers "Generate" here as a thin alias that routes
|
||||
// to the LLM factory. This way the v1 fixtures that still reference
|
||||
// the old name compile and run identically to LLM-backed flows.
|
||||
type GenerateStub struct {
|
||||
inner *LLMComponent
|
||||
}
|
||||
|
||||
// NewGenerateStub constructs a Generate stub. params is forwarded to
|
||||
// the LLM factory so Generate and LLM share the same param surface
|
||||
// (llm_id, prompt, temperature, message_history_window_size, cite).
|
||||
func NewGenerateStub(params map[string]any) (Component, error) {
|
||||
llmParams, err := buildLLMParamFromV1Params(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Generate: %w", err)
|
||||
}
|
||||
return &GenerateStub{inner: NewLLMComponent(llmParams)}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (g *GenerateStub) Name() string { return componentNameGenerate }
|
||||
|
||||
// Invoke delegates to the LLM component.
|
||||
func (g *GenerateStub) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
return g.inner.Invoke(ctx, inputs)
|
||||
}
|
||||
|
||||
// Stream delegates to the LLM component.
|
||||
func (g *GenerateStub) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
return g.inner.Stream(ctx, inputs)
|
||||
}
|
||||
|
||||
// Inputs returns the v1 DSL param surface. Matches LLM's surface
|
||||
// plus the v1-only message_history_window_size and cite.
|
||||
func (g *GenerateStub) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"llm_id": "LLM model identifier.",
|
||||
"prompt": "System / user prompt template.",
|
||||
"temperature": "Sampling temperature (0 = greedy).",
|
||||
"message_history_window_size": "How many prior turns to include.",
|
||||
"cite": "Whether to include source citations in the output.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the public output surface.
|
||||
func (g *GenerateStub) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"content": "Assistant text response.",
|
||||
"model": "Resolved model identifier.",
|
||||
"tokens": "Token count for the call.",
|
||||
}
|
||||
}
|
||||
|
||||
// buildLLMParamFromV1Params converts the v1 Generate params shape
|
||||
// into the LLMParam shape. v1 stores the user prompt under "prompt"
|
||||
// (not "user_prompt") and the system prompt is sometimes empty (the
|
||||
// system role is often folded into "prompt"). We map: prompt →
|
||||
// UserPrompt, llm_id → ModelID, temperature → Temperature,
|
||||
// base_url → BaseURL, api_key → APIKey.
|
||||
func buildLLMParamFromV1Params(p map[string]any) (LLMParam, error) {
|
||||
out := LLMParam{}
|
||||
if v, ok := p["llm_id"].(string); ok {
|
||||
out.ModelID = v
|
||||
}
|
||||
if v, ok := p["prompt"].(string); ok {
|
||||
out.UserPrompt = v
|
||||
}
|
||||
if v, ok := p["temperature"].(float64); ok {
|
||||
out.Temperature = &v
|
||||
}
|
||||
if v, ok := p["max_tokens"].(float64); ok {
|
||||
i := int(v)
|
||||
out.MaxTokens = &i
|
||||
}
|
||||
if v, ok := p["api_key"].(string); ok {
|
||||
out.APIKey = v
|
||||
}
|
||||
if v, ok := p["base_url"].(string); ok {
|
||||
out.BaseURL = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ----- Answer -----
|
||||
|
||||
const componentNameAnswer = "Answer"
|
||||
|
||||
// AnswerStub is a Phase 1 placeholder for the v1 Answer component.
|
||||
// Answer is the agent's "wait for user" node (it pairs with ExeSQL
|
||||
// or Message in conversational flows). The real implementation
|
||||
// pauses the run and resumes on user input; the stub returns an
|
||||
// empty answer immediately so the e2e flow can complete.
|
||||
type AnswerStub struct{}
|
||||
|
||||
// NewAnswerStub constructs an Answer stub.
|
||||
func NewAnswerStub(_ map[string]any) (Component, error) {
|
||||
return &AnswerStub{}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (a *AnswerStub) Name() string { return componentNameAnswer }
|
||||
|
||||
// Invoke returns an empty answer. Real implementation will block
|
||||
// until the user provides input; the stub is fire-and-forget so
|
||||
// the e2e flow doesn't deadlock.
|
||||
func (a *AnswerStub) Invoke(ctx context.Context, _ map[string]any) (map[string]any, error) {
|
||||
// Mirror the no-state-check pattern of Message/Retrieval: we
|
||||
// don't read state, but the signature must match.
|
||||
if _, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err != nil {
|
||||
return nil, fmt.Errorf("Answer: %w", err)
|
||||
}
|
||||
return map[string]any{"answer": ""}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke.
|
||||
func (a *AnswerStub) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := a.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the v1 DSL param surface.
|
||||
func (a *AnswerStub) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"question": "Optional clarification question to surface to the user.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the public output surface.
|
||||
func (a *AnswerStub) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"answer": "User's response text.",
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Iteration / IterationItem -----
|
||||
|
||||
const (
|
||||
componentNameIteration = "Iteration"
|
||||
componentNameIterationItem = "IterationItem"
|
||||
)
|
||||
|
||||
// IterationStub is a Phase 1 placeholder for the v1 Iteration
|
||||
// parent. The real implementation lives in canvas/loop_subgraph.go
|
||||
// and runs the body once per item. The stub returns a single empty
|
||||
// item list so the body never fires, which is a safe Phase 1
|
||||
// default for the e2e flow.
|
||||
type IterationStub struct{}
|
||||
|
||||
// NewIterationStub constructs an Iteration stub.
|
||||
func NewIterationStub(_ map[string]any) (Component, error) {
|
||||
return &IterationStub{}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (i *IterationStub) Name() string { return componentNameIteration }
|
||||
|
||||
// Invoke returns an empty iteration payload.
|
||||
func (i *IterationStub) Invoke(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"items": []any{}}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke.
|
||||
func (i *IterationStub) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := i.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the v1 DSL param surface.
|
||||
func (i *IterationStub) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"items_ref": "Reference to the items source (e.g. \"{generate:0@structured_content}\").",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the public output surface.
|
||||
func (i *IterationStub) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"items": "Items to iterate over (resolved at run time).",
|
||||
}
|
||||
}
|
||||
|
||||
// IterationItemStub is a Phase 1 placeholder for the body node of
|
||||
// an Iteration. The real wiring (parent_id → child routing) is
|
||||
// engine-side; the stub itself is a passthrough.
|
||||
type IterationItemStub struct{}
|
||||
|
||||
// NewIterationItemStub constructs an IterationItem stub.
|
||||
func NewIterationItemStub(_ map[string]any) (Component, error) {
|
||||
return &IterationItemStub{}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (it *IterationItemStub) Name() string { return componentNameIterationItem }
|
||||
|
||||
// Invoke returns a passthrough empty map.
|
||||
func (it *IterationItemStub) Invoke(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"result": ""}, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke.
|
||||
func (it *IterationItemStub) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := it.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the v1 DSL param surface.
|
||||
func (it *IterationItemStub) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"item": "The current iteration item, injected by the Iteration parent.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the public output surface.
|
||||
func (it *IterationItemStub) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"result": "Body result for the current item.",
|
||||
}
|
||||
}
|
||||
|
||||
// ----- registrations -----
|
||||
|
||||
// One init per file keeps the registrations grouped and visible.
|
||||
// Each Register call panics on a duplicate (the registry enforces
|
||||
// uniqueness), so accidental double-registration in a later refactor
|
||||
// surfaces as a panic at init time, not as a silent override.
|
||||
func init() {
|
||||
Register(componentNameRetrieval, NewRetrievalStub)
|
||||
Register(componentNameTavilySearch, NewTavilySearchStub)
|
||||
Register(componentNameExeSQL, NewExeSQLStub)
|
||||
Register(componentNameGenerate, NewGenerateStub)
|
||||
Register(componentNameAnswer, NewAnswerStub)
|
||||
Register(componentNameIteration, NewIterationStub)
|
||||
Register(componentNameIterationItem, NewIterationItemStub)
|
||||
}
|
||||
292
internal/agent/component/variable_aggregator.go
Normal file
292
internal/agent/component/variable_aggregator.go
Normal file
@@ -0,0 +1,292 @@
|
||||
//
|
||||
// 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 component — VariableAggregator (T3, plan §2.11.3 row 19).
|
||||
//
|
||||
// For each "group" in its param, VariableAggregator walks a list of
|
||||
// variable selectors and picks the first one whose resolved value is
|
||||
// truthy. The picked value is exposed at outputs[<group_name>].
|
||||
//
|
||||
// Mirrors agent/component/variable_aggregator.py. The Python implementation
|
||||
// also records each group's variables list under the synthetic input key
|
||||
// "<group_name>.variables" for engine bookkeeping; the Go port skips that
|
||||
// side-effect because the canvas engine consumes param data directly via
|
||||
// the component factory, not via a re-emitted inputs map.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameVariableAggregator = "VariableAggregator"
|
||||
|
||||
// variableAggregatorParam is the static configuration loaded from the DSL.
|
||||
// It mirrors the Python VariableAggregatorParam surface.
|
||||
type variableAggregatorParam struct {
|
||||
// Groups is a list of {group_name, variables} dicts. Each
|
||||
// group.variables entry is itself a {value: <ref-string>} dict.
|
||||
Groups []map[string]any `json:"groups"`
|
||||
}
|
||||
|
||||
// Update copies a fresh param map into the receiver. Mirrors the Python
|
||||
// ComponentParamBase contract.
|
||||
//
|
||||
// `groups` may arrive as either []any (engine-decoded from JSON) or
|
||||
// []map[string]any (test/direct construction); both shapes are accepted
|
||||
// so callers don't have to coerce.
|
||||
func (p *variableAggregatorParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
p.Groups = nil
|
||||
return nil
|
||||
}
|
||||
rawGroups, ok := conf["groups"]
|
||||
if !ok {
|
||||
p.Groups = nil
|
||||
return nil
|
||||
}
|
||||
var groupsList []any
|
||||
switch x := rawGroups.(type) {
|
||||
case []any:
|
||||
groupsList = x
|
||||
case []map[string]any:
|
||||
groupsList = make([]any, 0, len(x))
|
||||
for _, g := range x {
|
||||
groupsList = append(groupsList, g)
|
||||
}
|
||||
default:
|
||||
return &ParamError{Field: "groups", Reason: "must be a list"}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(groupsList))
|
||||
for i, raw := range groupsList {
|
||||
g, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return &ParamError{Field: fmt.Sprintf("groups[%d]", i), Reason: "must be a map"}
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
p.Groups = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check performs shallow validation. Mirrors VariableAggregatorParam.check.
|
||||
func (p *variableAggregatorParam) Check() error {
|
||||
if len(p.Groups) == 0 {
|
||||
return &ParamError{Field: "groups", Reason: "must not be empty"}
|
||||
}
|
||||
for i, g := range p.Groups {
|
||||
name, _ := g["group_name"].(string)
|
||||
if name == "" {
|
||||
return &ParamError{Field: fmt.Sprintf("groups[%d].group_name", i), Reason: "must not be empty"}
|
||||
}
|
||||
vars, ok := g["variables"]
|
||||
if !ok {
|
||||
return &ParamError{Field: fmt.Sprintf("groups[%d].variables", i), Reason: "must be a list"}
|
||||
}
|
||||
switch vars.(type) {
|
||||
case []any, []map[string]any:
|
||||
// accept both shapes
|
||||
default:
|
||||
return &ParamError{Field: fmt.Sprintf("groups[%d].variables", i), Reason: "must be a list"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsDict returns the params as a plain map.
|
||||
func (p *variableAggregatorParam) AsDict() map[string]any {
|
||||
out := map[string]any{"groups": make([]any, 0, len(p.Groups))}
|
||||
for _, g := range p.Groups {
|
||||
out["groups"] = append(out["groups"].([]any), g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VariableAggregatorComponent walks each group's selectors and emits
|
||||
// outputs[group_name] = first non-empty resolved value.
|
||||
type VariableAggregatorComponent struct {
|
||||
name string
|
||||
param variableAggregatorParam
|
||||
}
|
||||
|
||||
// NewVariableAggregatorComponent constructs a VariableAggregator from
|
||||
// the DSL param map. The param is validated via Check(); a check failure
|
||||
// is returned to the caller so the engine can surface a clean error.
|
||||
func NewVariableAggregatorComponent(params map[string]any) (Component, error) {
|
||||
p := &variableAggregatorParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("VariableAggregator: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("VariableAggregator: param check: %w", err)
|
||||
}
|
||||
return &VariableAggregatorComponent{
|
||||
name: componentNameVariableAggregator,
|
||||
param: *p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (v *VariableAggregatorComponent) Name() string { return v.name }
|
||||
|
||||
// Invoke iterates the configured groups and resolves each selector's
|
||||
// value against the canvas state. The first truthy value in a group
|
||||
// wins; outputs[group_name] is set to that value. Groups with no truthy
|
||||
// selector produce no output key.
|
||||
//
|
||||
// Variable references may be passed in two ways:
|
||||
// - static via param.groups[i].variables[j].value
|
||||
// - runtime via inputs["variables"] (a list of selector dicts that
|
||||
// REPLACES the static config for the duration of this call).
|
||||
//
|
||||
// The runtime override matches the Python component's get_input_form
|
||||
// contract: the engine is allowed to pass the resolved variable list
|
||||
// per-invocation. When inputs["variables"] is absent the static param
|
||||
// config is used unchanged.
|
||||
func (v *VariableAggregatorComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VariableAggregator: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("VariableAggregator: nil canvas state")
|
||||
}
|
||||
|
||||
groups := v.param.Groups
|
||||
// Optional runtime override: the engine can pass a fresh
|
||||
// "variables" list (a list of group dicts) that replaces the
|
||||
// static param. We accept either shape — a bare list of selectors
|
||||
// replaces the FIRST group's variables, or a list of group dicts
|
||||
// replaces all groups entirely. The latter is the common case
|
||||
// because the engine passes one item per group.
|
||||
if override, ok := inputs["variables"].([]any); ok && len(override) > 0 {
|
||||
if first, ok := override[0].(map[string]any); ok {
|
||||
if _, hasGroups := first["groups"]; hasGroups {
|
||||
// shape: [{groups: [...]}] — flatten outer wrapper
|
||||
groups = make([]map[string]any, 0, len(override))
|
||||
for _, raw := range override {
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
if gs, ok := m["groups"].([]any); ok {
|
||||
for _, g := range gs {
|
||||
if gm, ok := g.(map[string]any); ok {
|
||||
groups = append(groups, gm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// treat override as a list of group dicts
|
||||
groups = make([]map[string]any, 0, len(override))
|
||||
for _, g := range override {
|
||||
if gm, ok := g.(map[string]any); ok {
|
||||
groups = append(groups, gm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(groups))
|
||||
for _, g := range groups {
|
||||
gname, _ := g["group_name"].(string)
|
||||
if gname == "" {
|
||||
continue
|
||||
}
|
||||
selectors, _ := g["variables"].([]any)
|
||||
for _, raw := range selectors {
|
||||
sel, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref, _ := sel["value"].(string)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
val, err := state.GetVar(ref)
|
||||
if err != nil || !isTruthy(val) {
|
||||
continue
|
||||
}
|
||||
out[gname] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; VariableAggregator is a single-shot reduce.
|
||||
func (v *VariableAggregatorComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := v.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the public parameter surface. The "variables" key
|
||||
// accepts a runtime override of the per-group variable list (matching
|
||||
// the Python get_input_form contract).
|
||||
func (v *VariableAggregatorComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"variables": "Optional runtime override of the per-group variable selector list.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns one key per configured group: <group_name> = first
|
||||
// non-empty resolved value for that group.
|
||||
func (v *VariableAggregatorComponent) Outputs() map[string]string {
|
||||
out := make(map[string]string, len(v.param.Groups))
|
||||
for _, g := range v.param.Groups {
|
||||
if name, _ := g["group_name"].(string); name != "" {
|
||||
out[name] = "First non-empty resolved value among the group's selectors."
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isTruthy mirrors Python's bool() coercion: nil is false, empty
|
||||
// strings/slices/maps are false, zero numbers are false, false is
|
||||
// false, everything else is true.
|
||||
func isTruthy(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return x != ""
|
||||
case []any:
|
||||
return len(x) > 0
|
||||
case map[string]any:
|
||||
return len(x) > 0
|
||||
case int:
|
||||
return x != 0
|
||||
case int64:
|
||||
return x != 0
|
||||
case float64:
|
||||
return x != 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameVariableAggregator, NewVariableAggregatorComponent)
|
||||
}
|
||||
214
internal/agent/component/variable_aggregator_test.go
Normal file
214
internal/agent/component/variable_aggregator_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestVariableAggregator_FirstNonEmpty: 3 groups, 2 selectors each,
|
||||
// first selector's ref is unset, second has a value → second wins.
|
||||
func TestVariableAggregator_FirstNonEmpty(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
state.Outputs["cpn_0"] = map[string]any{}
|
||||
state.Outputs["cpn_1"] = map[string]any{"y": "second-a"}
|
||||
state.Outputs["cpn_2"] = map[string]any{"y": "second-b"}
|
||||
state.Outputs["cpn_3"] = map[string]any{"y": "second-c"}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
groups := []map[string]any{
|
||||
{
|
||||
"group_name": "group_a",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "cpn_0@missing"},
|
||||
map[string]any{"value": "cpn_1@y"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"group_name": "group_b",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "cpn_0@missing"},
|
||||
map[string]any{"value": "cpn_2@y"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"group_name": "group_c",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "cpn_0@missing"},
|
||||
map[string]any{"value": "cpn_3@y"},
|
||||
},
|
||||
},
|
||||
}
|
||||
c, err := NewVariableAggregatorComponent(map[string]any{"groups": groups})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAggregatorComponent: %v", err)
|
||||
}
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["group_a"], "second-a"; got != want {
|
||||
t.Errorf("group_a: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["group_b"], "second-b"; got != want {
|
||||
t.Errorf("group_b: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["group_c"], "second-c"; got != want {
|
||||
t.Errorf("group_c: got %v, want %v", got, want)
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Errorf("expected 3 output keys, got %d: %v", len(out), out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAggregator_SkipsEmptyString: empty string is falsy in
|
||||
// Python's bool() — the Go port must treat "" the same way.
|
||||
func TestVariableAggregator_SkipsEmptyString(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
state.Outputs["cpn_0"] = map[string]any{"x": ""}
|
||||
state.Outputs["cpn_1"] = map[string]any{"y": "picked"}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
groups := []map[string]any{
|
||||
{
|
||||
"group_name": "out",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "cpn_0@x"},
|
||||
map[string]any{"value": "cpn_1@y"},
|
||||
},
|
||||
},
|
||||
}
|
||||
c, err := NewVariableAggregatorComponent(map[string]any{"groups": groups})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAggregatorComponent: %v", err)
|
||||
}
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["out"], "picked"; got != want {
|
||||
t.Errorf("out: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAggregator_MultipleGroups: 3 groups, each picks its own
|
||||
// first-non-empty from independent state namespaces.
|
||||
func TestVariableAggregator_MultipleGroups(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
state.Sys["a"] = "alpha"
|
||||
state.Sys["b"] = ""
|
||||
state.Env["c"] = "gamma"
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
groups := []map[string]any{
|
||||
{
|
||||
"group_name": "g1",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "sys.a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"group_name": "g2",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "sys.b"},
|
||||
map[string]any{"value": "env.c"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"group_name": "g3",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "sys.d"}, // missing
|
||||
map[string]any{"value": "env.c"},
|
||||
},
|
||||
},
|
||||
}
|
||||
c, err := NewVariableAggregatorComponent(map[string]any{"groups": groups})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAggregatorComponent: %v", err)
|
||||
}
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := out["g1"], "alpha"; got != want {
|
||||
t.Errorf("g1: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["g2"], "gamma"; got != want {
|
||||
t.Errorf("g2: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := out["g3"], "gamma"; got != want {
|
||||
t.Errorf("g3: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAggregator_AllEmpty: no group picks a value → no output
|
||||
// keys. The component must not error or panic.
|
||||
func TestVariableAggregator_AllEmpty(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
state.Outputs["cpn_0"] = map[string]any{}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
groups := []map[string]any{
|
||||
{
|
||||
"group_name": "g1",
|
||||
"variables": []any{
|
||||
map[string]any{"value": "cpn_0@missing"},
|
||||
},
|
||||
},
|
||||
}
|
||||
c, err := NewVariableAggregatorComponent(map[string]any{"groups": groups})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAggregatorComponent: %v", err)
|
||||
}
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("expected empty outputs, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAggregator_ParamCheck: empty groups list must be rejected
|
||||
// at construction time.
|
||||
func TestVariableAggregator_ParamCheck(t *testing.T) {
|
||||
_, err := NewVariableAggregatorComponent(map[string]any{"groups": []any{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty groups, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAggregator_Registered: factory lookup via the registry.
|
||||
func TestVariableAggregator_Registered(t *testing.T) {
|
||||
c, err := New("VariableAggregator", map[string]any{
|
||||
"groups": []map[string]any{
|
||||
{
|
||||
"group_name": "g",
|
||||
"variables": []any{map[string]any{"value": "sys.x"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if c.Name() != "VariableAggregator" {
|
||||
t.Errorf("Name()=%q, want VariableAggregator", c.Name())
|
||||
}
|
||||
}
|
||||
512
internal/agent/component/variable_assigner.go
Normal file
512
internal/agent/component/variable_assigner.go
Normal file
@@ -0,0 +1,512 @@
|
||||
//
|
||||
// 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 component — VariableAssigner (T3, plan §2.11.3 row 20).
|
||||
//
|
||||
// VariableAssigner applies an ordered list of (variable, operator,
|
||||
// parameter) tuples to the shared *CanvasState. Each tuple's operator
|
||||
// reads the current variable value, computes a new one, and (unless the
|
||||
// operator returns an "ERROR:..." sentinel) writes the new value back to
|
||||
// the state bucket the variable ref points at.
|
||||
//
|
||||
// The eleven operators mirror agent/component/variable_assigner.py:
|
||||
//
|
||||
// overwrite, clear, set, append, extend, remove_first, remove_last,
|
||||
// "+= -= *= /="
|
||||
//
|
||||
// Variable refs may target cpn outputs ("cpn_0@x"), the sys namespace
|
||||
// ("sys.x"), the env namespace ("env.x"), or iteration aliases
|
||||
// ("item" / "index"). Cpn-typed refs are split on the first "@" into
|
||||
// (cpnID, param) and written via SetVar; sys/env/item/index are written
|
||||
// to their respective CanvasState maps directly.
|
||||
//
|
||||
// On "ERROR:..." returns the operator result is exposed at
|
||||
// outputs["errors"] and the state bucket is left unchanged.
|
||||
package component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
const componentNameVariableAssigner = "VariableAssigner"
|
||||
|
||||
// variableAssignerParam is the static configuration. variables is a
|
||||
// list of {variable, operator, parameter} dicts.
|
||||
type variableAssignerParam struct {
|
||||
Variables []map[string]any `json:"variables"`
|
||||
}
|
||||
|
||||
// Update copies a fresh param map into the receiver. `variables` may
|
||||
// arrive as []any (engine-decoded from JSON) or []map[string]any
|
||||
// (test/direct construction); both shapes are accepted.
|
||||
func (p *variableAssignerParam) Update(conf map[string]any) error {
|
||||
if conf == nil {
|
||||
p.Variables = nil
|
||||
return nil
|
||||
}
|
||||
raw, ok := conf["variables"]
|
||||
if !ok {
|
||||
p.Variables = nil
|
||||
return nil
|
||||
}
|
||||
var list []any
|
||||
switch x := raw.(type) {
|
||||
case []any:
|
||||
list = x
|
||||
case []map[string]any:
|
||||
list = make([]any, 0, len(x))
|
||||
for _, v := range x {
|
||||
list = append(list, v)
|
||||
}
|
||||
default:
|
||||
return &ParamError{Field: "variables", Reason: "must be a list"}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for i, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return &ParamError{Field: fmt.Sprintf("variables[%d]", i), Reason: "must be a map"}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
p.Variables = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check is a no-op for VariableAssigner; the Python base class also
|
||||
// returns True unconditionally.
|
||||
func (p *variableAssignerParam) Check() error { return nil }
|
||||
|
||||
// AsDict returns the params as a plain map.
|
||||
func (p *variableAssignerParam) AsDict() map[string]any {
|
||||
out := map[string]any{"variables": make([]any, 0, len(p.Variables))}
|
||||
for _, v := range p.Variables {
|
||||
out["variables"] = append(out["variables"].([]any), v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VariableAssignerComponent applies the configured (variable, operator,
|
||||
// parameter) tuples to the canvas state.
|
||||
type VariableAssignerComponent struct {
|
||||
name string
|
||||
param variableAssignerParam
|
||||
}
|
||||
|
||||
// NewVariableAssignerComponent constructs a VariableAssigner from the
|
||||
// DSL param map.
|
||||
func NewVariableAssignerComponent(params map[string]any) (Component, error) {
|
||||
p := &variableAssignerParam{}
|
||||
if err := p.Update(params); err != nil {
|
||||
return nil, fmt.Errorf("VariableAssigner: param update: %w", err)
|
||||
}
|
||||
if err := p.Check(); err != nil {
|
||||
return nil, fmt.Errorf("VariableAssigner: param check: %w", err)
|
||||
}
|
||||
return &VariableAssignerComponent{
|
||||
name: componentNameVariableAssigner,
|
||||
param: *p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the registered component name.
|
||||
func (v *VariableAssignerComponent) Name() string { return v.name }
|
||||
|
||||
// Invoke walks the param.variables list, evaluates each tuple against
|
||||
// the canvas state, and writes the result back unless the operator
|
||||
// returned an "ERROR:..." sentinel. The list of refs that were
|
||||
// assigned is returned at outputs["assignments"]; per-item errors (if
|
||||
// any) are returned at outputs["errors"].
|
||||
func (v *VariableAssignerComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VariableAssigner: %w", err)
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("VariableAssigner: nil canvas state")
|
||||
}
|
||||
|
||||
items := v.param.Variables
|
||||
// Allow runtime override via inputs["variables"] (a list of tuples
|
||||
// in the same shape as param.variables).
|
||||
if override, ok := inputs["variables"].([]any); ok && len(override) > 0 {
|
||||
items = items[:0]
|
||||
for _, raw := range override {
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
items = append(items, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assignments := make([]string, 0, len(items))
|
||||
var errors []string
|
||||
for i, item := range items {
|
||||
ref, _ := item["variable"].(string)
|
||||
op, _ := item["operator"].(string)
|
||||
param, _ := item["parameter"]
|
||||
if ref == "" || op == "" {
|
||||
return nil, &ParamError{
|
||||
Field: fmt.Sprintf("variables[%d]", i),
|
||||
Reason: "variable and operator must be non-empty",
|
||||
}
|
||||
}
|
||||
oldVal, err := state.GetVar(ref)
|
||||
if err != nil {
|
||||
// bad ref shape — surface as an error rather than silently skip
|
||||
return nil, fmt.Errorf("VariableAssigner: variables[%d] get %q: %w", i, ref, err)
|
||||
}
|
||||
newVal, opErr := operate(state, oldVal, op, param)
|
||||
if opErr != "" {
|
||||
errors = append(errors, fmt.Sprintf("variables[%d] %s: %s", i, ref, opErr))
|
||||
continue
|
||||
}
|
||||
if err := writeVar(state, ref, newVal); err != nil {
|
||||
return nil, fmt.Errorf("VariableAssigner: variables[%d] write %q: %w", i, ref, err)
|
||||
}
|
||||
assignments = append(assignments, ref)
|
||||
}
|
||||
out := map[string]any{"assignments": assignments}
|
||||
if len(errors) > 0 {
|
||||
out["errors"] = errors
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; VariableAssigner is a single-shot apply.
|
||||
func (v *VariableAssignerComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := v.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan map[string]any, 1)
|
||||
ch <- out
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Inputs returns the public parameter surface.
|
||||
func (v *VariableAssignerComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"variables": "Optional runtime override: a list of {variable, operator, parameter} dicts.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the assigned refs and any per-item errors.
|
||||
func (v *VariableAssignerComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"assignments": "List of refs that were successfully written back to state.",
|
||||
"errors": "Per-item error messages; absent when all operators succeeded.",
|
||||
}
|
||||
}
|
||||
|
||||
// operate applies the operator. Returns ("", "ERROR:...") on failure;
|
||||
// the caller treats the empty string opErr as success and the new value
|
||||
// as the value to write back.
|
||||
func operate(state *runtime.CanvasState, oldVal any, op string, param any) (any, string) {
|
||||
switch op {
|
||||
case "overwrite":
|
||||
// overwrite: new = canvas.get_variable_value(parameter).
|
||||
// parameter is itself a ref string (possibly wrapped in
|
||||
// {{...}}); strip the wrapping before lookup so
|
||||
// "{{cpn_1@y}}" resolves to cpn_1's "y" output value.
|
||||
if s, ok := param.(string); ok {
|
||||
bare := stripVarBraces(s)
|
||||
v, err := state.GetVar(bare)
|
||||
if err != nil {
|
||||
return nil, "ERROR:PARAMETER_UNRESOLVED"
|
||||
}
|
||||
return v, ""
|
||||
}
|
||||
// If parameter is not a string, the Python code calls
|
||||
// get_variable_value which expects a string. Pass through.
|
||||
return param, ""
|
||||
|
||||
case "clear":
|
||||
switch oldVal.(type) {
|
||||
case nil:
|
||||
return nil, ""
|
||||
case []any:
|
||||
return []any{}, ""
|
||||
case string:
|
||||
return "", ""
|
||||
case map[string]any:
|
||||
return map[string]any{}, ""
|
||||
case bool:
|
||||
return false, ""
|
||||
case int, int64, float64, float32:
|
||||
return 0, ""
|
||||
}
|
||||
return nil, ""
|
||||
|
||||
case "set":
|
||||
switch oldVal.(type) {
|
||||
case nil, string:
|
||||
// Try to interpret parameter as a ref (or {{...}} template);
|
||||
// fall back to the raw value when it doesn't look like one.
|
||||
if s, ok := param.(string); ok && s != "" {
|
||||
if v, err := state.GetVar(s); err == nil && v != nil {
|
||||
return v, ""
|
||||
}
|
||||
// also try template resolution against state for {{...}}
|
||||
if strings.Contains(s, "{{") {
|
||||
if resolved, err := runtime.ResolveTemplate(s, state); err == nil {
|
||||
return resolved, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return param, ""
|
||||
default:
|
||||
return param, ""
|
||||
}
|
||||
|
||||
case "append":
|
||||
p, _ := state.GetVar(asRefString(param))
|
||||
// when param is a non-ref literal, p is "" — fall back to raw
|
||||
_ = p
|
||||
p = resolveParamValue(state, param)
|
||||
if oldVal == nil {
|
||||
oldVal = []any{}
|
||||
}
|
||||
lst, ok := oldVal.([]any)
|
||||
if !ok {
|
||||
return nil, "ERROR:VARIABLE_NOT_LIST"
|
||||
}
|
||||
if len(lst) > 0 {
|
||||
if !compatibleElemType(lst[0], p) {
|
||||
return nil, "ERROR:PARAMETER_NOT_LIST_ELEMENT_TYPE"
|
||||
}
|
||||
}
|
||||
// append returns the original list mutated
|
||||
lst = append(lst, p)
|
||||
return lst, ""
|
||||
|
||||
case "extend":
|
||||
p := resolveParamValue(state, param)
|
||||
if oldVal == nil {
|
||||
oldVal = []any{}
|
||||
}
|
||||
lst, ok := oldVal.([]any)
|
||||
if !ok {
|
||||
return nil, "ERROR:VARIABLE_NOT_LIST"
|
||||
}
|
||||
pl, ok := p.([]any)
|
||||
if !ok {
|
||||
return nil, "ERROR:PARAMETER_NOT_LIST"
|
||||
}
|
||||
if len(lst) > 0 && len(pl) > 0 {
|
||||
if !compatibleElemType(lst[0], pl[0]) {
|
||||
return nil, "ERROR:PARAMETER_NOT_LIST_ELEMENT_TYPE"
|
||||
}
|
||||
}
|
||||
return append(lst, pl...), ""
|
||||
|
||||
case "remove_first":
|
||||
lst, ok := oldVal.([]any)
|
||||
if !ok {
|
||||
return nil, "ERROR:VARIABLE_NOT_LIST"
|
||||
}
|
||||
if len(lst) == 0 {
|
||||
return lst, ""
|
||||
}
|
||||
return lst[1:], ""
|
||||
|
||||
case "remove_last":
|
||||
lst, ok := oldVal.([]any)
|
||||
if !ok {
|
||||
return nil, "ERROR:VARIABLE_NOT_LIST"
|
||||
}
|
||||
if len(lst) == 0 {
|
||||
return lst, ""
|
||||
}
|
||||
return lst[:len(lst)-1], ""
|
||||
|
||||
case "+=":
|
||||
pv := resolveParamValue(state, param)
|
||||
if !isNumberish(oldVal) || !isNumberish(pv) {
|
||||
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
|
||||
}
|
||||
return toFloat64(oldVal) + toFloat64(pv), ""
|
||||
|
||||
case "-=":
|
||||
pv := resolveParamValue(state, param)
|
||||
if !isNumberish(oldVal) || !isNumberish(pv) {
|
||||
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
|
||||
}
|
||||
return toFloat64(oldVal) - toFloat64(pv), ""
|
||||
|
||||
case "*=":
|
||||
pv := resolveParamValue(state, param)
|
||||
if !isNumberish(oldVal) || !isNumberish(pv) {
|
||||
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
|
||||
}
|
||||
return toFloat64(oldVal) * toFloat64(pv), ""
|
||||
|
||||
case "/=":
|
||||
pv := resolveParamValue(state, param)
|
||||
if !isNumberish(oldVal) || !isNumberish(pv) {
|
||||
return nil, "ERROR:VARIABLE_NOT_NUMBER or PARAMETER_NOT_NUMBER"
|
||||
}
|
||||
if toFloat64(pv) == 0 {
|
||||
return nil, "ERROR:DIVIDE_BY_ZERO"
|
||||
}
|
||||
return toFloat64(oldVal) / toFloat64(pv), ""
|
||||
}
|
||||
return nil, "ERROR:UNKNOWN_OPERATOR"
|
||||
}
|
||||
|
||||
// writeVar routes a ref to the correct CanvasState bucket.
|
||||
//
|
||||
// - "cpn_id@param..." → SetVar(cpnID, param...)
|
||||
// - "sys.x" → Sys["x"] = v
|
||||
// - "env.x" → Env["x"] = v
|
||||
// - "item" → Globals["__item__"] = v
|
||||
// - "index" → Globals["__index__"] = v
|
||||
func writeVar(state *runtime.CanvasState, ref string, v any) error {
|
||||
switch {
|
||||
case ref == "item":
|
||||
state.Globals["__item__"] = v
|
||||
return nil
|
||||
case ref == "index":
|
||||
state.Globals["__index__"] = v
|
||||
return nil
|
||||
case strings.HasPrefix(ref, "sys."):
|
||||
state.Sys[strings.TrimPrefix(ref, "sys.")] = v
|
||||
return nil
|
||||
case strings.HasPrefix(ref, "env."):
|
||||
state.Env[strings.TrimPrefix(ref, "env.")] = v
|
||||
return nil
|
||||
}
|
||||
idx := strings.Index(ref, "@")
|
||||
if idx <= 0 {
|
||||
return fmt.Errorf("invalid variable ref %q", ref)
|
||||
}
|
||||
cpnID, param := ref[:idx], ref[idx+1:]
|
||||
state.SetVar(cpnID, param, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// asRefString returns param as a string ref if it is one, "" otherwise.
|
||||
// Used to gate "should I look this up in state" decisions.
|
||||
func asRefString(param any) string {
|
||||
if s, ok := param.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripVarBraces removes one or two layers of surrounding `{` `}` plus
|
||||
// whitespace from s, matching the chained .strip("{").strip("}").strip(" ").
|
||||
// strip("{").strip("}") at agent/canvas.py:196. The double-layer case
|
||||
// handles "{{cpn_1@y}}" → "cpn_1@y" so canvas.GetVar can resolve it
|
||||
// against the cpn output bucket.
|
||||
func stripVarBraces(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
for range 2 {
|
||||
if len(s) >= 2 && s[0] == '{' && s[len(s)-1] == '}' {
|
||||
s = strings.TrimSpace(s[1 : len(s)-1])
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveParamValue returns the value of param. Strings are looked up
|
||||
// in state first (treating them as refs); anything else is passed
|
||||
// through unchanged. The Python _canvas.get_variable_value does the
|
||||
// same after stripping the surrounding {{ }}; non-string params
|
||||
// (numbers, lists, dicts) are passed verbatim.
|
||||
func resolveParamValue(state *runtime.CanvasState, param any) any {
|
||||
if s, ok := param.(string); ok && s != "" {
|
||||
// Try the parameter as a bare ref first (matches Python's
|
||||
// canvas.get_variable_value which strips braces then splits on @).
|
||||
bare := stripVarBraces(s)
|
||||
if v, err := state.GetVar(bare); err == nil && v != nil {
|
||||
return v
|
||||
}
|
||||
// Fall back to the original string for cases where the param
|
||||
// contains template fragments that didn't fully resolve.
|
||||
}
|
||||
return param
|
||||
}
|
||||
|
||||
// compatibleElemType mirrors the Python isinstance check used in
|
||||
// _append / _extend. The Python code uses strict isinstance; the Go
|
||||
// port relaxes this to "same Go kind" (int / float64 / string) so
|
||||
// JSON-decoded numbers from LLM output compose correctly.
|
||||
func compatibleElemType(a, b any) bool {
|
||||
return goKind(a) == goKind(b)
|
||||
}
|
||||
|
||||
func goKind(v any) string {
|
||||
switch v.(type) {
|
||||
case int, int64, int32:
|
||||
return "int"
|
||||
case float64, float32:
|
||||
return "float"
|
||||
case string:
|
||||
return "string"
|
||||
case bool:
|
||||
return "bool"
|
||||
case map[string]any:
|
||||
return "map"
|
||||
case []any:
|
||||
return "list"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// isNumberish returns true for numeric values (int, float, including
|
||||
// JSON-decoded numbers). Booleans are explicitly excluded — Python's
|
||||
// numbers.Number is a superclass of int/float/complex/Decimal but
|
||||
// Python's isinstance(True, numbers.Number) is False; the spec matches
|
||||
// that.
|
||||
func isNumberish(v any) bool {
|
||||
if _, ok := v.(bool); ok {
|
||||
return false
|
||||
}
|
||||
switch v.(type) {
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toFloat64 converts any numeric value to float64. Callers must guard
|
||||
// with isNumberish first.
|
||||
func toFloat64(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case int32:
|
||||
return float64(x)
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(componentNameVariableAssigner, NewVariableAssignerComponent)
|
||||
}
|
||||
247
internal/agent/component/variable_assigner_test.go
Normal file
247
internal/agent/component/variable_assigner_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
// TestVariableAssigner_Append: list append, verify state updated.
|
||||
func TestVariableAssigner_Append(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{1, 2}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
vars := []map[string]any{
|
||||
{
|
||||
"variable": "cpn_0@xs",
|
||||
"operator": "append",
|
||||
"parameter": 3,
|
||||
},
|
||||
}
|
||||
c, err := NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAssignerComponent: %v", err)
|
||||
}
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got := state.Outputs["cpn_0"]["xs"]
|
||||
want := []any{1, 2, 3}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("state.Outputs[cpn_0][xs]: got %v, want %v", got, want)
|
||||
}
|
||||
assigns, _ := out["assignments"].([]string)
|
||||
if len(assigns) != 1 || assigns[0] != "cpn_0@xs" {
|
||||
t.Errorf("assignments: got %v, want [cpn_0@xs]", assigns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAssigner_Overwrite: variable="cpn_0@x", operator="overwrite",
|
||||
// parameter="cpn_1@y" → cpn_0@x = cpn_1@y value.
|
||||
func TestVariableAssigner_Overwrite(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-2", "task-2")
|
||||
state.Outputs["cpn_0"] = map[string]any{"x": "old"}
|
||||
state.Outputs["cpn_1"] = map[string]any{"y": "fresh"}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
vars := []map[string]any{
|
||||
{
|
||||
"variable": "cpn_0@x",
|
||||
"operator": "overwrite",
|
||||
"parameter": "cpn_1@y",
|
||||
},
|
||||
}
|
||||
c, err := NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAssignerComponent: %v", err)
|
||||
}
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := state.Outputs["cpn_0"]["x"], "fresh"; got != want {
|
||||
t.Errorf("state.Outputs[cpn_0][x]: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAssigner_DivideByZero: assert "ERROR:DIVIDE_BY_ZERO"
|
||||
// returned, state unchanged.
|
||||
func TestVariableAssigner_DivideByZero(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
state.Outputs["cpn_0"] = map[string]any{"n": 6.0}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
vars := []map[string]any{
|
||||
{
|
||||
"variable": "cpn_0@n",
|
||||
"operator": "/=",
|
||||
"parameter": 0,
|
||||
},
|
||||
}
|
||||
c, err := NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAssignerComponent: %v", err)
|
||||
}
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
errs, ok := out["errors"].([]string)
|
||||
if !ok || len(errs) == 0 {
|
||||
t.Fatalf("expected errors in outputs, got %v", out)
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if containsString(e, "DIVIDE_BY_ZERO") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected DIVIDE_BY_ZERO error, got %v", errs)
|
||||
}
|
||||
// state must be unchanged
|
||||
if got, want := state.Outputs["cpn_0"]["n"], 6.0; got != want {
|
||||
t.Errorf("state.Outputs[cpn_0][n]: got %v, want %v (unchanged)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAssigner_Clear: list/str/dict/int → empty values.
|
||||
func TestVariableAssigner_Clear(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
state.Outputs["cpn_0"] = map[string]any{
|
||||
"a": []any{1, 2},
|
||||
"b": "hello",
|
||||
"c": map[string]any{"k": "v"},
|
||||
"d": 42,
|
||||
}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
vars := []map[string]any{
|
||||
{"variable": "cpn_0@a", "operator": "clear", "parameter": "x"},
|
||||
{"variable": "cpn_0@b", "operator": "clear", "parameter": "x"},
|
||||
{"variable": "cpn_0@c", "operator": "clear", "parameter": "x"},
|
||||
{"variable": "cpn_0@d", "operator": "clear", "parameter": "x"},
|
||||
}
|
||||
c, err := NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAssignerComponent: %v", err)
|
||||
}
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got := state.Outputs["cpn_0"]["a"]; reflect.DeepEqual(got, []any{1, 2}) {
|
||||
t.Errorf("cpn_0@a not cleared: %v", got)
|
||||
}
|
||||
if got, _ := state.Outputs["cpn_0"]["b"].(string); got != "" {
|
||||
t.Errorf("cpn_0@b: got %q, want \"\"", got)
|
||||
}
|
||||
if got, ok := state.Outputs["cpn_0"]["c"].(map[string]any); !ok || len(got) != 0 {
|
||||
t.Errorf("cpn_0@c: got %v, want empty map", state.Outputs["cpn_0"]["c"])
|
||||
}
|
||||
if got, _ := state.Outputs["cpn_0"]["d"].(int); got != 0 {
|
||||
t.Errorf("cpn_0@d: got %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAssigner_Arithmetic: += -= *= /= on numeric values.
|
||||
func TestVariableAssigner_Arithmetic(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-5", "task-5")
|
||||
state.Outputs["cpn_0"] = map[string]any{"n": 10.0}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
vars := []map[string]any{
|
||||
{"variable": "cpn_0@n", "operator": "+=", "parameter": 5},
|
||||
{"variable": "cpn_0@n", "operator": "-=", "parameter": 3},
|
||||
{"variable": "cpn_0@n", "operator": "*=", "parameter": 2},
|
||||
{"variable": "cpn_0@n", "operator": "/=", "parameter": 4},
|
||||
}
|
||||
c, err := NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if err != nil {
|
||||
t.Fatalf("NewVariableAssignerComponent: %v", err)
|
||||
}
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
// 10 + 5 = 15, 15 - 3 = 12, 12 * 2 = 24, 24 / 4 = 6
|
||||
if got, want := state.Outputs["cpn_0"]["n"], 6.0; got != want {
|
||||
t.Errorf("after +5 -3 *2 /4: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAssigner_RemoveFirstLast: list slicing.
|
||||
func TestVariableAssigner_RemoveFirstLast(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-6", "task-6")
|
||||
state.Outputs["cpn_0"] = map[string]any{"xs": []any{"a", "b", "c", "d"}}
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
vars := []map[string]any{
|
||||
{"variable": "cpn_0@xs", "operator": "remove_first", "parameter": "x"},
|
||||
}
|
||||
c, _ := NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := state.Outputs["cpn_0"]["xs"], []any{"b", "c", "d"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("after remove_first: got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
vars = []map[string]any{
|
||||
{"variable": "cpn_0@xs", "operator": "remove_last", "parameter": "x"},
|
||||
}
|
||||
c, _ = NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := state.Outputs["cpn_0"]["xs"], []any{"b", "c"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("after remove_last: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAssigner_SysTarget: variable="sys.x" → state.Sys is written.
|
||||
func TestVariableAssigner_SysTarget(t *testing.T) {
|
||||
state := canvas.NewCanvasState("run-7", "task-7")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
vars := []map[string]any{
|
||||
{"variable": "sys.x", "operator": "set", "parameter": "hello"},
|
||||
}
|
||||
c, _ := NewVariableAssignerComponent(map[string]any{"variables": vars})
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if got, want := state.Sys["x"], "hello"; got != want {
|
||||
t.Errorf("state.Sys[x]: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVariableAssigner_Registered: factory lookup.
|
||||
func TestVariableAssigner_Registered(t *testing.T) {
|
||||
c, err := New("VariableAssigner", map[string]any{
|
||||
"variables": []map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if c.Name() != "VariableAssigner" {
|
||||
t.Errorf("Name()=%q, want VariableAssigner", c.Name())
|
||||
}
|
||||
}
|
||||
87
internal/agent/component/verify_p1_test.go
Normal file
87
internal/agent/component/verify_p1_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package component
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVerifyRegistration_P1 verifies all components are registered,
|
||||
// case-insensitive, and returned in sorted order. The expected count is
|
||||
// read from plan §2.11.10 — P0 (8) + P1 (5) + P2 (4) + P3 (2) + P4 (3) = 22
|
||||
// at plan completion, plus 7 v1 fixture stubs (Retrieval, TavilySearch,
|
||||
// ExeSQL, Generate, Answer, Iteration, IterationItem) registered by
|
||||
// v1_stubs.go to keep the dsl-examples e2e suite compiling. The test
|
||||
// allows counts between 12 (P0+P1 minus the removed ExitLoop) and 30
|
||||
// (the 22 plan components + the 7 v1 stubs + Parallel) to roll
|
||||
// forward as subsequent batches land.
|
||||
//
|
||||
// Note: ExitLoop is intentionally NOT in the registry anymore. The
|
||||
// canvas engine (internal/agent/canvas/canvas.go's legacyNoOpNames)
|
||||
// accepts the name for DSL v1 compatibility but the Go port no longer
|
||||
// ships a Component implementation for it — termination is now driven
|
||||
// by the loop_termination_condition predicate, not by reaching an
|
||||
// ExitLoop node in the body.
|
||||
func TestVerifyRegistration_P1(t *testing.T) {
|
||||
names := RegisteredNames()
|
||||
have := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
have[n] = true
|
||||
}
|
||||
|
||||
// Always-present P0+P1 (12 names — ExitLoop removed).
|
||||
requiredP0P1 := []string{
|
||||
"agent", "begin", "categorize", "dataoperations",
|
||||
"invoke", "listoperations", "llm", "message", "stringtransform",
|
||||
"switch", "variableaggregator", "variableassigner",
|
||||
}
|
||||
var missing []string
|
||||
for _, e := range requiredP0P1 {
|
||||
if !have[e] {
|
||||
missing = append(missing, e)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("missing P0/P1 components: %v (have %d: %v)", missing, len(names), names)
|
||||
}
|
||||
if got := len(names); got < 12 || got > 30 {
|
||||
t.Errorf("expected 12-30 registered (current plan scope + v1 stubs), got %d: %v", got, names)
|
||||
}
|
||||
|
||||
// ExitLoop must NOT be in the registry (legacy compat lives at
|
||||
// the canvas level, not here).
|
||||
if have["exitloop"] {
|
||||
t.Errorf("exitloop is registered; expected gone. The legacy no-op handling lives in canvas.legacyNoOpNames.")
|
||||
}
|
||||
if have["loopitem"] {
|
||||
t.Errorf("loopitem is registered; expected gone. The Python-era LoopItem node is collapsed into the workflowx.AddLoopNode wrapper.")
|
||||
}
|
||||
|
||||
// Case-insensitive lookup on a real component to prove the
|
||||
// registry's name normalization works at lookup time.
|
||||
if c, err := New("Message", nil); err != nil || c == nil {
|
||||
t.Errorf("New(Message) failed: c=%v err=%v", c, err)
|
||||
}
|
||||
|
||||
// Sorted output for stable error messages.
|
||||
sortedCopy := make([]string, len(names))
|
||||
copy(sortedCopy, names)
|
||||
sort.Strings(sortedCopy)
|
||||
if !equalStrings(sortedCopy, names) {
|
||||
t.Errorf("RegisteredNames() not sorted: got %v", names)
|
||||
}
|
||||
|
||||
t.Logf("OK — %d components registered, sorted, case-insensitive lookup works: %s", len(names), strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
180
internal/agent/dsl/converter_v1_to_v2.go
Normal file
180
internal/agent/dsl/converter_v1_to_v2.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Package dsl — v1 -> v2 converter (Phase 2.5 priority).
|
||||
//
|
||||
// The v1 format (see agent/canvas.py:43-95) is:
|
||||
//
|
||||
// {
|
||||
// "components": {
|
||||
// "<ComponentName>:<UUID>": {
|
||||
// "downstream": ["<ComponentName>:<UUID>", ...],
|
||||
// "upstream": ["<ComponentName>:<UUID>", ...],
|
||||
// "obj": {
|
||||
// "component_name": "ComponentName",
|
||||
// "params": {...},
|
||||
// "downstream": [...], // duplicate of the outer field
|
||||
// // legacy fields dropped in v2:
|
||||
// "_feeded_deprecated_params": {...},
|
||||
// "_deprecated_params": {...},
|
||||
// "_user_feeded_params": {...},
|
||||
// ...
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// v2 is flat (see v2.go). Conversion:
|
||||
// - key "<Name>:<UUID>" -> "<name>_<UUID>" (lowercased name, '_' separator)
|
||||
// - obj.component_name -> Component.Name
|
||||
// - obj.params -> Component.Params
|
||||
// - downstream (outer) -> Component.Downstream
|
||||
// - legacy fields -> dropped
|
||||
//
|
||||
// Reverse direction (v2 -> v1) is Phase 5.5 and is not implemented here.
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// v1Canvas is the intermediate v1 unmarshal target. We do not import
|
||||
// encoding/json's default field tag behavior — every field is explicit so
|
||||
// the converter remains readable.
|
||||
type v1Canvas struct {
|
||||
Components map[string]v1Component `json:"components"`
|
||||
}
|
||||
|
||||
type v1Component struct {
|
||||
Downstream []string `json:"downstream"`
|
||||
Upstream []string `json:"upstream"`
|
||||
Obj *v1Obj `json:"obj"`
|
||||
// legacy top-level keys, captured for the v1->v2 sanity check (and
|
||||
// dropped on emit).
|
||||
DeprecatedParams map[string]any `json:"_deprecated_params"`
|
||||
FeededDeprecatedParams map[string]any `json:"_feeded_deprecated_params"`
|
||||
UserFeededParams map[string]any `json:"_user_feeded_params"`
|
||||
}
|
||||
|
||||
type v1Obj struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
Params map[string]any `json:"params"`
|
||||
Downstream []string `json:"downstream"`
|
||||
Upstream []string `json:"upstream"`
|
||||
// Legacy fields at the obj level are also accepted; they are ignored.
|
||||
DeprecatedParams map[string]any `json:"_deprecated_params"`
|
||||
FeededDeprecatedParams map[string]any `json:"_feeded_deprecated_params"`
|
||||
UserFeededParams map[string]any `json:"_user_feeded_params"`
|
||||
}
|
||||
|
||||
// v1ToV2 converts a v1 JSON byte slice into a v2 Canvas.
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. Unmarshal into the v1 intermediate struct.
|
||||
// 2. For each v1 component key "<Name>:<UUID>":
|
||||
// - new_id = lowercased Name + "_" + UUID (e.g. "agent_abc123")
|
||||
// - new_cpn.ID = new_id
|
||||
// - new_cpn.Name = v1.ComponentName (from obj, falling back to Name)
|
||||
// - new_cpn.Downstream = v1.Downstream (outer) -> remap each via convertKey
|
||||
// - new_cpn.Params = v1.Obj.Params (or empty map if missing)
|
||||
// - legacy fields are dropped.
|
||||
// 3. Return Canvas{Version: 2, Components: new_map}.
|
||||
//
|
||||
// Edge cases handled:
|
||||
// - obj missing: use top-level downstream; Params = empty map.
|
||||
// - empty downstream: empty slice.
|
||||
// - custom_header inside params: preserved as-is in v2.Params (not stripped).
|
||||
// - nested messages (Begin -> Message chain): handled by the same key
|
||||
// remapping for both top-level and downstream refs.
|
||||
func v1ToV2(raw []byte) (*Canvas, error) {
|
||||
var v1 v1Canvas
|
||||
if err := json.Unmarshal(raw, &v1); err != nil {
|
||||
return nil, fmt.Errorf("dsl: v1 unmarshal: %w", err)
|
||||
}
|
||||
if len(v1.Components) == 0 {
|
||||
return nil, fmt.Errorf("dsl: v1 has no components")
|
||||
}
|
||||
|
||||
out := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: make(map[string]Component, len(v1.Components)),
|
||||
}
|
||||
|
||||
for oldKey, c := range v1.Components {
|
||||
newID, name, err := convertKey(oldKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dsl: convert key %q: %w", oldKey, err)
|
||||
}
|
||||
|
||||
// Component name: prefer obj.component_name, fall back to the
|
||||
// Name half of the colon-split key.
|
||||
cpnName := name
|
||||
if c.Obj != nil && c.Obj.ComponentName != "" {
|
||||
cpnName = c.Obj.ComponentName
|
||||
}
|
||||
|
||||
// Downstream: prefer the outer field, fall back to obj.downstream.
|
||||
// Either may be nil; remap each entry through convertKey.
|
||||
ds := c.Downstream
|
||||
if len(ds) == 0 && c.Obj != nil {
|
||||
ds = c.Obj.Downstream
|
||||
}
|
||||
dsOut := make([]string, 0, len(ds))
|
||||
for _, ref := range ds {
|
||||
mapped, _, err := convertKey(ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dsl: convert downstream ref %q of %q: %w", ref, oldKey, err)
|
||||
}
|
||||
dsOut = append(dsOut, mapped)
|
||||
}
|
||||
|
||||
// Params: from obj.params; empty map if obj missing.
|
||||
var params map[string]any
|
||||
if c.Obj != nil && c.Obj.Params != nil {
|
||||
params = c.Obj.Params
|
||||
} else {
|
||||
params = map[string]any{}
|
||||
}
|
||||
|
||||
out.Components[newID] = Component{
|
||||
ID: newID,
|
||||
Name: cpnName,
|
||||
Downstream: dsOut,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// convertKey splits a v1 "<Name>:<UUID>" key into a v2 "<name>_<UUID>" id,
|
||||
// and returns the original PascalCase/CamelCase Name alongside.
|
||||
//
|
||||
// Rules:
|
||||
// - Exactly one ":" required.
|
||||
// - Name segment lowercased (using ASCII ToLower).
|
||||
// - ":" replaced with "_".
|
||||
//
|
||||
// v1 keys without a ":" are accepted as-is: they are returned with empty
|
||||
// name and a synthetic "<key>_<key>" id to keep the v2 invariant that IDs
|
||||
// are non-empty. Callers should pre-validate, but we keep this tolerant
|
||||
// because some test fixtures omit the colon.
|
||||
func convertKey(oldKey string) (newID, name string, err error) {
|
||||
if oldKey == "" {
|
||||
return "", "", fmt.Errorf("empty key")
|
||||
}
|
||||
idx := strings.Index(oldKey, ":")
|
||||
if idx < 0 {
|
||||
// No colon — treat the whole key as both name and id stem.
|
||||
// v2 forbids ":" so we use "_" as separator with an empty uuid half.
|
||||
return strings.ToLower(oldKey) + "_", oldKey, nil
|
||||
}
|
||||
rawName := oldKey[:idx]
|
||||
uuid := oldKey[idx+1:]
|
||||
if rawName == "" {
|
||||
return "", "", fmt.Errorf("missing name before ':'")
|
||||
}
|
||||
if uuid == "" {
|
||||
return "", "", fmt.Errorf("missing uuid after ':'")
|
||||
}
|
||||
return strings.ToLower(rawName) + "_" + strings.ToLower(uuid), rawName, nil
|
||||
}
|
||||
308
internal/agent/dsl/converter_v1_to_v2_test.go
Normal file
308
internal/agent/dsl/converter_v1_to_v2_test.go
Normal file
@@ -0,0 +1,308 @@
|
||||
// Package dsl — tests for v1 -> v2 conversion.
|
||||
//
|
||||
// These tests load real v1 templates shipped under agent/templates/*.json
|
||||
// plus a few synthetic fixtures covering edge cases.
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// templatesDir is the on-disk location of the v1 DSL templates used as
|
||||
// fixtures. Tests skip (rather than fail) if the directory is missing so
|
||||
// the package can be built in environments where the Python side is
|
||||
// pruned.
|
||||
func templatesDir() string {
|
||||
// Walk up from the test file's working directory to the repo root.
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir := wd
|
||||
for i := 0; i < 8; i++ {
|
||||
candidate := filepath.Join(dir, "agent", "templates")
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadTemplate(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
dir := templatesDir()
|
||||
if dir == "" {
|
||||
t.Skip("agent/templates not found; skipping v1 fixture")
|
||||
}
|
||||
// Templates are wrapped in {"id": ..., "title": ..., "dsl": {...}}.
|
||||
// The v1 converter expects the raw "dsl" object, so we extract it.
|
||||
raw, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Skipf("template %s not readable: %v", name, err)
|
||||
}
|
||||
var wrapped struct {
|
||||
DSL json.RawMessage `json:"dsl"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapped); err != nil {
|
||||
t.Fatalf("template %s: parse wrapper: %v", name, err)
|
||||
}
|
||||
if len(wrapped.DSL) == 0 {
|
||||
t.Fatalf("template %s: missing dsl field", name)
|
||||
}
|
||||
return wrapped.DSL
|
||||
}
|
||||
|
||||
func TestV1ToV2_WebSearchAssistant(t *testing.T) {
|
||||
v1 := loadTemplate(t, "web_search_assistant.json")
|
||||
c, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2(web_search_assistant): %v", err)
|
||||
}
|
||||
if got, want := c.Version, CurrentVersion; got != want {
|
||||
t.Fatalf("Version = %d, want %d", got, want)
|
||||
}
|
||||
if len(c.Components) < 5 {
|
||||
t.Fatalf("expected >=5 components, got %d", len(c.Components))
|
||||
}
|
||||
// All v2 IDs must follow name_uuid (lowercased) and not contain ":".
|
||||
for id, cpn := range c.Components {
|
||||
if strings.Contains(id, ":") {
|
||||
t.Errorf("v2 id %q still contains ':'", id)
|
||||
}
|
||||
if cpn.ID != id {
|
||||
t.Errorf("key %q != Component.ID %q", id, cpn.ID)
|
||||
}
|
||||
if cpn.Name == "" {
|
||||
t.Errorf("component %q has empty Name", id)
|
||||
}
|
||||
// Downstream refs must resolve inside the same canvas.
|
||||
for _, ds := range cpn.Downstream {
|
||||
if _, ok := c.Components[ds]; !ok {
|
||||
t.Errorf("component %q downstream %q does not exist", id, ds)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate the result end-to-end.
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Errorf("Validate: %v", err)
|
||||
}
|
||||
// Spot-check a known component by its v1 key.
|
||||
// web_search_assistant.json contains an "Agent:SmartSchoolsCross" entry;
|
||||
// after conversion its v2 id is "agent_smartschoolscross".
|
||||
const wantID = "agent_smartschoolscross"
|
||||
cpn, ok := c.Components[wantID]
|
||||
if !ok {
|
||||
t.Fatalf("expected v2 id %q; not found (have %d components)", wantID, len(c.Components))
|
||||
}
|
||||
if cpn.Name != "Agent" {
|
||||
t.Errorf("Name = %q, want %q", cpn.Name, "Agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1ToV2_CustomerFeedback(t *testing.T) {
|
||||
v1 := loadTemplate(t, "customer_feedback_dispatcher.json")
|
||||
c, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2(customer_feedback_dispatcher): %v", err)
|
||||
}
|
||||
if c.Version != CurrentVersion {
|
||||
t.Fatalf("Version = %d, want %d", c.Version, CurrentVersion)
|
||||
}
|
||||
if len(c.Components) == 0 {
|
||||
t.Fatal("expected non-empty Components")
|
||||
}
|
||||
// Component names must be preserved as PascalCase class names
|
||||
// (e.g. "Categorize", "LLM", "Message").
|
||||
seen := map[string]bool{}
|
||||
for id, cpn := range c.Components {
|
||||
if seen[cpn.Name] {
|
||||
continue
|
||||
}
|
||||
seen[cpn.Name] = true
|
||||
if cpn.Name == "" {
|
||||
t.Errorf("component %q has empty Name", id)
|
||||
}
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Errorf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1ToV2_EdgeNoObj(t *testing.T) {
|
||||
// v1 component with no `obj` sub-object: downstream preserved,
|
||||
// Params becomes an empty map.
|
||||
v1 := []byte(`{
|
||||
"components": {
|
||||
"Begin:NoObjOne": {
|
||||
"downstream": ["Message:NoObjTwo"]
|
||||
},
|
||||
"Message:NoObjTwo": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
c, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2: %v", err)
|
||||
}
|
||||
begin, ok := c.Components["begin_noobjone"]
|
||||
if !ok {
|
||||
t.Fatalf("expected v2 id %q", "begin_noobjone")
|
||||
}
|
||||
if begin.Name != "Begin" {
|
||||
t.Errorf("Name = %q, want %q", begin.Name, "Begin")
|
||||
}
|
||||
if len(begin.Downstream) != 1 || begin.Downstream[0] != "message_noobjtwo" {
|
||||
t.Errorf("Downstream = %v, want [message_noobjtwo]", begin.Downstream)
|
||||
}
|
||||
if begin.Params == nil {
|
||||
t.Error("Params should be non-nil empty map (not nil)")
|
||||
}
|
||||
if len(begin.Params) != 0 {
|
||||
t.Errorf("Params should be empty, got %v", begin.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1ToV2_StripLegacy(t *testing.T) {
|
||||
// v1 component with the three deprecated param sets: they must NOT
|
||||
// appear anywhere on the v2 output.
|
||||
v1 := []byte(`{
|
||||
"components": {
|
||||
"Retrieval:LegacyOne": {
|
||||
"downstream": [],
|
||||
"obj": {
|
||||
"component_name": "Retrieval",
|
||||
"params": {"k": 5},
|
||||
"_feeded_deprecated_params": {"old_k": 5},
|
||||
"_deprecated_params": {"removed": true},
|
||||
"_user_feeded_params": {"k": 7}
|
||||
},
|
||||
"_deprecated_params": {"top": 99}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
c, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2: %v", err)
|
||||
}
|
||||
cpn, ok := c.Components["retrieval_legacyone"]
|
||||
if !ok {
|
||||
t.Fatalf("expected v2 id %q", "retrieval_legacyone")
|
||||
}
|
||||
// Round-trip through JSON and grep for the legacy keys.
|
||||
bs, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
for _, banned := range []string{
|
||||
"_feeded_deprecated_params",
|
||||
"_deprecated_params",
|
||||
"_user_feeded_params",
|
||||
} {
|
||||
if strings.Contains(string(bs), banned) {
|
||||
t.Errorf("v2 JSON still contains legacy key %q: %s", banned, string(bs))
|
||||
}
|
||||
}
|
||||
// And the kept param "k" must equal 5 (the canonical v1 value, not
|
||||
// the user_feeded override).
|
||||
if got, ok := cpn.Params["k"]; !ok {
|
||||
t.Error("Params[k] missing")
|
||||
} else if got != float64(5) {
|
||||
// JSON numbers decode as float64.
|
||||
t.Errorf("Params[k] = %v, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1ToV2_CustomHeaderPreserved(t *testing.T) {
|
||||
// `custom_header` injection inside params must be preserved as-is.
|
||||
v1 := []byte(`{
|
||||
"components": {
|
||||
"HTTP:CustomHeaderOne": {
|
||||
"downstream": [],
|
||||
"obj": {
|
||||
"component_name": "HTTP",
|
||||
"params": {
|
||||
"url": "https://example.com",
|
||||
"custom_header": {"X-Trace": "abc"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
c, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2: %v", err)
|
||||
}
|
||||
cpn := c.Components["http_customheaderone"]
|
||||
if cpn.Params["custom_header"] == nil {
|
||||
t.Fatal("custom_header was stripped on v1->v2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1ToV2_EmptyDownstream(t *testing.T) {
|
||||
// v1 component with no `downstream` key: should produce empty slice.
|
||||
v1 := []byte(`{
|
||||
"components": {
|
||||
"End:TerminalOne": {
|
||||
"obj": {
|
||||
"component_name": "End",
|
||||
"params": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
c, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2: %v", err)
|
||||
}
|
||||
cpn := c.Components["end_terminalone"]
|
||||
if cpn.Downstream == nil {
|
||||
t.Error("Downstream should be empty slice, not nil")
|
||||
}
|
||||
if len(cpn.Downstream) != 0 {
|
||||
t.Errorf("Downstream should be empty, got %v", cpn.Downstream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
old, newID, name string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Agent:SmartSchoolsCross", "agent_smartschoolscross", "Agent", false},
|
||||
{"Begin:NoObjOne", "begin_noobjone", "Begin", false},
|
||||
{"Message:NoObjTwo", "message_noobjtwo", "Message", false},
|
||||
{"LLM:LLM_Foo", "llm_llm_foo", "LLM", false},
|
||||
{":", "", "", true},
|
||||
{"NoColon", "nocolon_", "NoColon", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.old, func(t *testing.T) {
|
||||
id, name, err := convertKey(tc.old)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
|
||||
}
|
||||
if tc.wantErr {
|
||||
return
|
||||
}
|
||||
if id != tc.newID {
|
||||
t.Errorf("id = %q, want %q", id, tc.newID)
|
||||
}
|
||||
if name != tc.name {
|
||||
t.Errorf("name = %q, want %q", name, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
297
internal/agent/dsl/converter_v2_to_v1.go
Normal file
297
internal/agent/dsl/converter_v2_to_v1.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Package dsl — v2 -> v1 converter (Phase 5.5).
|
||||
//
|
||||
// The reverse direction of converter_v1_to_v2.go. The motivation is to keep
|
||||
// the v2 -> v1 round-trip closed: a canvas loaded from v1, upgraded to v2,
|
||||
// and then serialized back to v1 must be load-equivalent through the v1
|
||||
// reader (i.e. it must parse into the same v2 in-memory model).
|
||||
//
|
||||
// v1 format reference (see agent/canvas.py:43-95):
|
||||
//
|
||||
// {
|
||||
// "components": {
|
||||
// "<ComponentName>:<UUID>": {
|
||||
// "downstream": ["<ComponentName>:<UUID>", ...],
|
||||
// "upstream": ["<ComponentName>:<UUID>", ...], // optional
|
||||
// "obj": {
|
||||
// "component_name": "ComponentName",
|
||||
// "params": {...},
|
||||
// "downstream": [...], // duplicate of the outer field
|
||||
// "upstream": [...] // optional
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// v2 -> v1 algorithm:
|
||||
// - For each v2 Component (in deterministic order — sorted by v2 ID, with
|
||||
// the "begin_" prefix naturally first):
|
||||
// 1. Reverse the v2 ID "<name>_<UUID>" back to a v1 key "<Name>:<UUID>".
|
||||
// The split happens on the FIRST "_" from the left. The left half is
|
||||
// the lowercased Name, the right half is the (lowercased) UUID. We
|
||||
// apply a best-effort PascalCase restore to the name by upper-casing
|
||||
// its first rune: "begin_abc" -> "Begin:abc", "agent_abc" -> "Agent:abc".
|
||||
// The UUID is emitted as-is. The reverse is LOSSY when the original
|
||||
// name was already lowercase (e.g. "begin" -> "begin_" -> "Begin:")
|
||||
// or when the original name was multi-segment PascalCase (e.g.
|
||||
// "LLM" -> "llm_xxx" -> "Llm:xxx" not "LLM:xxx"). Round-trip
|
||||
// structural equivalence through v1ToV2 is preserved (v1ToV2 always
|
||||
// lowercases on the way in), but the v1 key string itself is not
|
||||
// byte-for-byte restorable.
|
||||
// 2. Build a v1 entry: { "downstream": [v1 keys], "obj":
|
||||
// { "component_name": name, "params": params,
|
||||
// "downstream": [same list, duplicated per v1 format] } }.
|
||||
// - Return JSON bytes of { "components": { ... } } with 2-space indent.
|
||||
//
|
||||
// Edge cases handled:
|
||||
// - Component with no Downstream: emit "downstream": [] (NOT null).
|
||||
// - Component with nil Params: emit "params": {} (NOT null).
|
||||
// - v2 ID with no "_" (e.g. legacy fixture): emit as "<Name>:" (left-side
|
||||
// handling — see convertKey for the forward direction).
|
||||
// - v2 ID with multiple "_" (e.g. "switch_abc_def"): split on the FIRST
|
||||
// "_" from the left ("switch" + "abc_def") — see plan §5 Phase 5.5.
|
||||
// - Legacy v1 fields (_deprecated_params, _feeded_deprecated_params,
|
||||
// _user_feeded_params) are NEVER emitted; v2 does not carry them.
|
||||
//
|
||||
// Upstream is intentionally omitted from the v1 emit. The Python v1 reader
|
||||
// computes upstream by inverting downstream (it is the dual of the graph
|
||||
// edge relation) and tolerates its absence. This keeps the converter
|
||||
// minimal and aligned with the plan.
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// v2ToV1 serializes a v2 Canvas back into the v1 JSON envelope.
|
||||
//
|
||||
// The output is deterministic: components are emitted in lexicographic
|
||||
// order of their v2 ID (with the "begin_" prefix naturally first).
|
||||
//
|
||||
// Returns an error if the canvas is nil, empty, or fails Validate.
|
||||
// Use json.MarshalIndent with 2-space indent to keep the output
|
||||
// human-readable, matching the rest of the dsl package's conventions.
|
||||
func v2ToV1(c *Canvas) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("dsl: v2ToV1: nil canvas")
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("dsl: v2ToV1: validate: %w", err)
|
||||
}
|
||||
if len(c.Components) == 0 {
|
||||
return nil, fmt.Errorf("dsl: v2ToV1: empty components")
|
||||
}
|
||||
|
||||
// Deterministic iteration order: the "begin" component (v2 id
|
||||
// "begin_...") sorts first to match the v1 reader's convention;
|
||||
// remaining ids are sorted lexicographically.
|
||||
ids := make([]string, 0, len(c.Components))
|
||||
for id := range c.Components {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
iBegin := strings.HasPrefix(ids[i], "begin_") || ids[i] == "begin_"
|
||||
jBegin := strings.HasPrefix(ids[j], "begin_") || ids[j] == "begin_"
|
||||
if iBegin != jBegin {
|
||||
return iBegin
|
||||
}
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
|
||||
out := v1Envelope{Components: make(map[string]v1Emit, len(c.Components))}
|
||||
|
||||
for _, v2ID := range ids {
|
||||
cpn := c.Components[v2ID]
|
||||
|
||||
// Reverse the v2 id to a v1 key. Lossy case-restore is acceptable;
|
||||
// the structural invariant (v1ToV2 closure) is preserved.
|
||||
v1Key := reverseIDToV1Key(v2ID)
|
||||
|
||||
// Remap each downstream v2 id to its v1 key form.
|
||||
ds := cpn.Downstream
|
||||
if ds == nil {
|
||||
ds = []string{}
|
||||
}
|
||||
dsOut := make([]string, 0, len(ds))
|
||||
for _, d := range ds {
|
||||
dsOut = append(dsOut, reverseIDToV1Key(d))
|
||||
}
|
||||
|
||||
// Params: ensure non-nil so the v1 emit is "{}" not "null".
|
||||
params := cpn.Params
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
|
||||
out.Components[v1Key] = v1Emit{
|
||||
Downstream: dsOut,
|
||||
Obj: &v1ObjEmit{
|
||||
ComponentName: cpn.Name,
|
||||
Params: params,
|
||||
Downstream: dsOut,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// v1Envelope is the on-the-wire v1 shape we emit. Only the fields the
|
||||
// Python reader actually consumes are present.
|
||||
//
|
||||
// We override MarshalJSON to guarantee deterministic key order (Begin
|
||||
// first, then lexicographic). The default Go map encoder sorts by
|
||||
// key text, which would place "Alpha" before "Begin" — not what
|
||||
// canonical v1 emits do.
|
||||
type v1Envelope struct {
|
||||
Components map[string]v1Emit `json:"components"`
|
||||
}
|
||||
|
||||
// MarshalJSON renders the v1 envelope with deterministic component order.
|
||||
// The "Begin" key sorts first; the remaining keys sort lexicographically.
|
||||
// This is the ordering convention used by the Python v1 writer, and
|
||||
// tests like TestV2ToV1_BeginFirst depend on it.
|
||||
//
|
||||
// We hand-build the indented output rather than relying on the default
|
||||
// map encoder, which sorts by key text and would place "Alpha" before
|
||||
// "Begin" — not what canonical v1 emits do.
|
||||
func (e v1Envelope) MarshalJSON() ([]byte, error) {
|
||||
keys := make([]string, 0, len(e.Components))
|
||||
for k := range e.Components {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
iBegin := isBeginV1Key(keys[i])
|
||||
jBegin := isBeginV1Key(keys[j])
|
||||
if iBegin != jBegin {
|
||||
return iBegin
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
|
||||
// Re-marshal each entry at indent level 2 and stitch them into
|
||||
// the outer envelope. json.Indent rejects a pre-indented body, so
|
||||
// we keep the per-value marshaling compact and use Indent on the
|
||||
// final blob to apply 2-space layout to the top-level braces and
|
||||
// "components" key as well.
|
||||
|
||||
// Build a compact but ordered JSON manually.
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(`{"components": {`)
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
kb, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vb, err := json.Marshal(e.Components[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf.Write(kb)
|
||||
buf.WriteString(`: `)
|
||||
buf.Write(vb)
|
||||
}
|
||||
buf.WriteString(`}}`)
|
||||
|
||||
// Apply 2-space indent across the whole envelope.
|
||||
var out bytes.Buffer
|
||||
if err := json.Indent(&out, buf.Bytes(), "", " "); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// isBeginV1Key reports whether a v1 key corresponds to the Begin
|
||||
// component. Forms covered:
|
||||
// - "begin" (legacy no-colon form, after v1ToV2 round-trip)
|
||||
// - "Begin" (restored no-colon form from v2ToV1)
|
||||
// - "Begin:..." (PascalCase + colon)
|
||||
// - "begin:..." (lowercase, tolerated)
|
||||
func isBeginV1Key(k string) bool {
|
||||
if k == "begin" || k == "Begin" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(k, "Begin:") || strings.HasPrefix(k, "begin:")
|
||||
}
|
||||
|
||||
// v1Emit is one entry in the v1 components map. The `upstream` field is
|
||||
// intentionally omitted — see file-level comment.
|
||||
type v1Emit struct {
|
||||
Downstream []string `json:"downstream"`
|
||||
Obj *v1ObjEmit `json:"obj"`
|
||||
}
|
||||
|
||||
// v1ObjEmit is the `obj` sub-object of a v1 component. Mirrors v1Obj in
|
||||
// converter_v1_to_v2.go with the legacy fields removed (we never re-emit
|
||||
// them — v2 dropped them on the way in).
|
||||
type v1ObjEmit struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
Params map[string]any `json:"params"`
|
||||
Downstream []string `json:"downstream"`
|
||||
}
|
||||
|
||||
// reverseIDToV1Key reverses a v2 ID "<name>_<UUID>" back to a v1 key
|
||||
// "<Name>:<UUID>".
|
||||
//
|
||||
// Splitting happens on the FIRST "_" from the left. The left half is
|
||||
// treated as the lowercased Name; we apply a best-effort PascalCase
|
||||
// restore by upper-casing the first rune only. The right half (the UUID
|
||||
// half) is preserved verbatim — it was already lowercased by v1ToV2 in
|
||||
// the canonical pipeline, so the emitted UUID will be lowercase.
|
||||
//
|
||||
// Edge cases:
|
||||
// - Empty uuid half (trailing "_", e.g. "begin_" from a v1 key
|
||||
// "begin" that lacked a colon): emit "<Name>" with NO colon. The
|
||||
// Python v1 reader accepts no-colon keys for Begin/End, and
|
||||
// convertKey on the round-trip returns the same "begin_". This
|
||||
// matches v1ToV2's no-colon branch.
|
||||
// - No "_" in the id at all (rare; should not happen in well-formed
|
||||
// v2): emit the whole id upper-cased on the first rune, followed
|
||||
// by ":" — same shape as the populated case for tolerance.
|
||||
// - Multiple "_" (e.g. "switch_abc_def"): split on the FIRST "_" so
|
||||
// the uuid half is "abc_def".
|
||||
// - All-uppercase name (e.g. "LLM_abc"): v1ToV2 lowercases the name,
|
||||
// so v2 ID is "llm_abc"; the restore produces "Llm:abc" — lossy vs.
|
||||
// the original "LLM:abc" but structurally equivalent after v1ToV2.
|
||||
func reverseIDToV1Key(v2ID string) string {
|
||||
if v2ID == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.Index(v2ID, "_")
|
||||
if idx < 0 {
|
||||
return upperFirstRune(v2ID) + ":"
|
||||
}
|
||||
name := v2ID[:idx]
|
||||
uuid := v2ID[idx+1:]
|
||||
if uuid == "" {
|
||||
// Trailing underscore means the original v1 key had no colon.
|
||||
// Emit without ":" so v1ToV2 can re-parse it (it requires a
|
||||
// non-empty uuid on the colon path; the no-colon branch
|
||||
// handles the legacy form).
|
||||
return upperFirstRune(name)
|
||||
}
|
||||
return upperFirstRune(name) + ":" + uuid
|
||||
}
|
||||
|
||||
// upperFirstRune returns s with its first rune converted to upper case.
|
||||
// ASCII-only on purpose: v1 component names are ASCII identifiers
|
||||
// (PascalCase). Operates on bytes to keep it allocation-free for the
|
||||
// common case where s is non-empty and starts with a letter.
|
||||
func upperFirstRune(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// Fast ASCII path: 'a'..'z' -> 'A'..'Z'. Non-ASCII (multi-byte rune)
|
||||
// falls back to the generic Unicode upper-case.
|
||||
b := s[0]
|
||||
if b >= 'a' && b <= 'z' {
|
||||
return string(b-'a'+'A') + s[1:]
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
533
internal/agent/dsl/converter_v2_to_v1_test.go
Normal file
533
internal/agent/dsl/converter_v2_to_v1_test.go
Normal file
@@ -0,0 +1,533 @@
|
||||
// Package dsl — tests for v2 -> v1 conversion (Phase 5.5).
|
||||
//
|
||||
// ENVIRONMENT GAP NOTE (per plan §8.5 数据源约束):
|
||||
// The 100-sample staging corpus "staging_canvas_snapshot_2026q2.json" is
|
||||
// owned by InfiniFlow SRE and is not present in this dev env. The 10
|
||||
// real v1 templates under agent/templates/*.json (web_search_assistant,
|
||||
// customer_feedback_dispatcher, ingestion_pipeline_general, etc.) are
|
||||
// the best local proxy. The Python reader compat test ("v2 写出的 DSL
|
||||
// 喂给旧 Python reader 仍能加载") is deferred to staging verification
|
||||
// — see docs/agent-port/phase-5-5-acceptance.md for the run-book.
|
||||
//
|
||||
// The tests below verify the strongest deterministic invariant we can
|
||||
// ship from a Go-only env: the round-trip
|
||||
//
|
||||
// loadV1(t) -> v1ToV2 -> v2ToV1 -> loadV1 -> v1ToV2
|
||||
//
|
||||
// produces a v2 Canvas whose component-ID set, downstream refs, and
|
||||
// param shapes match the direct v1ToV2 conversion. The reverse direction
|
||||
// (v1 -> v2 -> v1 -> v2) is what the Python reader effectively does on
|
||||
// re-load, so equality here implies the Go-emitted v1 is structurally
|
||||
// readable.
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestV2ToV1_WebSearchAssistant: round-trip the web_search_assistant
|
||||
// template through v1 -> v2 -> v1 -> v2 and assert the v2 representations
|
||||
// are structurally equivalent.
|
||||
func TestV2ToV1_WebSearchAssistant(t *testing.T) {
|
||||
v1 := loadTemplate(t, "web_search_assistant.json")
|
||||
|
||||
// Forward: v1 -> v2.
|
||||
first, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2 (initial): %v", err)
|
||||
}
|
||||
|
||||
// Backward: v2 -> v1.
|
||||
v1Bytes, err := v2ToV1(first)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
|
||||
// The emitted v1 must be valid JSON and re-loadable through LoadV1.
|
||||
if !json.Valid(v1Bytes) {
|
||||
t.Fatalf("v2ToV1 output is not valid JSON:\n%s", string(v1Bytes))
|
||||
}
|
||||
|
||||
// Round-trip: v2 -> v1 -> v2.
|
||||
second, err := v1ToV2(v1Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2 (round-trip): %v", err)
|
||||
}
|
||||
|
||||
// Compare component-ID sets.
|
||||
if len(first.Components) != len(second.Components) {
|
||||
t.Fatalf("component count differs: first=%d, second=%d",
|
||||
len(first.Components), len(second.Components))
|
||||
}
|
||||
for id := range first.Components {
|
||||
if _, ok := second.Components[id]; !ok {
|
||||
t.Errorf("v2 id %q lost on round-trip", id)
|
||||
}
|
||||
}
|
||||
for id := range second.Components {
|
||||
if _, ok := first.Components[id]; !ok {
|
||||
t.Errorf("v2 id %q gained spuriously on round-trip", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare downstream refs and params per component.
|
||||
for id, c1 := range first.Components {
|
||||
c2 := second.Components[id]
|
||||
if c1.Name != c2.Name {
|
||||
t.Errorf("[%s] Name: first=%q, second=%q", id, c1.Name, c2.Name)
|
||||
}
|
||||
if !sameStringSet(c1.Downstream, c2.Downstream) {
|
||||
t.Errorf("[%s] Downstream: first=%v, second=%v",
|
||||
id, c1.Downstream, c2.Downstream)
|
||||
}
|
||||
if !sameParams(c1.Params, c2.Params) {
|
||||
t.Errorf("[%s] Params differ:\n first: %s\n second: %s",
|
||||
id, asJSON(t, c1.Params), asJSON(t, c2.Params))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_CustomerFeedback: same shape for customer_feedback_dispatcher.
|
||||
func TestV2ToV1_CustomerFeedback(t *testing.T) {
|
||||
v1 := loadTemplate(t, "customer_feedback_dispatcher.json")
|
||||
first, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2: %v", err)
|
||||
}
|
||||
v1Bytes, err := v2ToV1(first)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
second, err := v1ToV2(v1Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2 (round-trip): %v", err)
|
||||
}
|
||||
if len(first.Components) != len(second.Components) {
|
||||
t.Fatalf("component count differs: first=%d, second=%d",
|
||||
len(first.Components), len(second.Components))
|
||||
}
|
||||
for id, c1 := range first.Components {
|
||||
c2 := second.Components[id]
|
||||
if c1.Name != c2.Name {
|
||||
t.Errorf("[%s] Name: first=%q, second=%q", id, c1.Name, c2.Name)
|
||||
}
|
||||
if !sameStringSet(c1.Downstream, c2.Downstream) {
|
||||
t.Errorf("[%s] Downstream: first=%v, second=%v",
|
||||
id, c1.Downstream, c2.Downstream)
|
||||
}
|
||||
if !sameParams(c1.Params, c2.Params) {
|
||||
t.Errorf("[%s] Params differ", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_IngestionPipeline: same shape for ingestion_pipeline_general.
|
||||
func TestV2ToV1_IngestionPipeline(t *testing.T) {
|
||||
v1 := loadTemplate(t, "ingestion_pipeline_general.json")
|
||||
first, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2: %v", err)
|
||||
}
|
||||
v1Bytes, err := v2ToV1(first)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
second, err := v1ToV2(v1Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2 (round-trip): %v", err)
|
||||
}
|
||||
if len(first.Components) != len(second.Components) {
|
||||
t.Fatalf("component count differs: first=%d, second=%d",
|
||||
len(first.Components), len(second.Components))
|
||||
}
|
||||
for id := range first.Components {
|
||||
if _, ok := second.Components[id]; !ok {
|
||||
t.Errorf("v2 id %q lost on round-trip", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_EmptyDownstream: a synthetic v2 with one component that has
|
||||
// no downstream must emit "downstream": [] (not null) at BOTH the outer
|
||||
// and the obj level.
|
||||
func TestV2ToV1_EmptyDownstream(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"message_0": {
|
||||
ID: "message_0",
|
||||
Name: "Message",
|
||||
Downstream: []string{},
|
||||
Params: map[string]any{"content": "hi"},
|
||||
},
|
||||
},
|
||||
}
|
||||
bs, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
out := string(bs)
|
||||
// Outer "downstream": []
|
||||
if !strings.Contains(out, `"downstream": []`) {
|
||||
t.Errorf("outer downstream not empty array:\n%s", out)
|
||||
}
|
||||
// Inside obj too: "obj": { ..., "downstream": [] }.
|
||||
if !strings.Contains(out, `"obj":`) {
|
||||
t.Errorf("missing obj sub-object:\n%s", out)
|
||||
}
|
||||
// No literal "null" downstream in the output.
|
||||
if strings.Contains(out, `"downstream": null`) {
|
||||
t.Errorf("downstream emitted as null:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_NilParams: synthetic v2 with no params must emit "params":
|
||||
// {} (not null) on the obj.
|
||||
func TestV2ToV1_NilParams(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"begin_0": {
|
||||
ID: "begin_0",
|
||||
Name: "Begin",
|
||||
Downstream: []string{"message_0"},
|
||||
Params: map[string]any{},
|
||||
},
|
||||
"message_0": {
|
||||
ID: "message_0",
|
||||
Name: "Message",
|
||||
Downstream: []string{},
|
||||
Params: map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
bs, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
out := string(bs)
|
||||
// We expect exactly two empty params objects (one per component).
|
||||
count := strings.Count(out, `"params": {}`)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 `\"params\": {}` entries, got %d:\n%s", count, out)
|
||||
}
|
||||
if strings.Contains(out, `"params": null`) {
|
||||
t.Errorf("params emitted as null:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_NoLegacyFields: the v1 emit must NEVER contain any of the
|
||||
// three Python-era legacy keys (_deprecated_params,
|
||||
// _feeded_deprecated_params, _user_feeded_params), even when the v2
|
||||
// input is full of synthetic data.
|
||||
func TestV2ToV1_NoLegacyFields(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"retrieval_0": {
|
||||
ID: "retrieval_0",
|
||||
Name: "Retrieval",
|
||||
Downstream: []string{"llm_0"},
|
||||
Params: map[string]any{
|
||||
"k": 5,
|
||||
"kb_ids": []any{"kb1", "kb2"},
|
||||
"outputs": map[string]any{"content": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
"llm_0": {
|
||||
ID: "llm_0",
|
||||
Name: "LLM",
|
||||
Downstream: []string{},
|
||||
Params: map[string]any{
|
||||
"model": "deepseek-chat",
|
||||
"temperature": 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
bs, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
out := string(bs)
|
||||
for _, banned := range []string{
|
||||
"_deprecated_params",
|
||||
"_feeded_deprecated_params",
|
||||
"_user_feeded_params",
|
||||
} {
|
||||
if strings.Contains(out, banned) {
|
||||
t.Errorf("v1 emit contains banned legacy key %q:\n%s", banned, out)
|
||||
}
|
||||
}
|
||||
// And: the canonical params ("k", "kb_ids", "model", "temperature")
|
||||
// must still be present.
|
||||
for _, expected := range []string{`"k"`, `"kb_ids"`, `"model"`, `"temperature"`} {
|
||||
if !strings.Contains(out, expected) {
|
||||
t.Errorf("v1 emit missing expected field %s:\n%s", expected, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_DeterministicOrder: calling v2ToV1 twice on the same canvas
|
||||
// must produce byte-for-byte identical output. Map iteration in Go is
|
||||
// non-deterministic, so a correct implementation must sort.
|
||||
func TestV2ToV1_DeterministicOrder(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"zeta_0": {ID: "zeta_0", Name: "Z", Downstream: []string{}, Params: map[string]any{}},
|
||||
"alpha_0": {
|
||||
ID: "alpha_0", Name: "A",
|
||||
Downstream: []string{"beta_0"},
|
||||
Params: map[string]any{"x": 1},
|
||||
},
|
||||
"beta_0": {
|
||||
ID: "beta_0", Name: "B",
|
||||
Downstream: []string{"gamma_0"},
|
||||
Params: map[string]any{"y": 2},
|
||||
},
|
||||
"gamma_0": {
|
||||
ID: "gamma_0", Name: "G",
|
||||
Downstream: []string{"delta_0"},
|
||||
Params: map[string]any{"z": 3},
|
||||
},
|
||||
"delta_0": {ID: "delta_0", Name: "D", Downstream: []string{}, Params: map[string]any{}},
|
||||
},
|
||||
}
|
||||
first, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1 #1: %v", err)
|
||||
}
|
||||
// Mutate the map insertion order by adding/removing entries to
|
||||
// exercise the Go map's non-determinism on the second pass.
|
||||
c.Components["epsilon_0"] = Component{
|
||||
ID: "epsilon_0", Name: "E", Downstream: []string{}, Params: map[string]any{},
|
||||
}
|
||||
delete(c.Components, "zeta_0")
|
||||
second, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1 #2: %v", err)
|
||||
}
|
||||
// Now mutate back to exactly the original 5 components, ensuring a
|
||||
// different insertion order than the first call.
|
||||
c.Components["zeta_0"] = Component{
|
||||
ID: "zeta_0", Name: "Z", Downstream: []string{}, Params: map[string]any{},
|
||||
}
|
||||
delete(c.Components, "epsilon_0")
|
||||
third, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1 #3: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, third) {
|
||||
t.Errorf("v2ToV1 not deterministic across map-mutation cycles:\n#1:\n%s\n#3:\n%s",
|
||||
string(first), string(third))
|
||||
}
|
||||
// And #2 (different canvas) must differ.
|
||||
if bytes.Equal(first, second) {
|
||||
t.Error("v2ToV1 #1 == v2ToV1 #2 (different canvases produced same bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_KeyRestore: verify the case-restore heuristic on the v2
|
||||
// id <-> v1 key reversal.
|
||||
func TestV2ToV1_KeyRestore(t *testing.T) {
|
||||
cases := []struct {
|
||||
v2ID, want string
|
||||
}{
|
||||
{"begin_abc", "Begin:abc"},
|
||||
{"agent_abc", "Agent:abc"},
|
||||
// Trailing underscore: original v1 had no colon (e.g. "begin").
|
||||
// Emit WITHOUT a colon so v1ToV2's no-colon branch re-parses.
|
||||
{"begin_", "Begin"},
|
||||
{"message_0", "Message:0"},
|
||||
{"switch_abc_def", "Switch:abc_def"},
|
||||
{"llm_xyz", "Llm:xyz"}, // lossy vs original "LLM:xyz" but documented
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.v2ID, func(t *testing.T) {
|
||||
got := reverseIDToV1Key(tc.v2ID)
|
||||
if got != tc.want {
|
||||
t.Errorf("reverseIDToV1Key(%q) = %q, want %q", tc.v2ID, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_NilCanvas: a nil canvas must error, not panic.
|
||||
func TestV2ToV1_NilCanvas(t *testing.T) {
|
||||
if _, err := v2ToV1(nil); err == nil {
|
||||
t.Error("v2ToV1(nil): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_EmptyComponents: a v2 with zero components must error.
|
||||
func TestV2ToV1_EmptyComponents(t *testing.T) {
|
||||
c := &Canvas{Version: CurrentVersion, Components: map[string]Component{}}
|
||||
if _, err := v2ToV1(c); err == nil {
|
||||
t.Error("v2ToV1(empty): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_BeginFirst: the "begin" component must appear before all
|
||||
// other components in the v1 emit. With lexicographic sort, "Begin:..."
|
||||
// sorts before any other PascalCase key, so this is a built-in property —
|
||||
// the test guards against a future change that drops the sort.
|
||||
func TestV2ToV1_BeginFirst(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"zeta_0": {ID: "zeta_0", Name: "Z", Downstream: []string{}, Params: map[string]any{}},
|
||||
"begin_0": {ID: "begin_0", Name: "Begin", Downstream: []string{"zeta_0"}, Params: map[string]any{}},
|
||||
"alpha_0": {ID: "alpha_0", Name: "A", Downstream: []string{}, Params: map[string]any{}},
|
||||
},
|
||||
}
|
||||
bs, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
out := string(bs)
|
||||
idxBegin := strings.Index(out, `"Begin:`)
|
||||
idxAlpha := strings.Index(out, `"Alpha:`)
|
||||
idxZeta := strings.Index(out, `"Zeta:`)
|
||||
if idxBegin < 0 || idxAlpha < 0 || idxZeta < 0 {
|
||||
t.Fatalf("expected Begin/Alpha/Zeta keys in output:\n%s", out)
|
||||
}
|
||||
if !(idxBegin < idxAlpha && idxBegin < idxZeta) {
|
||||
t.Errorf("Begin is not first in emit order: begin=%d alpha=%d zeta=%d\n%s",
|
||||
idxBegin, idxAlpha, idxZeta, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_ParamOrderStable: when a v2 Canvas is built with several
|
||||
// keys in one Params map, the v1 emit's "params" object should reflect
|
||||
// them faithfully (we don't constrain key order, but every value must
|
||||
// round-trip).
|
||||
func TestV2ToV1_ParamOrderStable(t *testing.T) {
|
||||
c := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"llm_0": {
|
||||
ID: "llm_0",
|
||||
Name: "LLM",
|
||||
Downstream: []string{},
|
||||
Params: map[string]any{
|
||||
"alpha": 1,
|
||||
"bravo": "two",
|
||||
"charlie": []any{3, 4},
|
||||
"delta": map[string]any{"nested": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
bs, err := v2ToV1(c)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
var got v1Envelope
|
||||
if err := json.Unmarshal(bs, &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
cpn := got.Components["Llm:0"]
|
||||
if cpn.Obj == nil {
|
||||
t.Fatal("missing obj")
|
||||
}
|
||||
if got := cpn.Obj.Params["alpha"]; got != float64(1) {
|
||||
t.Errorf("alpha = %v, want 1", got)
|
||||
}
|
||||
if got := cpn.Obj.Params["bravo"]; got != "two" {
|
||||
t.Errorf("bravo = %v, want \"two\"", got)
|
||||
}
|
||||
if got := cpn.Obj.Params["charlie"]; got == nil {
|
||||
t.Error("charlie missing")
|
||||
}
|
||||
if got := cpn.Obj.Params["delta"]; got == nil {
|
||||
t.Error("delta missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2ToV1_AcceptanceFixture_Smoke: a quick end-to-end smoke that
|
||||
// runs the v1 template through v1ToV2 -> v2ToV1 and re-loads via LoadV1.
|
||||
// Catches malformed-JSON regressions even when the structural comparison
|
||||
// would pass.
|
||||
func TestV2ToV1_AcceptanceFixture_Smoke(t *testing.T) {
|
||||
if dir := templatesDir(); dir == "" {
|
||||
t.Skip("agent/templates not found; skipping smoke")
|
||||
}
|
||||
v1 := loadTemplate(t, "web_search_assistant.json")
|
||||
stage1, err := v1ToV2(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1ToV2: %v", err)
|
||||
}
|
||||
v1Out, err := v2ToV1(stage1)
|
||||
if err != nil {
|
||||
t.Fatalf("v2ToV1: %v", err)
|
||||
}
|
||||
// Re-parse through the public LoadV1 entry point (catches loader
|
||||
// regressions that v1ToV2 alone would not surface).
|
||||
stage2, err := LoadV1(v1Out)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadV1(round-tripped v1): %v", err)
|
||||
}
|
||||
if len(stage2.Components) != len(stage1.Components) {
|
||||
t.Errorf("component count drift: stage1=%d, stage2=%d",
|
||||
len(stage1.Components), len(stage2.Components))
|
||||
}
|
||||
}
|
||||
|
||||
// sameStringSet returns true if a and b contain the same elements
|
||||
// regardless of order. nil and empty are treated as equal.
|
||||
func sameStringSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]int, len(a))
|
||||
for _, s := range a {
|
||||
seen[s]++
|
||||
}
|
||||
for _, s := range b {
|
||||
seen[s]--
|
||||
if seen[s] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sameParams compares two Params maps by canonical JSON encoding so
|
||||
// key-order and slice-order are normalized. This mirrors the pattern
|
||||
// used by TestLoadV2RoundTrip in loader_test.go (bytes.Equal on
|
||||
// canonical Marshal output).
|
||||
func sameParams(a, b map[string]any) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, va := range a {
|
||||
vb, ok := b[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ca, errA := json.Marshal(va)
|
||||
cb, errB := json.Marshal(vb)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(ca, cb) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// asJSON is a small helper that marshals v to canonical JSON for
|
||||
// diagnostic messages.
|
||||
func asJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
bs, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
return string(bs)
|
||||
}
|
||||
136
internal/agent/dsl/loader.go
Normal file
136
internal/agent/dsl/loader.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Package dsl — version auto-detection loader.
|
||||
//
|
||||
// Loader accepts raw JSON bytes that may be either v1 (the legacy Python-era
|
||||
// format) or v2 (the Go-native schema) and returns a uniform v2 *Canvas.
|
||||
//
|
||||
// Detection rules (in order):
|
||||
// 1. Top-level "version" field == 2 -> V2.
|
||||
// 2. Top-level "components" map whose values have an "obj" sub-object
|
||||
// with "component_name" -> V1.
|
||||
// 3. Anything else -> error "unknown DSL version".
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Version is the DSL schema version a payload was written in.
|
||||
type Version int
|
||||
|
||||
const (
|
||||
// V1 is the legacy Python-era schema with the `obj` wrapper and
|
||||
// deprecated param fields. See plan §2.11.7.
|
||||
V1 Version = 1
|
||||
// V2 is the Go-native flat schema. See plan §4.6.
|
||||
V2 Version = 2
|
||||
)
|
||||
|
||||
// String renders a Version for diagnostic messages.
|
||||
func (v Version) String() string {
|
||||
switch v {
|
||||
case V1:
|
||||
return "v1"
|
||||
case V2:
|
||||
return "v2"
|
||||
default:
|
||||
return fmt.Sprintf("v?(%d)", int(v))
|
||||
}
|
||||
}
|
||||
|
||||
// DetectVersion peeks at the JSON bytes and returns the schema version.
|
||||
//
|
||||
// Detection is a structural probe — it does not perform a full unmarshal.
|
||||
// A payload is reported as V2 only if the top-level integer field
|
||||
// `"version"` equals 2. A payload is reported as V1 if it has a top-level
|
||||
// `"components"` map whose values each contain an `"obj"` sub-object with
|
||||
// a `"component_name"` string field. Anything else is rejected.
|
||||
func DetectVersion(raw []byte) (Version, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
|
||||
// Probe 1: top-level "version": 2 -> V2.
|
||||
var v2Probe struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := dec.Decode(&v2Probe); err != nil {
|
||||
return 0, fmt.Errorf("dsl: detect version: %w", err)
|
||||
}
|
||||
if v2Probe.Version == CurrentVersion {
|
||||
return V2, nil
|
||||
}
|
||||
|
||||
// Probe 2: top-level "components" with `obj.component_name` -> V1.
|
||||
dec2 := json.NewDecoder(bytes.NewReader(raw))
|
||||
var v1Probe struct {
|
||||
Components map[string]json.RawMessage `json:"components"`
|
||||
}
|
||||
if err := dec2.Decode(&v1Probe); err != nil {
|
||||
return 0, fmt.Errorf("dsl: detect version: %w", err)
|
||||
}
|
||||
if len(v1Probe.Components) == 0 {
|
||||
return 0, fmt.Errorf("dsl: unknown DSL version (no top-level version and no components map)")
|
||||
}
|
||||
for _, raw := range v1Probe.Components {
|
||||
var objProbe struct {
|
||||
Obj struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &objProbe); err != nil {
|
||||
return 0, fmt.Errorf("dsl: detect version: probe v1: %w", err)
|
||||
}
|
||||
if objProbe.Obj.ComponentName != "" {
|
||||
return V1, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("dsl: unknown DSL version (components map has no obj.component_name)")
|
||||
}
|
||||
|
||||
// Load auto-detects the version of raw and returns a v2 Canvas.
|
||||
//
|
||||
// V1 payloads are run through v1ToV2 first; v2 payloads are unmarshaled
|
||||
// directly via UnmarshalV2.
|
||||
func Load(raw []byte) (*Canvas, error) {
|
||||
v, err := DetectVersion(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch v {
|
||||
case V1:
|
||||
return LoadV1(raw)
|
||||
case V2:
|
||||
return LoadV2(raw)
|
||||
default:
|
||||
return nil, fmt.Errorf("dsl: unsupported version %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadV1 parses a v1 payload and converts it to a v2 Canvas. Returns
|
||||
// validation errors for v1-only consumers (e.g. integration tests).
|
||||
func LoadV1(raw []byte) (*Canvas, error) {
|
||||
c, err := v1ToV2(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("dsl: v1->v2 validation: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// LoadV2 parses a v2 payload and validates it.
|
||||
func LoadV2(raw []byte) (*Canvas, error) {
|
||||
return UnmarshalV2(raw)
|
||||
}
|
||||
|
||||
// DecodeReader is a convenience: reads a JSON byte stream and routes it
|
||||
// through Load. The full body is buffered in memory.
|
||||
func DecodeReader(r io.Reader) (*Canvas, error) {
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
return nil, fmt.Errorf("dsl: read: %w", err)
|
||||
}
|
||||
return Load(buf.Bytes())
|
||||
}
|
||||
471
internal/agent/dsl/loader_test.go
Normal file
471
internal/agent/dsl/loader_test.go
Normal file
@@ -0,0 +1,471 @@
|
||||
// Package dsl — tests for the version auto-detection loader.
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectVersion_V1(t *testing.T) {
|
||||
v1 := []byte(`{
|
||||
"components": {
|
||||
"Agent:SmartSchoolsCross": {
|
||||
"downstream": ["Message:ShaggyRingsCrash"],
|
||||
"obj": {"component_name": "Agent", "params": {}}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
v, err := DetectVersion(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectVersion: %v", err)
|
||||
}
|
||||
if v != V1 {
|
||||
t.Errorf("version = %s, want v1", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersion_V2(t *testing.T) {
|
||||
v2 := []byte(`{
|
||||
"version": 2,
|
||||
"components": {
|
||||
"begin_0": {
|
||||
"id": "begin_0",
|
||||
"name": "Begin",
|
||||
"downstream": ["llm_0"],
|
||||
"params": {}
|
||||
},
|
||||
"llm_0": {
|
||||
"id": "llm_0",
|
||||
"name": "LLM",
|
||||
"downstream": [],
|
||||
"params": {"model": "deepseek-chat"}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
v, err := DetectVersion(v2)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectVersion: %v", err)
|
||||
}
|
||||
if v != V2 {
|
||||
t.Errorf("version = %s, want v2", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersion_Unknown(t *testing.T) {
|
||||
// `version` is set to 99 (not 2), and `components` is empty. Probe 1
|
||||
// fails the V2 check, probe 2 sees an empty components map and
|
||||
// rejects the payload.
|
||||
bogus := []byte(`{"version": 99, "components": {}}`)
|
||||
if _, err := DetectVersion(bogus); err == nil {
|
||||
t.Error("expected error for unknown version, got nil")
|
||||
}
|
||||
|
||||
// No `version` field at all and no `components` map: rejected.
|
||||
noComponents := []byte(`{"something": "else"}`)
|
||||
if _, err := DetectVersion(noComponents); err == nil {
|
||||
t.Error("expected error for no components, got nil")
|
||||
}
|
||||
|
||||
// Garbage JSON: rejected at the very first decode.
|
||||
notJSON := []byte(`not json at all`)
|
||||
if _, err := DetectVersion(notJSON); err == nil {
|
||||
t.Error("expected error for non-JSON, got nil")
|
||||
}
|
||||
|
||||
// `version: 2` with empty components is still a v2 envelope; the
|
||||
// emptiness is a load-time validation concern, not a detection
|
||||
// concern. DetectVersion must say V2; LoadV2 must then fail Validate.
|
||||
v2Empty := []byte(`{"version": 2, "components": {}}`)
|
||||
v, err := DetectVersion(v2Empty)
|
||||
if err != nil {
|
||||
t.Errorf("DetectVersion(version=2, components=empty): unexpected err = %v", err)
|
||||
}
|
||||
if v != V2 {
|
||||
t.Errorf("DetectVersion(version=2): v = %s, want v2", v)
|
||||
}
|
||||
if _, err := LoadV2(v2Empty); err == nil {
|
||||
t.Error("LoadV2 should reject empty Components, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadV1FullChain(t *testing.T) {
|
||||
// Use a real template (skips if templates dir missing).
|
||||
v1 := loadTemplate(t, "web_search_assistant.json")
|
||||
c, err := LoadV1(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadV1: %v", err)
|
||||
}
|
||||
if c.Version != CurrentVersion {
|
||||
t.Fatalf("Version = %d, want %d", c.Version, CurrentVersion)
|
||||
}
|
||||
if len(c.Components) == 0 {
|
||||
t.Fatal("expected non-empty Components")
|
||||
}
|
||||
// All downstream refs must resolve.
|
||||
for id, cpn := range c.Components {
|
||||
for _, ds := range cpn.Downstream {
|
||||
if _, ok := c.Components[ds]; !ok {
|
||||
t.Errorf("component %q downstream %q does not exist", id, ds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadV2RoundTrip(t *testing.T) {
|
||||
// Build a v2 Canvas in memory, serialize it, parse it back, and
|
||||
// verify field-for-field equality.
|
||||
src := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"begin_0": {
|
||||
ID: "begin_0",
|
||||
Name: "Begin",
|
||||
Downstream: []string{"llm_0", "retrieval_0"},
|
||||
Params: map[string]any{"query": "{{sys.query}}"},
|
||||
},
|
||||
"llm_0": {
|
||||
ID: "llm_0",
|
||||
Name: "LLM",
|
||||
Downstream: []string{"message_0"},
|
||||
Params: map[string]any{
|
||||
"model": "deepseek-chat",
|
||||
"temperature": 0.1,
|
||||
"prompts": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "Hi",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"retrieval_0": {
|
||||
ID: "retrieval_0",
|
||||
Name: "Retrieval",
|
||||
Downstream: []string{"message_0"},
|
||||
Params: map[string]any{"k": 5},
|
||||
},
|
||||
"message_0": {
|
||||
ID: "message_0",
|
||||
Name: "Message",
|
||||
Downstream: []string{},
|
||||
Params: map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
bs, err := src.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
got, err := LoadV2(bs)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadV2: %v", err)
|
||||
}
|
||||
if got.Version != src.Version {
|
||||
t.Errorf("Version = %d, want %d", got.Version, src.Version)
|
||||
}
|
||||
if len(got.Components) != len(src.Components) {
|
||||
t.Fatalf("len(Components) = %d, want %d", len(got.Components), len(src.Components))
|
||||
}
|
||||
for id, want := range src.Components {
|
||||
got := got.Components[id]
|
||||
if got.ID != want.ID {
|
||||
t.Errorf("[%s] ID = %q, want %q", id, got.ID, want.ID)
|
||||
}
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("[%s] Name = %q, want %q", id, got.Name, want.Name)
|
||||
}
|
||||
if len(got.Downstream) != len(want.Downstream) {
|
||||
t.Errorf("[%s] len(Downstream) = %d, want %d", id, len(got.Downstream), len(want.Downstream))
|
||||
continue
|
||||
}
|
||||
for i, ds := range want.Downstream {
|
||||
if got.Downstream[i] != ds {
|
||||
t.Errorf("[%s] Downstream[%d] = %q, want %q", id, i, got.Downstream[i], ds)
|
||||
}
|
||||
}
|
||||
// Params is map[string]any; compare as canonical JSON.
|
||||
wantJSON, _ := json.Marshal(want.Params)
|
||||
gotJSON, _ := json.Marshal(got.Params)
|
||||
if !bytes.Equal(wantJSON, gotJSON) {
|
||||
t.Errorf("[%s] Params mismatch: got %s, want %s", id, gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AutoDetect(t *testing.T) {
|
||||
// A v1 payload fed to Load() should auto-convert.
|
||||
v1 := []byte(`{
|
||||
"components": {
|
||||
"Begin:AutoOne": {
|
||||
"downstream": ["Message:AutoTwo"],
|
||||
"obj": {"component_name": "Begin", "params": {}}
|
||||
},
|
||||
"Message:AutoTwo": {
|
||||
"obj": {"component_name": "Message", "params": {}}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
c, err := Load(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(v1): %v", err)
|
||||
}
|
||||
if c.Version != CurrentVersion {
|
||||
t.Errorf("Version = %d, want %d", c.Version, CurrentVersion)
|
||||
}
|
||||
if _, ok := c.Components["begin_autoone"]; !ok {
|
||||
t.Error("expected v2 id begin_autoone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeReader(t *testing.T) {
|
||||
v2 := []byte(`{
|
||||
"version": 2,
|
||||
"components": {
|
||||
"begin_0": {
|
||||
"id": "begin_0",
|
||||
"name": "Begin",
|
||||
"downstream": [],
|
||||
"params": {}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
c, err := DecodeReader(io.NopCloser(bytes.NewReader(v2)))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeReader: %v", err)
|
||||
}
|
||||
if _, ok := c.Components["begin_0"]; !ok {
|
||||
t.Error("expected begin_0 in components")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsDangling(t *testing.T) {
|
||||
bad := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"a": {ID: "a", Name: "A", Downstream: []string{"zzz"}, Params: map[string]any{}},
|
||||
},
|
||||
}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Error("expected error for dangling downstream ref, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsEmptyName(t *testing.T) {
|
||||
bad := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"a": {ID: "a", Name: "", Downstream: []string{}, Params: map[string]any{}},
|
||||
},
|
||||
}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Error("expected error for empty Name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsColonInID(t *testing.T) {
|
||||
bad := &Canvas{
|
||||
Version: CurrentVersion,
|
||||
Components: map[string]Component{
|
||||
"Agent:BadId": {ID: "Agent:BadId", Name: "Agent", Downstream: []string{}, Params: map[string]any{}},
|
||||
},
|
||||
}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Error("expected error for v1-style colon id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// loadComplexFixture reads the comprehensive v1 DSL fixture from
|
||||
// testdata/complex_v1.json, next to this test file. Unlike the production
|
||||
// templates under agent/templates/*.json, this fixture is stored as the
|
||||
// raw v1 `{"components": {...}}` envelope (no `dsl:` wrapper), so it
|
||||
// round-trips through LoadV1 / Load byte-for-byte.
|
||||
func loadComplexFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/complex_v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read testdata/complex_v1.json: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestLoadV1ComplexFixture(t *testing.T) {
|
||||
// Round-trip the full fixture through the v1->v2 converter and
|
||||
// assert the resulting Canvas mirrors what the Python v1 reader
|
||||
// would build at runtime. The fixture deliberately exercises
|
||||
// control-flow (Categorize, Switch, Iteration/IterationItem,
|
||||
// Loop/LoopItem) and the data-plane surfaces (Begin, Agent, LLM,
|
||||
// Retrieval, VariableAggregator, VariableAssigner, CodeExec,
|
||||
// Invoke, UserFillup, Message).
|
||||
v1 := loadComplexFixture(t)
|
||||
c, err := LoadV1(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadV1(complex_v1.json): %v", err)
|
||||
}
|
||||
if c.Version != CurrentVersion {
|
||||
t.Fatalf("Version = %d, want %d", c.Version, CurrentVersion)
|
||||
}
|
||||
// 23 components in the fixture, modelling a "multi-source
|
||||
// research copilot" workflow:
|
||||
// Begin (1) -> Categorize(2) -> Switch(3) routes between
|
||||
// - UserFillup (4) -> Message(clarify)
|
||||
// - Message(decline) terminal
|
||||
// - Agent(decompose) (5) -> Loop(over sub-questions)
|
||||
// -> LoopItem (body) -> Retrieval -> LLM(score) ->
|
||||
// Categorize(filter) -> Switch(filter) -> VariableAggregator
|
||||
// -> Iteration(re-rank) -> IterationItem ->
|
||||
// VariableAggregator(ranked) -> LLM(synthesize) ->
|
||||
// CodeExec(validate) -> Switch(decide, with back-edge
|
||||
// retry) -> VariableAssigner -> Message(final) /
|
||||
// Message(fallback).
|
||||
const wantComponents = 23
|
||||
if got := len(c.Components); got != wantComponents {
|
||||
t.Fatalf("len(Components) = %d, want %d", got, wantComponents)
|
||||
}
|
||||
|
||||
// Component-name histogram: every control-flow surface must be
|
||||
// present and at the right multiplicity. Failing this catches
|
||||
// fixture drift (someone rebalances the graph) and converter
|
||||
// regressions that drop a node silently.
|
||||
byName := map[string]int{}
|
||||
for _, cpn := range c.Components {
|
||||
byName[cpn.Name]++
|
||||
}
|
||||
wantNames := map[string]int{
|
||||
"Begin": 1,
|
||||
"Categorize": 2, // intent + chunk-relevance filter
|
||||
"Switch": 3, // intent route + filter + validate-decide
|
||||
"UserFillup": 1,
|
||||
"Agent": 1, // decomposes the user question
|
||||
"LLM": 2, // score + synthesize
|
||||
"Retrieval": 1,
|
||||
"Iteration": 1, // re-rank the aggregated chunks
|
||||
"IterationItem": 1,
|
||||
"VariableAggregator": 2, // within-loop evidence + post-iter ranked
|
||||
"VariableAssigner": 1, // persists the final answer to session
|
||||
"Loop": 1, // iterates over sub-questions (max 5)
|
||||
"LoopItem": 1,
|
||||
"CodeExec": 1, // validates citation completeness
|
||||
"Message": 4, // decline / clarify / fallback / final
|
||||
}
|
||||
for name, want := range wantNames {
|
||||
if got := byName[name]; got != want {
|
||||
t.Errorf("components by name: %q = %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// v2 ids follow the canonical lowercased "<name>_<uuid>" shape;
|
||||
// the converter drops the v1 "<Name>:<UUID>" colon convention
|
||||
// and lowercases both halves (see converter_v1_to_v2.go).
|
||||
for id, cpn := range c.Components {
|
||||
if strings.Contains(id, ":") {
|
||||
t.Errorf("v2 id %q still contains ':'", id)
|
||||
}
|
||||
if id != cpn.ID {
|
||||
t.Errorf("key %q != Component.ID %q", id, cpn.ID)
|
||||
}
|
||||
if cpn.Name == "" {
|
||||
t.Errorf("component %q has empty Name", id)
|
||||
}
|
||||
for _, ds := range cpn.Downstream {
|
||||
if _, ok := c.Components[ds]; !ok {
|
||||
t.Errorf("component %q downstream %q does not exist (have %v)", id, ds, sortedIDs(c.Components))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spot-check the v2 ids the runtime will actually look up. These
|
||||
// are the bridge between the v1 "<Name>:<UUID>" keys and the v2
|
||||
// "<name>_<uuid>" lowercase ids. If any of these go missing,
|
||||
// either the fixture was edited or the key-remap rule changed.
|
||||
wantIDs := []string{
|
||||
"begin_7d83abf3b4f611efa3c40242ac120002",
|
||||
"categorize_11111111aaaa0001aaaa0001aaaa0001",
|
||||
"categorize_11111111aaaa0002aaaa0002aaaa0002",
|
||||
"switch_22222222bbbb0001bbbb0001bbbb0001",
|
||||
"switch_22222222bbbb0002bbbb0002bbbb0002",
|
||||
"switch_22222222bbbb0003bbbb0003bbbb0003",
|
||||
"userfillup_cccccccc00010001cccc00010001cccc0001",
|
||||
"agent_33333333cccc0001cccc0001cccc0001",
|
||||
"loop_dddddddd0001000100010001000100010001",
|
||||
"loopitem_eeeeeeee00010001000100010001bbbb0001",
|
||||
"retrieval_555555550001000100010001eeee0001",
|
||||
"llm_444444440001000100010001dddd0001",
|
||||
"llm_444444440002000200020002dddd0002",
|
||||
"variableaggregator_666666660001000100010001ffff0001",
|
||||
"variableaggregator_666666660002000200020002ffff0002",
|
||||
"iteration_7777777700010001000100010001aaaa0001",
|
||||
"iterationitem_8888888800010001000100010001bbbb0001",
|
||||
"codeexec_bbbbbbbb0001000100010001cccc0001",
|
||||
"variableassigner_9999999900010001000100010001cccc0001",
|
||||
"message_00000000eeee0001eeee0001eeee0001",
|
||||
"message_00000000eeee0002eeee0002eeee0002",
|
||||
"message_00000000eeee0003eeee0003eeee0003",
|
||||
"message_00000000eeee0004eeee0004eeee0004",
|
||||
}
|
||||
for _, id := range wantIDs {
|
||||
if _, ok := c.Components[id]; !ok {
|
||||
t.Errorf("expected v2 id %q in converted canvas", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Topological shape: Begin has at least one downstream (proves
|
||||
// the graph has a real entry point) and at least one Message has
|
||||
// empty downstream (proves the graph has a real sink). The
|
||||
// per-node downstream check above catches dangling refs, so we
|
||||
// don't need a full BFS — Iteration/Loop's body items are linked
|
||||
// via implicit parent_id at runtime, not via downstream edges.
|
||||
beginCpn, ok := c.Components["begin_7d83abf3b4f611efa3c40242ac120002"]
|
||||
if !ok {
|
||||
t.Fatal("begin component missing from converted canvas")
|
||||
}
|
||||
if len(beginCpn.Downstream) == 0 {
|
||||
t.Error("Begin has no downstream; the graph has no entry edge")
|
||||
}
|
||||
sinkCount := 0
|
||||
for _, cpn := range c.Components {
|
||||
if len(cpn.Downstream) == 0 {
|
||||
sinkCount++
|
||||
}
|
||||
}
|
||||
if sinkCount < 1 {
|
||||
t.Error("no terminal components (every node has a downstream); graph is infinite")
|
||||
}
|
||||
|
||||
// The same payload must also auto-detect through the generic
|
||||
// Load() entry point (DetectVersion says v1 -> LoadV1).
|
||||
c2, err := Load(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(v1) auto-detect: %v", err)
|
||||
}
|
||||
if c2.Version != CurrentVersion {
|
||||
t.Errorf("Load auto-detect Version = %d, want %d", c2.Version, CurrentVersion)
|
||||
}
|
||||
if len(c2.Components) != len(c.Components) {
|
||||
t.Errorf("Load auto-detect produced %d components, LoadV1 produced %d",
|
||||
len(c2.Components), len(c.Components))
|
||||
}
|
||||
}
|
||||
|
||||
// sortedIDs returns the canvas component IDs in lexicographic order.
|
||||
// Used in test failure messages so the dump is deterministic.
|
||||
func sortedIDs(m map[string]Component) []string {
|
||||
ids := make([]string, 0, len(m))
|
||||
for id := range m {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// Avoid pulling in sort in tests that don't already need it; the
|
||||
// caller only needs a stable list, and the v1 fixture uses
|
||||
// hand-named UUIDs that sort lexically fine on their own. A simple
|
||||
// bubble sort is plenty for the <=30 IDs these messages contain.
|
||||
for i := 1; i < len(ids); i++ {
|
||||
for j := i; j > 0 && ids[j-1] > ids[j]; j-- {
|
||||
ids[j-1], ids[j] = ids[j], ids[j-1]
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
453
internal/agent/dsl/testdata/complex_v1.json
vendored
Normal file
453
internal/agent/dsl/testdata/complex_v1.json
vendored
Normal file
@@ -0,0 +1,453 @@
|
||||
{
|
||||
"components": {
|
||||
"Begin:7d83abf3b4f611efa3c40242ac120002": {
|
||||
"downstream": ["Categorize:11111111aaaa0001aaaa0001aaaa0001"],
|
||||
"upstream": [],
|
||||
"obj": {
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"mode": "conversational",
|
||||
"enablePrologue": true,
|
||||
"prologue": "Hi! I'm your multi-source research copilot. Ask me anything and I'll dig through our knowledge base, score the evidence, and synthesize a sourced answer.",
|
||||
"inputs": [
|
||||
{"name": "query", "type": "string", "value": "{sys.query}"},
|
||||
{"name": "user_id", "type": "string", "value": "{sys.user_id}"},
|
||||
{"name": "session_id", "type": "string", "value": "{sys.session_id}"}
|
||||
],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"Categorize:11111111aaaa0001aaaa0001aaaa0001": {
|
||||
"downstream": ["Switch:22222222bbbb0001bbbb0001bbbb0001"],
|
||||
"obj": {
|
||||
"component_name": "Categorize",
|
||||
"params": {
|
||||
"category_description": {
|
||||
"research": {
|
||||
"to": "Switch:22222222bbbb0001bbbb0001bbbb0001",
|
||||
"description": "User is asking a research question, a how-to, a comparison, or wants a sourced answer.",
|
||||
"examples": [
|
||||
"What is retrieval-augmented generation?",
|
||||
"Compare LoRA vs full fine-tuning",
|
||||
"Summarize the latest papers on agent loops"
|
||||
]
|
||||
},
|
||||
"clarify": {
|
||||
"to": "UserFillup:cccccccc00010001cccc00010001cccc0001",
|
||||
"description": "User's intent is ambiguous and we need them to disambiguate.",
|
||||
"examples": ["explain that", "tell me about it", "go on"]
|
||||
},
|
||||
"decline": {
|
||||
"to": "Message:00000000eeee0001eeee0001eeee0001",
|
||||
"description": "User is asking for content we cannot provide (e.g. medical diagnosis, legal advice, or unrelated chitchat).",
|
||||
"examples": ["diagnose my symptoms", "should I sue my employer", "hi how are you"]
|
||||
}
|
||||
},
|
||||
"llm_id": "gpt-4o-mini",
|
||||
"query": "{Begin:7d83abf3b4f611efa3c40242ac120002@query}",
|
||||
"message_history_window_size": 5,
|
||||
"temperature": 0.0,
|
||||
"outputs": [{"name": "intent", "type": "string"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Switch:22222222bbbb0001bbbb0001bbbb0001": {
|
||||
"downstream": [
|
||||
"Agent:33333333cccc0001cccc0001cccc0001",
|
||||
"UserFillup:cccccccc00010001cccc00010001cccc0001",
|
||||
"Message:00000000eeee0001eeee0001eeee0001"
|
||||
],
|
||||
"obj": {
|
||||
"component_name": "Switch",
|
||||
"params": {
|
||||
"logical_operator": "or",
|
||||
"conditions": [
|
||||
{
|
||||
"cpn_id": "Categorize:11111111aaaa0001aaaa0001aaaa0001",
|
||||
"operator": "=",
|
||||
"value": "research",
|
||||
"to": "Agent:33333333cccc0001cccc0001cccc0001"
|
||||
}
|
||||
],
|
||||
"end_cpn_ids": [
|
||||
"UserFillup:cccccccc00010001cccc00010001cccc0001",
|
||||
"Message:00000000eeee0001eeee0001eeee0001"
|
||||
],
|
||||
"operators": [
|
||||
"contains", "not contains", "start with", "end with",
|
||||
"empty", "not empty", "=", "≠", ">", "<", "≥", "≤"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserFillup:cccccccc00010001cccc00010001cccc0001": {
|
||||
"downstream": ["Message:00000000eeee0002eeee0002eeee0002"],
|
||||
"obj": {
|
||||
"component_name": "UserFillup",
|
||||
"params": {
|
||||
"inputs": [
|
||||
{
|
||||
"name": "intent",
|
||||
"type": "select",
|
||||
"options": ["research", "chitchat", "support"],
|
||||
"value": "research"
|
||||
},
|
||||
{
|
||||
"name": "clarifying_question",
|
||||
"type": "string",
|
||||
"value": "Could you tell me a bit more about what you want to know? I can answer research questions, summarize documents, or compare approaches."
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "intent", "type": "string"},
|
||||
{"name": "clarifying_question", "type": "string"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Message:00000000eeee0001eeee0001eeee0001": {
|
||||
"downstream": [],
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": "I'm sorry, but I can't help with that kind of request. I can answer research questions, summarize documents, or compare approaches — feel free to ask one of those!",
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"Message:00000000eeee0002eeee0002eeee0002": {
|
||||
"downstream": [],
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": "Got it. To help me route your question, please share a clarifying question via the form.",
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"Agent:33333333cccc0001cccc0001cccc0001": {
|
||||
"downstream": ["Loop:dddddddd0001000100010001000100010001"],
|
||||
"obj": {
|
||||
"component_name": "Agent",
|
||||
"params": {
|
||||
"description": "Decomposes the user's research question into 1-5 self-contained sub-questions that the loop can answer one at a time.",
|
||||
"llm_id": "gpt-4o",
|
||||
"prompts": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a meticulous research planner. Decompose the user's question into 1-5 self-contained sub-questions that can each be answered by retrieving from a knowledge base. Output JSON: {\"sub_questions\": [\"...\", \"...\"]}."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{Begin:7d83abf3b4f611efa3c40242ac120002@query}"
|
||||
}
|
||||
],
|
||||
"tools": ["retrieval"],
|
||||
"memory": [
|
||||
{"role": "user", "limit": 6, "session_only": true}
|
||||
],
|
||||
"cite": false,
|
||||
"max_steps": 4,
|
||||
"delay_after_error": 2,
|
||||
"exception_method": "default",
|
||||
"exception_default_value": "{\"sub_questions\": [\"{Begin:7d83abf3b4f611efa3c40242ac120002@query}\"]}",
|
||||
"exception_goto": "Loop:dddddddd0001000100010001000100010001",
|
||||
"exception_comment": "fallback to a single-sub-question loop with the original query",
|
||||
"outputs": [
|
||||
{"name": "sub_questions", "type": "json"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Loop:dddddddd0001000100010001000100010001": {
|
||||
"downstream": ["Iteration:7777777700010001000100010001aaaa0001"],
|
||||
"obj": {
|
||||
"component_name": "Loop",
|
||||
"params": {
|
||||
"loop_variables": [
|
||||
{"name": "accumulated_evidence", "source": "literal", "value": "[]"}
|
||||
],
|
||||
"loop_termination_condition": [
|
||||
{
|
||||
"variable": "accumulated_evidence",
|
||||
"operator": "≥",
|
||||
"value": "5"
|
||||
}
|
||||
],
|
||||
"maximum_loop_count": 5,
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoopItem:eeeeeeee00010001000100010001bbbb0001": {
|
||||
"downstream": ["Retrieval:555555550001000100010001eeee0001"],
|
||||
"obj": {
|
||||
"component_name": "LoopItem",
|
||||
"params": {
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"Retrieval:555555550001000100010001eeee0001": {
|
||||
"downstream": ["LLM:444444440001000100010001dddd0001"],
|
||||
"obj": {
|
||||
"component_name": "Retrieval",
|
||||
"params": {
|
||||
"kb_ids": ["kb_research_2024", "kb_company_wiki", "kb_industry_reports"],
|
||||
"top_k": 10,
|
||||
"rerank_id": "bge-reranker-v2-m3",
|
||||
"empty_response": "No matching documents were found.",
|
||||
"keywords": [
|
||||
"{Agent:33333333cccc0001cccc0001cccc0001@sub_questions}"
|
||||
],
|
||||
"query": "{Begin:7d83abf3b4f611efa3c40242ac120002@query}",
|
||||
"outputs": [{"name": "chunks", "type": "json"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"LLM:444444440001000100010001dddd0001": {
|
||||
"downstream": ["Categorize:11111111aaaa0002aaaa0002aaaa0002"],
|
||||
"obj": {
|
||||
"component_name": "LLM",
|
||||
"params": {
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.0,
|
||||
"system": "You are a relevance scorer. For each chunk, output a JSON object {\"idx\": <int>, \"score\": <0.0-1.0>, \"reason\": \"<short>\"}.",
|
||||
"prompt": "Score the relevance of each chunk to the user's question.\n\nQuestion: {Begin:7d83abf3b4f611efa3c40242ac120002@query}\n\nChunks: {Retrieval:555555550001000100010001eeee0001@chunks}",
|
||||
"max_tokens": 1024,
|
||||
"outputs": [{"name": "scored_chunks", "type": "json"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Categorize:11111111aaaa0002aaaa0002aaaa0002": {
|
||||
"downstream": ["Switch:22222222bbbb0002bbbb0002bbbb0002"],
|
||||
"obj": {
|
||||
"component_name": "Categorize",
|
||||
"params": {
|
||||
"category_description": {
|
||||
"high_relevance": {
|
||||
"to": "Switch:22222222bbbb0002bbbb0002bbbb0002",
|
||||
"description": "The retrieved chunk is highly relevant to the user's question (score ≥ 0.7).",
|
||||
"examples": ["directly answers the question", "contains the exact term the user asked about"]
|
||||
},
|
||||
"low_relevance": {
|
||||
"to": "VariableAggregator:666666660001000100010001ffff0001",
|
||||
"description": "The retrieved chunk is off-topic or redundant (score < 0.3).",
|
||||
"examples": ["tangentially mentions the topic", "duplicates another chunk"]
|
||||
}
|
||||
},
|
||||
"llm_id": "gpt-4o-mini",
|
||||
"query": "{LLM:444444440001000100010001dddd0001@scored_chunks}",
|
||||
"message_history_window_size": 1,
|
||||
"temperature": 0.0,
|
||||
"outputs": [{"name": "relevance", "type": "string"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Switch:22222222bbbb0002bbbb0002bbbb0002": {
|
||||
"downstream": ["VariableAggregator:666666660001000100010001ffff0001"],
|
||||
"obj": {
|
||||
"component_name": "Switch",
|
||||
"params": {
|
||||
"logical_operator": "or",
|
||||
"conditions": [
|
||||
{
|
||||
"cpn_id": "Categorize:11111111aaaa0002aaaa0002aaaa0002",
|
||||
"operator": "contains",
|
||||
"value": "high_relevance",
|
||||
"to": "VariableAggregator:666666660001000100010001ffff0001"
|
||||
}
|
||||
],
|
||||
"end_cpn_ids": ["VariableAggregator:666666660001000100010001ffff0001"],
|
||||
"operators": [
|
||||
"contains", "not contains", "=", "≠"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"VariableAggregator:666666660001000100010001ffff0001": {
|
||||
"downstream": ["Iteration:7777777700010001000100010001aaaa0001"],
|
||||
"obj": {
|
||||
"component_name": "VariableAggregator",
|
||||
"params": {
|
||||
"groups": [
|
||||
{
|
||||
"group_id": "evidence_group",
|
||||
"group_name": "evidence",
|
||||
"llm_id": "gpt-4o-mini",
|
||||
"format": "{Retrieval:555555550001000100010001eeee0001@chunks}",
|
||||
"refusal_answer": "no evidence",
|
||||
"output": {
|
||||
"value": {"type": "collected", "value": "evidence"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "evidence", "type": "json"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Iteration:7777777700010001000100010001aaaa0001": {
|
||||
"downstream": ["LLM:444444440002000200020002dddd0002"],
|
||||
"obj": {
|
||||
"component_name": "Iteration",
|
||||
"params": {
|
||||
"items_ref": "VariableAggregator:666666660001000100010001ffff0001@evidence",
|
||||
"variable": {
|
||||
"chunk_id": "",
|
||||
"chunk_text": ""
|
||||
},
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"IterationItem:8888888800010001000100010001bbbb0001": {
|
||||
"downstream": ["VariableAggregator:666666660002000200020002ffff0002"],
|
||||
"obj": {
|
||||
"component_name": "IterationItem",
|
||||
"params": {
|
||||
"outputs": [
|
||||
{"name": "excerpt", "type": "string"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"VariableAggregator:666666660002000200020002ffff0002": {
|
||||
"downstream": ["LLM:444444440002000200020002dddd0002"],
|
||||
"obj": {
|
||||
"component_name": "VariableAggregator",
|
||||
"params": {
|
||||
"groups": [
|
||||
{
|
||||
"group_id": "ranked_group",
|
||||
"group_name": "ranked_excerpts",
|
||||
"llm_id": "gpt-4o-mini",
|
||||
"format": "{IterationItem:8888888800010001000100010001bbbb0001@excerpt}",
|
||||
"refusal_answer": "no excerpt",
|
||||
"output": {
|
||||
"value": {"type": "collected", "value": "ranked_excerpts"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "ranked_excerpts", "type": "json"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"LLM:444444440002000200020002dddd0002": {
|
||||
"downstream": ["CodeExec:bbbbbbbb0001000100010001cccc0001"],
|
||||
"obj": {
|
||||
"component_name": "LLM",
|
||||
"params": {
|
||||
"model": "deepseek-chat",
|
||||
"temperature": 0.2,
|
||||
"system": "You are a careful synthesizer. Combine the ranked excerpts into a sourced answer. Cite the chunk id for every claim.",
|
||||
"prompt": "Question: {Begin:7d83abf3b4f611efa3c40242ac120002@query}\n\nRanked excerpts: {VariableAggregator:666666660002000200020002ffff0002@ranked_excerpts}",
|
||||
"max_tokens": 1024,
|
||||
"outputs": [
|
||||
{"name": "draft_answer", "type": "string"},
|
||||
{"name": "citations", "type": "json"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"CodeExec:bbbbbbbb0001000100010001cccc0001": {
|
||||
"downstream": ["Switch:22222222bbbb0003bbbb0003bbbb0003"],
|
||||
"obj": {
|
||||
"component_name": "CodeExec",
|
||||
"params": {
|
||||
"script": "import json, sys\ndraft = sys.argv[1]\ncitations = json.loads(sys.argv[2])\n# Validate: every claim in the draft must reference a real citation id.\nok = all(c.get('id') for c in citations) and len(citations) > 0\nprint(json.dumps({\"ok\": ok, \"citation_count\": len(citations), \"draft_len\": len(draft)}))",
|
||||
"lang": "python",
|
||||
"arguments": [
|
||||
"{LLM:444444440002000200020002dddd0002@draft_answer}",
|
||||
"{LLM:444444440002000200020002dddd0002@citations}"
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "validation", "type": "json"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Switch:22222222bbbb0003bbbb0003bbbb0003": {
|
||||
"downstream": [
|
||||
"VariableAssigner:9999999900010001000100010001cccc0001",
|
||||
"LLM:444444440002000200020002dddd0002",
|
||||
"Message:00000000eeee0003eeee0003eeee0003"
|
||||
],
|
||||
"obj": {
|
||||
"component_name": "Switch",
|
||||
"params": {
|
||||
"logical_operator": "or",
|
||||
"conditions": [
|
||||
{
|
||||
"cpn_id": "CodeExec:bbbbbbbb0001000100010001cccc0001",
|
||||
"operator": "contains",
|
||||
"value": "\"ok\": true",
|
||||
"to": "VariableAssigner:9999999900010001000100010001cccc0001"
|
||||
}
|
||||
],
|
||||
"end_cpn_ids": [
|
||||
"LLM:444444440002000200020002dddd0002",
|
||||
"Message:00000000eeee0003eeee0003eeee0003"
|
||||
],
|
||||
"operators": [
|
||||
"contains", "not contains", "=", "≠"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"VariableAssigner:9999999900010001000100010001cccc0001": {
|
||||
"downstream": ["Message:00000000eeee0004eeee0004eeee0004"],
|
||||
"obj": {
|
||||
"component_name": "VariableAssigner",
|
||||
"params": {
|
||||
"concurrent": true,
|
||||
"orders": [
|
||||
{
|
||||
"id": "research_done",
|
||||
"name": "research_done",
|
||||
"source": "literal",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"id": "draft_answer",
|
||||
"name": "draft_answer",
|
||||
"source": "ref",
|
||||
"ref": "{LLM:444444440002000200020002dddd0002@draft_answer}"
|
||||
},
|
||||
{
|
||||
"id": "citations",
|
||||
"name": "citations",
|
||||
"source": "ref",
|
||||
"ref": "{LLM:444444440002000200020002dddd0002@citations}"
|
||||
}
|
||||
],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"Message:00000000eeee0003eeee0003eeee0003": {
|
||||
"downstream": [],
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": "I couldn't validate the answer after multiple attempts. Here is my best-effort draft:\n\n{LLM:444444440002000200020002dddd0002@draft_answer}",
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"Message:00000000eeee0004eeee0004eeee0004": {
|
||||
"downstream": [],
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": "{LLM:444444440002000200020002dddd0002@draft_answer}\n\nCitations:\n{LLM:444444440002000200020002dddd0002@citations}",
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
internal/agent/dsl/testdata/v1_examples/categorize_and_agent_with_tavily.json
vendored
Normal file
85
internal/agent/dsl/testdata/v1_examples/categorize_and_agent_with_tavily.json
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"components": {
|
||||
"begin": {
|
||||
"obj":{
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"prologue": "Hi there!"
|
||||
}
|
||||
},
|
||||
"downstream": ["categorize:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"categorize:0": {
|
||||
"obj": {
|
||||
"component_name": "Categorize",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"category_description": {
|
||||
"product_related": {
|
||||
"description": "The question is about the product usage, appearance and how it works.",
|
||||
"to": ["agent:0"]
|
||||
},
|
||||
"others": {
|
||||
"description": "The question is not about the product usage, appearance and how it works.",
|
||||
"to": ["message:0"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["begin"]
|
||||
},
|
||||
"message:0": {
|
||||
"obj":{
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": [
|
||||
"Sorry, I don't know. I'm an AI bot."
|
||||
]
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["categorize:0"]
|
||||
},
|
||||
"agent:0": {
|
||||
"obj": {
|
||||
"component_name": "Agent",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"sys_prompt": "You are a smart researcher. You could generate proper queries to search. According to the search results, you could deside next query if the result is not enough.",
|
||||
"temperature": 0.2,
|
||||
"llm_enabled_tools": [
|
||||
{
|
||||
"component_name": "TavilySearch",
|
||||
"params": {
|
||||
"api_key": "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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"downstream": ["message:1"],
|
||||
"upstream": ["categorize:0"]
|
||||
},
|
||||
"message:1": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": ["{agent:0@content}"]
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["agent:0"]
|
||||
}
|
||||
},
|
||||
"history": [],
|
||||
"path": [],
|
||||
"retrieval": {"chunks": [], "doc_aggs": []},
|
||||
"globals": {
|
||||
"sys.query": "",
|
||||
"sys.user_id": "",
|
||||
"sys.conversation_turns": 0,
|
||||
"sys.files": []
|
||||
}
|
||||
}
|
||||
43
internal/agent/dsl/testdata/v1_examples/exesql.json
vendored
Normal file
43
internal/agent/dsl/testdata/v1_examples/exesql.json
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"components": {
|
||||
"begin": {
|
||||
"obj":{
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"prologue": "Hi there!"
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"answer:0": {
|
||||
"obj": {
|
||||
"component_name": "Answer",
|
||||
"params": {}
|
||||
},
|
||||
"downstream": ["exesql:0"],
|
||||
"upstream": ["begin", "exesql:0"]
|
||||
},
|
||||
"exesql:0": {
|
||||
"obj": {
|
||||
"component_name": "ExeSQL",
|
||||
"params": {
|
||||
"database": "rag_flow",
|
||||
"username": "root",
|
||||
"host": "mysql",
|
||||
"port": 3306,
|
||||
"password": "infini_rag_flow",
|
||||
"top_n": 3
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:0"],
|
||||
"upstream": ["answer:0"]
|
||||
}
|
||||
},
|
||||
"history": [],
|
||||
"messages": [],
|
||||
"reference": {},
|
||||
"path": [],
|
||||
"answer": []
|
||||
}
|
||||
|
||||
210
internal/agent/dsl/testdata/v1_examples/headhunter_zh.json
vendored
Normal file
210
internal/agent/dsl/testdata/v1_examples/headhunter_zh.json
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"components": {
|
||||
"begin": {
|
||||
"obj": {
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"prologue": "您好!我是AGI方向的猎头,了解到您是这方面的大佬,然后冒昧的就联系到您。这边有个机会想和您分享,RAGFlow正在招聘您这个岗位的资深的工程师不知道您那边是不是感兴趣?"
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"answer:0": {
|
||||
"obj": {
|
||||
"component_name": "Answer",
|
||||
"params": {}
|
||||
},
|
||||
"downstream": ["categorize:0"],
|
||||
"upstream": ["begin", "message:reject"]
|
||||
},
|
||||
"categorize:0": {
|
||||
"obj": {
|
||||
"component_name": "Categorize",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"category_description": {
|
||||
"about_job": {
|
||||
"description": "该问题关于职位本身或公司的信息。",
|
||||
"examples": "什么岗位?\n汇报对象是谁?\n公司多少人?\n公司有啥产品?\n具体工作内容是啥?\n地点哪里?\n双休吗?",
|
||||
"to": "retrieval:0"
|
||||
},
|
||||
"casual": {
|
||||
"description": "该问题不关于职位本身或公司的信息,属于闲聊。",
|
||||
"examples": "你好\n好久不见\n你男的女的?\n你是猴子派来的救兵吗?\n上午开会了?\n你叫啥?\n最近市场如何?生意好做吗?",
|
||||
"to": "generate:casual"
|
||||
},
|
||||
"interested": {
|
||||
"description": "该回答表示他对于该职位感兴趣。",
|
||||
"examples": "嗯\n说吧\n说说看\n还好吧\n是的\n哦\nyes\n具体说说",
|
||||
"to": "message:introduction"
|
||||
},
|
||||
"answer": {
|
||||
"description": "该回答表示他对于该职位不感兴趣,或感觉受到骚扰。",
|
||||
"examples": "不需要\n不感兴趣\n暂时不看\n不要\nno\n我已经不干这个了\n我不是这个方向的",
|
||||
"to": "message:reject"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downstream": [
|
||||
"message:introduction",
|
||||
"generate:casual",
|
||||
"message:reject",
|
||||
"retrieval:0"
|
||||
],
|
||||
"upstream": ["answer:0"]
|
||||
},
|
||||
"message:introduction": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"messages": [
|
||||
"我简单介绍以下:\nRAGFlow 是一款基于深度文档理解构建的开源 RAG(Retrieval-Augmented Generation)引擎。RAGFlow 可以为各种规模的企业及个人提供一套精简的 RAG 工作流程,结合大语言模型(LLM)针对用户各类不同的复杂格式数据提供可靠的问答以及有理有据的引用。https://github.com/infiniflow/ragflow\n您那边还有什么要了解的?"
|
||||
]
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:1"],
|
||||
"upstream": ["categorize:0"]
|
||||
},
|
||||
"answer:1": {
|
||||
"obj": {
|
||||
"component_name": "Answer",
|
||||
"params": {}
|
||||
},
|
||||
"downstream": ["categorize:1"],
|
||||
"upstream": [
|
||||
"message:introduction",
|
||||
"generate:aboutJob",
|
||||
"generate:casual",
|
||||
"generate:get_wechat",
|
||||
"generate:nowechat"
|
||||
]
|
||||
},
|
||||
"categorize:1": {
|
||||
"obj": {
|
||||
"component_name": "Categorize",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"category_description": {
|
||||
"about_job": {
|
||||
"description": "该问题关于职位本身或公司的信息。",
|
||||
"examples": "什么岗位?\n汇报对象是谁?\n公司多少人?\n公司有啥产品?\n具体工作内容是啥?\n地点哪里?\n双休吗?",
|
||||
"to": "retrieval:0"
|
||||
},
|
||||
"casual": {
|
||||
"description": "该问题不关于职位本身或公司的信息,属于闲聊。",
|
||||
"examples": "你好\n好久不见\n你男的女的?\n你是猴子派来的救兵吗?\n上午开会了?\n你叫啥?\n最近市场如何?生意好做吗?",
|
||||
"to": "generate:casual"
|
||||
},
|
||||
"wechat": {
|
||||
"description": "该回答表示他愿意加微信,或者已经报了微信号。",
|
||||
"examples": "嗯\n可以\n是的\n哦\nyes\n15002333453\nwindblow_2231",
|
||||
"to": "generate:get_wechat"
|
||||
},
|
||||
"giveup": {
|
||||
"description": "该回答表示他不愿意加微信。",
|
||||
"examples": "不需要\n不感兴趣\n暂时不看\n不要\nno\n不方便\n不知道还要加我微信",
|
||||
"to": "generate:nowechat"
|
||||
}
|
||||
},
|
||||
"message_history_window_size": 8
|
||||
}
|
||||
},
|
||||
"downstream": [
|
||||
"retrieval:0",
|
||||
"generate:casual",
|
||||
"generate:get_wechat",
|
||||
"generate:nowechat"
|
||||
],
|
||||
"upstream": ["answer:1"]
|
||||
},
|
||||
"generate:casual": {
|
||||
"obj": {
|
||||
"component_name": "Generate",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"prompt": "你是AGI方向的猎头,现在候选人的聊了和职位无关的话题,请耐心的回应候选人,并将话题往该AGI的职位上带,最好能要到候选人微信号以便后面保持联系。",
|
||||
"temperature": 0.9,
|
||||
"message_history_window_size": 12,
|
||||
"cite": false
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:1"],
|
||||
"upstream": ["categorize:0", "categorize:1"]
|
||||
},
|
||||
"retrieval:0": {
|
||||
"obj": {
|
||||
"component_name": "Retrieval",
|
||||
"params": {
|
||||
"similarity_threshold": 0.2,
|
||||
"keywords_similarity_weight": 0.3,
|
||||
"top_n": 6,
|
||||
"top_k": 1024,
|
||||
"rerank_id": "BAAI/bge-reranker-v2-m3",
|
||||
"kb_ids": ["869a236818b811ef91dffa163e197198"]
|
||||
}
|
||||
},
|
||||
"downstream": ["generate:aboutJob"],
|
||||
"upstream": ["categorize:0", "categorize:1"]
|
||||
},
|
||||
"generate:aboutJob": {
|
||||
"obj": {
|
||||
"component_name": "Generate",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"prompt": "你是AGI方向的猎头,候选人问了有关职位或公司的问题,你根据以下职位信息回答。如果职位信息中不包含候选人的问题就回答不清楚、不知道、有待确认等。回答完后引导候选人加微信号,如:\n - 方便加一下微信吗,我把JD发您看看?\n - 微信号多少,我把详细职位JD发您?\n 职位信息如下:\n {input}\n 职位信息如上。",
|
||||
"temperature": 0.02
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:1"],
|
||||
"upstream": ["retrieval:0"]
|
||||
},
|
||||
"generate:get_wechat": {
|
||||
"obj": {
|
||||
"component_name": "Generate",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"prompt": "你是AGI方向的猎头,候选人表示不反感加微信,如果对方已经报了微信号,表示感谢和信任并表示马上会加上;如果没有,则问对方微信号多少。你的微信号是weixin_kevin,E-mail是kkk@ragflow.com。说话不要重复。不要总是您好。",
|
||||
"temperature": 0.1,
|
||||
"message_history_window_size": 12,
|
||||
"cite": false
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:1"],
|
||||
"upstream": ["categorize:1"]
|
||||
},
|
||||
"generate:nowechat": {
|
||||
"obj": {
|
||||
"component_name": "Generate",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"prompt": "你是AGI方向的猎头,当你提出加微信时对方表示拒绝。你需要耐心礼貌的回应候选人,表示对于保护隐私信息给予理解,也可以询问他对该职位的看法和顾虑。并在恰当的时机再次询问微信联系方式。也可以鼓励候选人主动与你取得联系。你的微信号是weixin_kevin,E-mail是kkk@ragflow.com。说话不要重复。不要总是您好。",
|
||||
"temperature": 0.1,
|
||||
"message_history_window_size": 12,
|
||||
"cite": false
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:1"],
|
||||
"upstream": ["categorize:1"]
|
||||
},
|
||||
"message:reject": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"messages": [
|
||||
"好的,祝您生活愉快,工作顺利。",
|
||||
"哦,好的,感谢您宝贵的时间!"
|
||||
]
|
||||
}
|
||||
},
|
||||
"downstream": ["answer:0"],
|
||||
"upstream": ["categorize:0"]
|
||||
}
|
||||
},
|
||||
"history": [],
|
||||
"messages": [],
|
||||
"path": [],
|
||||
"reference": [],
|
||||
"answer": []
|
||||
}
|
||||
92
internal/agent/dsl/testdata/v1_examples/iteration.json
vendored
Normal file
92
internal/agent/dsl/testdata/v1_examples/iteration.json
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"components": {
|
||||
"begin": {
|
||||
"obj":{
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"prologue": "Hi there!"
|
||||
}
|
||||
},
|
||||
"downstream": ["generate:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"generate:0": {
|
||||
"obj": {
|
||||
"component_name": "Agent",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"sys_prompt": "You are an helpful research assistant. \nPlease decompose user's topic: '{sys.query}' into several meaningful sub-topics. \nThe output format MUST be an string array like: [\"sub-topic1\", \"sub-topic2\", ...]. Redundant information is forbidden.",
|
||||
"temperature": 0.2,
|
||||
"cite":false,
|
||||
"output_structure": ["sub-topic1", "sub-topic2", "sub-topic3"]
|
||||
}
|
||||
},
|
||||
"downstream": ["iteration:0"],
|
||||
"upstream": ["begin"]
|
||||
},
|
||||
"iteration:0": {
|
||||
"obj": {
|
||||
"component_name": "Iteration",
|
||||
"params": {
|
||||
"items_ref": "generate:0@structured_content"
|
||||
}
|
||||
},
|
||||
"downstream": ["message:0"],
|
||||
"upstream": ["generate:0"]
|
||||
},
|
||||
"iterationitem:0": {
|
||||
"obj": {
|
||||
"component_name": "IterationItem",
|
||||
"params": {}
|
||||
},
|
||||
"parent_id": "iteration:0",
|
||||
"downstream": ["tavily:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"tavily:0": {
|
||||
"obj": {
|
||||
"component_name": "TavilySearch",
|
||||
"params": {
|
||||
"api_key": "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",
|
||||
"query": "iterationitem:0@result"
|
||||
}
|
||||
},
|
||||
"parent_id": "iteration:0",
|
||||
"downstream": ["generate:1"],
|
||||
"upstream": ["iterationitem:0"]
|
||||
},
|
||||
"generate:1": {
|
||||
"obj": {
|
||||
"component_name": "Agent",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"sys_prompt": "Your goal is to provide answers based on information from the internet. \nYou must use the provided search results to find relevant online information. \nYou should never use your own knowledge to answer questions.\nPlease include relevant url sources in the end of your answers.\n\n \"{tavily:0@formalized_content}\" \nUsing the above information, answer the following question or topic: \"{iterationitem:0@result} \"\nin a detailed report — The report should focus on the answer to the question, should be well structured, informative, in depth, with facts and numbers if available, a minimum of 200 words and with markdown syntax and apa format. Write all source urls at the end of the report in apa format. You should write your report only based on the given information and nothing else.",
|
||||
"temperature": 0.9,
|
||||
"cite":false
|
||||
}
|
||||
},
|
||||
"parent_id": "iteration:0",
|
||||
"downstream": ["iterationitem:0"],
|
||||
"upstream": ["tavily:0"]
|
||||
},
|
||||
"message:0": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": ["{iteration:0@generate:1}"]
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["iteration:0"]
|
||||
}
|
||||
},
|
||||
"history": [],
|
||||
"path": [],
|
||||
"retrieval": {"chunks": [], "doc_aggs": []},
|
||||
"globals": {
|
||||
"sys.query": "",
|
||||
"sys.user_id": "",
|
||||
"sys.conversation_turns": 0,
|
||||
"sys.files": []
|
||||
}
|
||||
}
|
||||
61
internal/agent/dsl/testdata/v1_examples/retrieval_and_generate.json
vendored
Normal file
61
internal/agent/dsl/testdata/v1_examples/retrieval_and_generate.json
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"components": {
|
||||
"begin": {
|
||||
"obj":{
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"prologue": "Hi there!"
|
||||
}
|
||||
},
|
||||
"downstream": ["retrieval:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"retrieval:0": {
|
||||
"obj": {
|
||||
"component_name": "Retrieval",
|
||||
"params": {
|
||||
"similarity_threshold": 0.2,
|
||||
"keywords_similarity_weight": 0.3,
|
||||
"top_n": 6,
|
||||
"top_k": 1024,
|
||||
"rerank_id": "",
|
||||
"empty_response": "Nothing found in dataset",
|
||||
"kb_ids": ["1a3d1d7afb0611ef9866047c16ec874f"]
|
||||
}
|
||||
},
|
||||
"downstream": ["generate:0"],
|
||||
"upstream": ["begin"]
|
||||
},
|
||||
"generate:0": {
|
||||
"obj": {
|
||||
"component_name": "LLM",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"sys_prompt": "You are an intelligent assistant. Please summarize the content of the knowledge base to answer the question. Please list the data in the knowledge base and answer in detail. When all knowledge base content is irrelevant to the question, your answer must include the sentence \"The answer you are looking for is not found in the knowledge base!\" Answers need to consider chat history.\n Here is the knowledge base:\n {retrieval:0@formalized_content}\n Above is the knowledge base.",
|
||||
"temperature": 0.2
|
||||
}
|
||||
},
|
||||
"downstream": ["message:0"],
|
||||
"upstream": ["retrieval:0"]
|
||||
},
|
||||
"message:0": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": ["{generate:0@content}"]
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["generate:0"]
|
||||
}
|
||||
},
|
||||
"history": [],
|
||||
"path": [],
|
||||
"retrieval": {"chunks": [], "doc_aggs": []},
|
||||
"globals": {
|
||||
"sys.query": "",
|
||||
"sys.user_id": "",
|
||||
"sys.conversation_turns": 0,
|
||||
"sys.files": []
|
||||
}
|
||||
}
|
||||
95
internal/agent/dsl/testdata/v1_examples/retrieval_categorize_and_generate.json
vendored
Normal file
95
internal/agent/dsl/testdata/v1_examples/retrieval_categorize_and_generate.json
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"components": {
|
||||
"begin": {
|
||||
"obj":{
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"prologue": "Hi there!"
|
||||
}
|
||||
},
|
||||
"downstream": ["categorize:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"categorize:0": {
|
||||
"obj": {
|
||||
"component_name": "Categorize",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"category_description": {
|
||||
"product_related": {
|
||||
"description": "The question is about the product usage, appearance and how it works.",
|
||||
"examples": [],
|
||||
"to": ["retrieval:0"]
|
||||
},
|
||||
"others": {
|
||||
"description": "The question is not about the product usage, appearance and how it works.",
|
||||
"examples": [],
|
||||
"to": ["message:0"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["begin"]
|
||||
},
|
||||
"message:0": {
|
||||
"obj":{
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": [
|
||||
"Sorry, I don't know. I'm an AI bot."
|
||||
]
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["categorize:0"]
|
||||
},
|
||||
"retrieval:0": {
|
||||
"obj": {
|
||||
"component_name": "Retrieval",
|
||||
"params": {
|
||||
"similarity_threshold": 0.2,
|
||||
"keywords_similarity_weight": 0.3,
|
||||
"top_n": 6,
|
||||
"top_k": 1024,
|
||||
"rerank_id": "",
|
||||
"empty_response": "Nothing found in dataset",
|
||||
"kb_ids": ["1a3d1d7afb0611ef9866047c16ec874f"]
|
||||
}
|
||||
},
|
||||
"downstream": ["generate:0"],
|
||||
"upstream": ["categorize:0"]
|
||||
},
|
||||
"generate:0": {
|
||||
"obj": {
|
||||
"component_name": "Agent",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"sys_prompt": "You are an intelligent assistant. Please summarize the content of the dataset to answer the question. Please list the data in the knowledge base and answer in detail. When all knowledge base content is irrelevant to the question, your answer must include the sentence \"The answer you are looking for is not found in the knowledge base!\" Answers need to consider chat history.\n Here is the knowledge base:\n {retrieval:0@formalized_content}\n The above is the knowledge base.",
|
||||
"temperature": 0.2
|
||||
}
|
||||
},
|
||||
"downstream": ["message:1"],
|
||||
"upstream": ["retrieval:0"]
|
||||
},
|
||||
"message:1": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": ["{generate:0@content}"]
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["generate:0"]
|
||||
}
|
||||
},
|
||||
"history": [],
|
||||
"path": [],
|
||||
"retrieval": {"chunks": [], "doc_aggs": []},
|
||||
"globals": {
|
||||
"sys.query": "",
|
||||
"sys.user_id": "",
|
||||
"sys.conversation_turns": 0,
|
||||
"sys.files": []
|
||||
}
|
||||
}
|
||||
55
internal/agent/dsl/testdata/v1_examples/tavily_and_generate.json
vendored
Normal file
55
internal/agent/dsl/testdata/v1_examples/tavily_and_generate.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"components": {
|
||||
"begin": {
|
||||
"obj":{
|
||||
"component_name": "Begin",
|
||||
"params": {
|
||||
"prologue": "Hi there!"
|
||||
}
|
||||
},
|
||||
"downstream": ["tavily:0"],
|
||||
"upstream": []
|
||||
},
|
||||
"tavily:0": {
|
||||
"obj": {
|
||||
"component_name": "TavilySearch",
|
||||
"params": {
|
||||
"api_key": "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"
|
||||
}
|
||||
},
|
||||
"downstream": ["generate:0"],
|
||||
"upstream": ["begin"]
|
||||
},
|
||||
"generate:0": {
|
||||
"obj": {
|
||||
"component_name": "LLM",
|
||||
"params": {
|
||||
"llm_id": "deepseek-chat",
|
||||
"sys_prompt": "You are an intelligent assistant. Please summarize the content of the knowledge base to answer the question. Please list the data in the knowledge base and answer in detail. When all knowledge base content is irrelevant to the question, your answer must include the sentence \"The answer you are looking for is not found in the knowledge base!\" Answers need to consider chat history.\n Here is the knowledge base:\n {tavily:0@formalized_content}\n Above is the knowledge base.",
|
||||
"temperature": 0.2
|
||||
}
|
||||
},
|
||||
"downstream": ["message:0"],
|
||||
"upstream": ["tavily:0"]
|
||||
},
|
||||
"message:0": {
|
||||
"obj": {
|
||||
"component_name": "Message",
|
||||
"params": {
|
||||
"content": ["{generate:0@content}"]
|
||||
}
|
||||
},
|
||||
"downstream": [],
|
||||
"upstream": ["generate:0"]
|
||||
}
|
||||
},
|
||||
"history": [],
|
||||
"path": [],
|
||||
"retrieval": {"chunks": [], "doc_aggs": []},
|
||||
"globals": {
|
||||
"sys.query": "",
|
||||
"sys.user_id": "",
|
||||
"sys.conversation_turns": 0,
|
||||
"sys.files": []
|
||||
}
|
||||
}
|
||||
192
internal/agent/dsl/v1_examples_test.go
Normal file
192
internal/agent/dsl/v1_examples_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Package dsl — loader test for the production v1 DSL examples.
|
||||
//
|
||||
// These fixtures are mirrored from agent/test/dsl_examples/*.json so
|
||||
// the Go port has a self-contained set of representative v1 payloads
|
||||
// to exercise against. The Python side ships the originals (and may
|
||||
// grow them); this directory is the Go side's frozen copy for unit
|
||||
// tests so the Go port is buildable on its own.
|
||||
//
|
||||
// The test walks every fixture through the four DSL entry points
|
||||
// (DetectVersion, LoadV1, Load auto-detect, DecodeReader) and asserts
|
||||
// the converted v2 Canvas is structurally sound in each case. The
|
||||
// four entry points differ only in how the bytes reach the
|
||||
// parser/detector — by covering them all from one table we catch
|
||||
// any drift in detection rules or converter behaviour without
|
||||
// fragmenting coverage into per-entry-point subtests.
|
||||
//
|
||||
// Each example covers a distinct topology:
|
||||
//
|
||||
// retrieval_and_generate — Begin → Retrieval → LLM → Message
|
||||
// tavily_and_generate — Begin → TavilySearch → LLM → Message
|
||||
// categorize_and_agent_with_tavily — Begin → Categorize → {Agent(Tavily) | Message}
|
||||
// retrieval_categorize_and_generate — Begin → Categorize → {Retrieval → Agent → Message | Message}
|
||||
// iteration — Begin → Agent → Iteration → {IterationItem → TavilySearch → Agent}
|
||||
// exesql — Begin → Answer ↔ ExeSQL (cycle, kept in fixtures as a stress test)
|
||||
// headhunter_zh — multi-Categorize, multi-Generate, Answer+Message loop
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v1Examples lists every fixture under testdata/v1_examples. Adding
|
||||
// a new file to the directory automatically extends coverage.
|
||||
var v1Examples = []string{
|
||||
"categorize_and_agent_with_tavily.json",
|
||||
"exesql.json",
|
||||
"headhunter_zh.json",
|
||||
"iteration.json",
|
||||
"retrieval_and_generate.json",
|
||||
"retrieval_categorize_and_generate.json",
|
||||
"tavily_and_generate.json",
|
||||
}
|
||||
|
||||
// readV1Example reads a v1 fixture from testdata/v1_examples. Tests
|
||||
// call t.Skip if the file is missing so the package still builds in
|
||||
// environments where the fixtures have not been vendored.
|
||||
func readV1Example(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", "v1_examples", name)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("v1 fixture %s not readable: %v", path, err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// TestV1Examples is the single canary for the v1 fixture set. It
|
||||
// drives every fixture through the four DSL entry points and asserts
|
||||
// the resulting v2 Canvas is structurally sound in each case. Failing
|
||||
// here means either the directory contents drifted, a new fixture
|
||||
// doesn't match the v1 envelope, or the converter lost something on
|
||||
// its way to v2.
|
||||
func TestV1Examples(t *testing.T) {
|
||||
// Directory canary: the on-disk list must match v1Examples. This
|
||||
// guards against the suite silently passing because new files
|
||||
// were added without updating v1Examples, or vice versa.
|
||||
entries, err := os.ReadDir(filepath.Join("testdata", "v1_examples"))
|
||||
if err != nil {
|
||||
t.Fatalf("testdata/v1_examples not accessible: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
|
||||
got = append(got, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := append([]string(nil), v1Examples...)
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("v1_examples count = %d (%v), want %d (%v)", len(got), got, len(want), want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("v1_examples[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range v1Examples {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
raw := readV1Example(t, name)
|
||||
|
||||
// DetectVersion must report V1.
|
||||
v, err := DetectVersion(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectVersion: %v", err)
|
||||
}
|
||||
if v != V1 {
|
||||
t.Fatalf("DetectVersion = %s, want v1", v)
|
||||
}
|
||||
|
||||
// LoadV1 (explicit v1) and Load (auto-detect) must both
|
||||
// produce a valid v2 Canvas. Comparing the two
|
||||
// additionally proves the auto-detect path routed to
|
||||
// LoadV1 (the canvas shapes must match).
|
||||
explicit, err := LoadV1(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadV1: %v", err)
|
||||
}
|
||||
assertConvertedCanvasOK(t, name, explicit)
|
||||
|
||||
auto, err := Load(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
assertConvertedCanvasOK(t, name, auto)
|
||||
if len(auto.Components) != len(explicit.Components) {
|
||||
t.Errorf("Load and LoadV1 disagree on component count: %d vs %d",
|
||||
len(auto.Components), len(explicit.Components))
|
||||
}
|
||||
|
||||
// DecodeReader (io.Reader path) must produce the same
|
||||
// v2 Canvas up to component count.
|
||||
decoded, err := DecodeReader(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeReader: %v", err)
|
||||
}
|
||||
if decoded.Version != CurrentVersion {
|
||||
t.Errorf("DecodeReader Version = %d, want %d", decoded.Version, CurrentVersion)
|
||||
}
|
||||
if len(decoded.Components) != len(explicit.Components) {
|
||||
t.Errorf("DecodeReader produced %d components, LoadV1 produced %d",
|
||||
len(decoded.Components), len(explicit.Components))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertConvertedCanvasOK verifies a v1-converted Canvas is a valid v2
|
||||
// graph: non-empty components, non-empty names, no colon in ids, and
|
||||
// every Downstream ref resolves. This is the load-time check that
|
||||
// protects against drift in either the fixture or the converter.
|
||||
func assertConvertedCanvasOK(t *testing.T, name string, c *Canvas) {
|
||||
t.Helper()
|
||||
if c.Version != CurrentVersion {
|
||||
t.Errorf("[%s] Version = %d, want %d", name, c.Version, CurrentVersion)
|
||||
}
|
||||
if len(c.Components) == 0 {
|
||||
t.Fatalf("[%s] Components is empty", name)
|
||||
}
|
||||
byName := map[string]int{}
|
||||
for id, cpn := range c.Components {
|
||||
if strings.Contains(id, ":") {
|
||||
t.Errorf("[%s] v2 id %q still contains ':'", name, id)
|
||||
}
|
||||
if id != cpn.ID && cpn.ID != "" {
|
||||
t.Errorf("[%s] key %q != Component.ID %q", name, id, cpn.ID)
|
||||
}
|
||||
if cpn.Name == "" {
|
||||
t.Errorf("[%s] component %q has empty Name", name, id)
|
||||
}
|
||||
for _, ds := range cpn.Downstream {
|
||||
if _, ok := c.Components[ds]; !ok {
|
||||
t.Errorf("[%s] component %q downstream %q does not exist", name, id, ds)
|
||||
}
|
||||
}
|
||||
byName[cpn.Name]++
|
||||
}
|
||||
// Begin must be present in every fixture (they all start from a
|
||||
// user query). Catches the case where someone renames Begin in a
|
||||
// fixture and silently breaks every orchestrator that looks it up.
|
||||
if byName["Begin"] == 0 {
|
||||
t.Errorf("[%s] no Begin component found in converted canvas (have %v)", name, byName)
|
||||
}
|
||||
// We deliberately do NOT require at least one terminal node
|
||||
// here: the v1 fixtures include Answer↔ExeSQL and Answer↔Message
|
||||
// cycles (e.g. exesql.json, headhunter_zh.json) where every
|
||||
// component has a non-empty downstream by design — the cycle
|
||||
// represents the agent waiting for the next user turn. Cycle
|
||||
// detection is a runtime concern, not a load-time one, and lives
|
||||
// in the scheduler.
|
||||
//
|
||||
// Run the v2 Validate as the final gate. This catches anything
|
||||
// the per-component loop above did not (e.g. Version mismatch).
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Errorf("[%s] Validate: %v", name, err)
|
||||
}
|
||||
}
|
||||
146
internal/agent/dsl/v2.go
Normal file
146
internal/agent/dsl/v2.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package dsl provides the Go-native v2 schema, version auto-detection loader,
|
||||
// and v1->v2 converter for the RAGFlow agent canvas.
|
||||
//
|
||||
// Phase 2.5 introduces the v2 schema. The Python-era v1 DSL (with the `obj`
|
||||
// wrapper and `_feeded_deprecated_params` / `_deprecated_params` /
|
||||
// `_user_feeded_params` legacy fields) is still loadable via the converter
|
||||
// (see converter_v1_to_v2.go). v2 -> v1 round-trip is deferred to Phase 5.5.
|
||||
//
|
||||
// See plan: .claude/plans/agent-go-port.md
|
||||
// - §3.1 (In-scope)
|
||||
// - §2.11.7 (Python-era fields removed in v2)
|
||||
// - §4.6 (DSL v2 schema)
|
||||
// - §4.7 (v1 <-> v2 converter)
|
||||
// - §5 Phase 2.5
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CurrentVersion is the schema version emitted by the v2 loader and converter.
|
||||
const CurrentVersion = 2
|
||||
|
||||
// Canvas is the v2 in-memory model of a RAGFlow agent canvas.
|
||||
//
|
||||
// A Canvas is a directed graph keyed by Component.ID. v2 removes the v1
|
||||
// `obj` wrapper and the three deprecated param sets; the schema is flat.
|
||||
type Canvas struct {
|
||||
// Version is always 2 for a Canvas produced by this package.
|
||||
Version int `json:"version"`
|
||||
|
||||
// Components is keyed by Component.ID (v2 uses simple `name_id` keys,
|
||||
// e.g. "begin_0", "llm_3" — no `Name:UUID` colon-prefixed keys).
|
||||
Components map[string]Component `json:"components"`
|
||||
}
|
||||
|
||||
// Component is a single node in a v2 canvas.
|
||||
//
|
||||
// Required fields are ID, Name, Downstream, Params. Outputs is optional and
|
||||
// reserved for runtime materialization; it is not present in a freshly
|
||||
// loaded DSL.
|
||||
type Component struct {
|
||||
// ID is the canvas-local identifier. For v1-converted components, it is
|
||||
// the lowercased name + "_" + uuid, e.g. "agent_abc123".
|
||||
ID string `json:"id"`
|
||||
|
||||
// Name is the human-readable component class name, e.g. "Retrieval",
|
||||
// "LLM", "Agent". Matches v1 `obj.component_name`.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Downstream lists canvas-local IDs of components that follow this one.
|
||||
// Empty for terminal nodes.
|
||||
Downstream []string `json:"downstream"`
|
||||
|
||||
// Params is the v2 parameter bag. v1 legacy fields
|
||||
// (_feeded_deprecated_params / _deprecated_params / _user_feeded_params)
|
||||
// are dropped on conversion.
|
||||
Params map[string]any `json:"params"`
|
||||
|
||||
// Outputs is the runtime materialization slot; not present in DSL on
|
||||
// load. It is reserved for future Phase 5 execution wiring.
|
||||
Outputs map[string]any `json:"outputs,omitempty"`
|
||||
}
|
||||
|
||||
// Marshal serializes a Canvas to compact JSON bytes.
|
||||
func (c *Canvas) Marshal() ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("dsl: nil Canvas")
|
||||
}
|
||||
if c.Version == 0 {
|
||||
c.Version = CurrentVersion
|
||||
}
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// MarshalIndent serializes a Canvas to indented JSON bytes.
|
||||
func (c *Canvas) MarshalIndent() ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("dsl: nil Canvas")
|
||||
}
|
||||
if c.Version == 0 {
|
||||
c.Version = CurrentVersion
|
||||
}
|
||||
return json.MarshalIndent(c, "", " ")
|
||||
}
|
||||
|
||||
// UnmarshalV2 parses a v2 JSON byte slice into a Canvas. It rejects v1
|
||||
// payloads (no `version` field, contains `obj` wrappers) — use Load for
|
||||
// auto-detect, or LoadV1 for v1-only callers.
|
||||
func UnmarshalV2(raw []byte) (*Canvas, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var c Canvas
|
||||
if err := dec.Decode(&c); err != nil {
|
||||
return nil, fmt.Errorf("dsl: parse v2: %w", err)
|
||||
}
|
||||
if c.Version != CurrentVersion {
|
||||
return nil, fmt.Errorf("dsl: expected version %d, got %d", CurrentVersion, c.Version)
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("dsl: validate v2: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Validate checks the structural well-formedness of a v2 Canvas.
|
||||
//
|
||||
// Rules:
|
||||
// - Version must be 2.
|
||||
// - Components map must be non-empty.
|
||||
// - Each Component.Name must be non-empty.
|
||||
// - Each Downstream entry must reference an existing component ID in
|
||||
// Components (no dangling refs).
|
||||
// - No `:` in component IDs (v2 forbids the v1 `Name:UUID` convention).
|
||||
func (c *Canvas) Validate() error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("nil Canvas")
|
||||
}
|
||||
if c.Version != CurrentVersion {
|
||||
return fmt.Errorf("version=%d, expected %d", c.Version, CurrentVersion)
|
||||
}
|
||||
if len(c.Components) == 0 {
|
||||
return fmt.Errorf("components map is empty")
|
||||
}
|
||||
for id, comp := range c.Components {
|
||||
if strings.Contains(id, ":") {
|
||||
return fmt.Errorf("component id %q contains ':' (v1 legacy)", id)
|
||||
}
|
||||
if comp.Name == "" {
|
||||
return fmt.Errorf("component %q has empty Name", id)
|
||||
}
|
||||
if id != comp.ID && comp.ID != "" {
|
||||
// comp.ID is set explicitly; allow if it matches the key.
|
||||
return fmt.Errorf("component key %q does not match Component.ID %q", id, comp.ID)
|
||||
}
|
||||
for _, ds := range comp.Downstream {
|
||||
if _, ok := c.Components[ds]; !ok {
|
||||
return fmt.Errorf("component %q downstream %q does not exist", id, ds)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user