Files
ragflow/internal/agent/component/tool_component_test.go
Hz_ a7da78d0d7 refactor(go-agent): unify tool-backed canvas components (#16912)
## Summary

This PR consolidates Eino-backed Agent tools behind the shared
ToolBackedComponent implementation and aligns their Canvas
configuration,
runtime inputs, output conversion, validation, and registration.

## What changed

- Migrated these Canvas components to the unified tool-backed path:
    - Tavily Search and Extract
    - Execute SQL
    - Google
    - Yahoo Finance
    - Email
    - DuckDuckGo
    - Wikipedia
    - Google Scholar
    - ArXiv
    - PubMed
    - BGPT
    - GitHub
    - WenCai
    - SearXNG
    - Keenable Search

- Removed superseded component wrappers and their duplicate tests.
- Added dedicated registry builders for node-level configuration and
validation.
- Kept model-emitted runtime inputs separate from Canvas node
configuration.
- Moved Email defaults, template resolution, recipient parsing, SMTP
execution, and output conversion into the owning tool.
- Added complete ToolComponent specifications for Canvas inputs,
outputs, and input forms.
- Preserved raw upstream fields where downstream workflows may depend on
them.
- Added workflow registration coverage for all migrated component names.
- Kept HTTP Request, Docs Generator, and Browser as standalone
components because they are not Eino-backed tools.

## Testing

Passed:

bash build.sh --test ./internal/agent/tool/...
bash build.sh --test ./internal/agent/component/...
bash build.sh --test ./internal/agent/runtime/...

<img width="2057" height="1111" alt="image"
src="https://github.com/user-attachments/assets/c728d7a3-9d15-4c5c-b0eb-6b77ad0e41ac"
/>
<img width="2057" height="1111" alt="image"
src="https://github.com/user-attachments/assets/ef13119a-ef92-4b43-9061-eee9511bf492"
/>
<img width="2057" height="1111" alt="image"
src="https://github.com/user-attachments/assets/f50f2c30-eab0-463c-b0b3-0a02523219f1"
/>
2026-07-15 21:42:08 +08:00

515 lines
19 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package component
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
einotool "github.com/cloudwego/eino/components/tool"
"ragflow/internal/agent/canvas"
"ragflow/internal/agent/runtime"
agenttool "ragflow/internal/agent/tool"
)
type fakeToolAdapter struct {
args map[string]any
referenceEnvelope map[string]any
outputEnvelope map[string]any
calls int
events []string
out string
err error
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
func (f *fakeToolAdapter) InvokableRun(_ context.Context, argsJSON string, _ ...einotool.Option) (string, error) {
f.calls++
if err := json.Unmarshal([]byte(argsJSON), &f.args); err != nil {
return "", err
}
if f.out != "" || f.err != nil {
return f.out, f.err
}
return `{"results":[{"title":"RAGFlow","url":"https://ragflow.io","content":"RAG engine"}]}`, nil
}
func (f *fakeToolAdapter) ComponentSpec() agenttool.ComponentSpec {
return agenttool.ComponentSpec{
Inputs: map[string]string{"query": "Search query."},
Outputs: map[string]string{"formalized_content": "Rendered results.", "json": "Raw results."},
InputForm: map[string]any{"query": map[string]any{"name": "Query", "type": "line"}},
}
}
func (f *fakeToolAdapter) BuildReferences(_ context.Context, envelope map[string]any) ([]map[string]any, []map[string]any) {
f.referenceEnvelope = envelope
f.events = append(f.events, "references")
return []map[string]any{{"chunk_id": "1", "content": "RAG engine", "docnm_kwd": "RAGFlow"}},
[]map[string]any{{"doc_name": "RAGFlow", "doc_id": "1", "count": 1}}
}
func (f *fakeToolAdapter) BuildComponentOutputs(envelope map[string]any) map[string]any {
f.outputEnvelope = envelope
f.events = append(f.events, "render")
formalizedContent := ""
results := anySlice(envelope["results"])
if len(results) > 0 {
if result, ok := results[0].(map[string]any); ok {
formalizedContent, _ = result["content"].(string)
}
}
return map[string]any{
"json": results,
"formalized_content": formalizedContent,
"tool_metadata": envelope["tool_metadata"],
}
}
func TestToolBackedComponentRegisteredGitHubFactory(t *testing.T) {
c, err := New("GitHub", map[string]any{
"top_n": float64(10),
"query": "runtime query",
"outputs": map[string]any{"json": map[string]any{}},
"setups": map[string]any{"query": "configured query"},
})
if err != nil {
t.Fatalf("New(GitHub): %v", err)
}
if _, ok := c.(*ToolBackedComponent); !ok {
t.Fatalf("New(GitHub) returned %T, want *ToolBackedComponent", c)
}
if c.Name() != "GitHub" {
t.Fatalf("Name = %q, want GitHub", c.Name())
}
form := c.(interface{ GetInputForm() map[string]any }).GetInputForm()
if query, ok := form["query"].(map[string]any); !ok || query["type"] != "line" {
t.Fatalf("query input form = %#v, want line", form["query"])
}
}
func TestToolBackedComponentRegisteredFactories(t *testing.T) {
tests := []struct {
name string
toolName string
params map[string]any
outputKey string
inputKey string
}{
{
name: "ArXiv",
toolName: "ArXiv",
params: map[string]any{"top_n": float64(3), "sort_by": "relevance", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "BGPT",
toolName: "BGPT",
params: map[string]any{"api_key": "stored-key", "top_n": float64(3), "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "KeenableSearch",
toolName: "KeenableSearch",
params: map[string]any{"api_key": "stored-key", "mode": "realtime", "top_n": float64(3), "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "PubMed",
toolName: "PubMed",
params: map[string]any{"top_n": float64(3), "email": "node@example.com", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "Google",
toolName: "Google",
params: map[string]any{"api_key": "stored-key", "country": "cn", "language": "en", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "q",
},
{
name: "ExeSQL",
toolName: "ExeSQL",
params: map[string]any{
"database": "demo", "username": "root", "host": "db.example.com", "port": float64(3306), "password": "secret",
"top_n": float64(50), "outputs": map[string]any{"json": map[string]any{}},
},
outputKey: "json",
inputKey: "sql",
},
{
name: "GoogleScholar",
toolName: "GoogleScholar",
params: map[string]any{"top_n": float64(3), "sort_by": "relevance", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "DuckDuckGo",
toolName: "DuckDuckGo",
params: map[string]any{"top_n": float64(3), "channel": "news", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "Email",
toolName: "Email",
params: map[string]any{
"smtp_server": "smtp.example.com", "smtp_port": float64(465), "email": "sender@example.com",
"password": "secret", "sender_name": "Sender", "outputs": map[string]any{"success": map[string]any{}},
},
outputKey: "success",
inputKey: "to_email",
},
{
name: "SearXNG",
toolName: "SearXNG",
params: map[string]any{"top_n": "10", "searxng_url": "https://searx.example.com", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "WenCai",
toolName: "WenCai",
params: map[string]any{"top_n": float64(20), "query_type": "stock", "outputs": map[string]any{"report": map[string]any{}}},
outputKey: "report",
inputKey: "query",
},
{
name: "Wikipedia",
toolName: "Wikipedia",
params: map[string]any{"top_n": float64(3), "language": "en", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
{
name: "YahooFinance",
toolName: "YahooFinance",
params: map[string]any{"outputs": map[string]any{"report": map[string]any{}}},
outputKey: "report",
inputKey: "stock_code",
},
{
name: "TavilyExtract",
toolName: "TavilyExtract",
params: map[string]any{"api_key": "stored-key", "extract_depth": "advanced", "format": "text", "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "urls",
},
{
name: "TavilySearch",
toolName: "TavilySearch",
params: map[string]any{"api_key": "stored-key", "search_depth": "advanced", "max_results": float64(3), "outputs": map[string]any{"json": map[string]any{}}},
outputKey: "json",
inputKey: "query",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, err := New(tt.toolName, tt.params)
if err != nil {
t.Fatalf("New(%s): %v", tt.toolName, err)
}
if _, ok := c.(*ToolBackedComponent); !ok {
t.Fatalf("New(%s) returned %T, want *ToolBackedComponent", tt.toolName, c)
}
if c.Name() != tt.toolName {
t.Fatalf("Name = %q, want %q", c.Name(), tt.toolName)
}
if _, ok := c.Inputs()[tt.inputKey]; !ok {
t.Fatalf("Inputs missing %q: %#v", tt.inputKey, c.Inputs())
}
if _, ok := c.Outputs()[tt.outputKey]; !ok {
t.Fatalf("Outputs missing %q: %#v", tt.outputKey, c.Outputs())
}
})
}
}
func TestToolBackedComponentWenCaiInvoke(t *testing.T) {
c, err := New("WenCai", map[string]any{"top_n": float64(20), "query_type": "stock"})
if err != nil {
t.Fatalf("New(WenCai): %v", err)
}
out, err := c.Invoke(context.Background(), map[string]any{
"query": "商业航天",
"unrelated": "ignored by the tool parameter struct",
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if out["report"] != "" {
t.Fatalf("outputs = %#v, want empty report", out)
}
}
func TestToolBackedComponentRegisteredBuildWorkflow(t *testing.T) {
for _, componentName := range []string{"ArXiv", "BGPT", "DuckDuckGo", "Email", "Google", "GoogleScholar", "KeenableSearch", "PubMed", "SearXNG", "WenCai", "TavilyExtract", "TavilySearch", "Wikipedia", "YahooFinance"} {
t.Run(componentName, func(t *testing.T) {
c := &canvas.Canvas{
Components: map[string]canvas.CanvasComponent{
"begin_0": {
Obj: canvas.CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
Downstream: []string{"tool_0"},
},
"tool_0": {
Obj: canvas.CanvasComponentObj{ComponentName: componentName, Params: map[string]any{}},
Upstream: []string{"begin_0"},
},
},
Path: []string{"begin_0", "tool_0"},
}
if _, err := canvas.BuildWorkflow(context.Background(), c); err != nil {
t.Fatalf("BuildWorkflow with %s: %v", componentName, err)
}
})
}
}
func TestToolBackedComponentCanvasBuildWorkflow(t *testing.T) {
c := &canvas.Canvas{
Components: map[string]canvas.CanvasComponent{
"begin_0": {
Obj: canvas.CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
Downstream: []string{"github_0"},
},
"github_0": {
Obj: canvas.CanvasComponentObj{ComponentName: "GitHub", Params: map[string]any{"top_n": float64(10)}},
Upstream: []string{"begin_0"},
},
},
Path: []string{"begin_0", "github_0"},
}
if _, err := canvas.BuildWorkflow(context.Background(), c); err != nil {
t.Fatalf("BuildWorkflow with GitHub: %v", err)
}
}
func TestToolBackedComponentInvokeOrdersReferencesBeforeRendering(t *testing.T) {
fake := &fakeToolAdapter{out: `{"results":[{"title":"RAGFlow","content":"RAG engine"}],"tool_metadata":{"request_id":"request-1"}}`}
c := &ToolBackedComponent{name: "Search", tool: fake, spec: fake.ComponentSpec()}
state := runtime.NewCanvasState("run", "task")
out, err := c.Invoke(runtime.WithState(context.Background(), state), map[string]any{
"query": "ragflow",
"top_n": float64(10),
"outputs": map[string]any{"json": map[string]any{}},
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if fake.args["query"] != "ragflow" {
t.Fatalf("runtime args = %#v", fake.args)
}
if fake.args["top_n"] != float64(10) || fake.args["outputs"] == nil {
t.Fatalf("generic component did not pass all inputs: %#v", fake.args)
}
if !reflect.DeepEqual(fake.events, []string{"references", "render"}) {
t.Fatalf("post-process order = %#v, want references then render", fake.events)
}
if fake.referenceEnvelope["tool_metadata"] == nil || fake.outputEnvelope["tool_metadata"] == nil {
t.Fatalf("complete tool envelope was not passed to post-processors: references=%#v outputs=%#v", fake.referenceEnvelope, fake.outputEnvelope)
}
if _, exists := fake.outputEnvelope["chunks"]; exists {
t.Fatalf("generic component injected references into the tool envelope: %#v", fake.outputEnvelope)
}
if metadata, ok := out["tool_metadata"].(map[string]any); !ok || metadata["request_id"] != "request-1" {
t.Fatalf("tool-specific envelope fields were lost: %#v", out)
}
if out["formalized_content"] != "RAG engine" {
t.Fatalf("formalized_content = %#v", out["formalized_content"])
}
if len(state.GetRetrievalChunks()) != 1 {
t.Fatalf("retrieval chunks = %#v", state.GetRetrievalChunks())
}
}
func TestToolBackedComponentReturnsErrorEnvelopeWithoutReferences(t *testing.T) {
fake := &fakeToolAdapter{
out: `{"results":[],"_ERROR":"rate limited"}`,
err: errors.New("rate limited"),
}
c := &ToolBackedComponent{name: "Search", tool: fake, spec: fake.ComponentSpec()}
state := runtime.NewCanvasState("run-error", "task-error")
out, err := c.Invoke(runtime.WithState(context.Background(), state), map[string]any{"query": "ragflow"})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if out["_ERROR"] != "rate limited" || out["formalized_content"] != "" {
t.Fatalf("error outputs = %#v", out)
}
if results, ok := out["json"].([]any); !ok || len(results) != 0 {
t.Fatalf("error json output = %#v, want an empty array", out["json"])
}
if len(state.GetRetrievalChunks()) != 0 {
t.Fatalf("error path recorded references: %#v", state.GetRetrievalChunks())
}
}
func TestToolBackedComponentGitHubIntegration(t *testing.T) {
serverCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
serverCalls++
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"items":[{"name":"ragflow","html_url":"https://github.com/infiniflow/ragflow","description":"RAG engine","watchers":12000,"private":false}]}`))
}))
defer server.Close()
target, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse test server URL: %v", err)
}
helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
cloned := request.Clone(request.Context())
cloned.URL.Scheme = target.Scheme
cloned.URL.Host = target.Host
return http.DefaultTransport.RoundTrip(cloned)
})})
github := agenttool.NewGitHubToolWith(helper)
component := &ToolBackedComponent{name: "GitHub", tool: github, spec: github.ComponentSpec()}
state := runtime.NewCanvasState("run-github", "task-github")
empty, err := component.Invoke(context.Background(), map[string]any{"query": ""})
if err != nil {
t.Fatalf("Invoke(empty query): %v", err)
}
if serverCalls != 0 || len(empty["json"].([]any)) != 0 || empty["formalized_content"] != "" {
t.Fatalf("empty query result = %#v, server calls = %d", empty, serverCalls)
}
out, err := component.Invoke(runtime.WithState(context.Background(), state), map[string]any{"query": "ragflow"})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
rendered, _ := out["formalized_content"].(string)
if !strings.Contains(rendered, "Title: ragflow") || !strings.Contains(rendered, "RAG engine\n stars:12000") {
t.Fatalf("formalized_content = %q", rendered)
}
results, ok := out["json"].([]any)
if !ok || len(results) != 1 || results[0].(map[string]any)["private"] != false {
t.Fatalf("raw json results = %#v", out["json"])
}
chunks := state.GetRetrievalChunks()
if len(chunks) != 1 || chunks[0]["document_name"] != "ragflow" {
t.Fatalf("recorded references = %#v", chunks)
}
}
func TestToolBackedComponentTavilyIntegration(t *testing.T) {
serverCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
serverCalls++
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"results":[{"title":"RAGFlow","url":"https://ragflow.io","raw_content":"RAG article","content":"fallback","score":0.8,"custom":"preserved"}]}`))
}))
defer server.Close()
target, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse test server URL: %v", err)
}
helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
cloned := request.Clone(request.Context())
cloned.URL.Scheme = target.Scheme
cloned.URL.Host = target.Host
return http.DefaultTransport.RoundTrip(cloned)
})})
tavily := agenttool.NewTavilyToolWith(helper)
component := &ToolBackedComponent{name: "TavilySearch", tool: tavily, spec: tavily.ComponentSpec()}
state := runtime.NewCanvasState("run-tavily", "task-tavily")
empty, err := component.Invoke(context.Background(), map[string]any{"query": ""})
if err != nil {
t.Fatalf("Invoke(empty query): %v", err)
}
if serverCalls != 0 || len(empty["json"].([]any)) != 0 || empty["formalized_content"] != "" {
t.Fatalf("empty query result = %#v, server calls = %d", empty, serverCalls)
}
out, err := component.Invoke(runtime.WithState(context.Background(), state), map[string]any{"query": "ragflow", "api_key": "key"})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
rendered, _ := out["formalized_content"].(string)
if !strings.Contains(rendered, "Title: RAGFlow") || !strings.Contains(rendered, "RAG article") {
t.Fatalf("formalized_content = %q", rendered)
}
results, ok := out["json"].([]any)
if !ok || len(results) != 1 || results[0].(map[string]any)["custom"] != "preserved" {
t.Fatalf("raw json results = %#v", out["json"])
}
chunks := state.GetRetrievalChunks()
if len(chunks) != 1 || chunks[0]["document_name"] != "RAGFlow" || chunks[0]["similarity"] != float64(0.8) {
t.Fatalf("recorded references = %#v", chunks)
}
}
func TestToolBackedComponentYahooFinanceIntegration(t *testing.T) {
serverCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
serverCalls++
if symbols := request.URL.Query().Get("symbols"); symbols != "AAPL" {
t.Errorf("symbols = %q", symbols)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"quoteResponse":{"result":[{"symbol":"AAPL","regularMarketPrice":189.5,"currency":"USD"}],"error":null}}`))
}))
defer server.Close()
target, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse test server URL: %v", err)
}
helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
cloned := request.Clone(request.Context())
cloned.URL.Scheme = target.Scheme
cloned.URL.Host = target.Host
return http.DefaultTransport.RoundTrip(cloned)
})})
yahoo := agenttool.NewYahooFinanceToolWith(helper)
component := &ToolBackedComponent{name: "YahooFinance", tool: yahoo, spec: yahoo.ComponentSpec()}
empty, err := component.Invoke(context.Background(), map[string]any{"stock_code": ""})
if err != nil {
t.Fatalf("Invoke(empty stock_code): %v", err)
}
if serverCalls != 0 || empty["report"] != "" {
t.Fatalf("empty result = %#v, server calls = %d", empty, serverCalls)
}
out, err := component.Invoke(context.Background(), map[string]any{"stock_code": "AAPL"})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
report, ok := out["report"].(string)
if !ok || !strings.Contains(report, "# Information:") || !strings.Contains(report, "| symbol | AAPL |") {
t.Fatalf("report = %#v", out["report"])
}
if serverCalls != 1 {
t.Fatalf("server calls = %d, want 1", serverCalls)
}
}