mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-12 11:43:39 +08:00
fix(go-agent): add Google search wrapper component and tool registry (#16768)
### Summary - Implemented googleComponent wrapper to bridge the canvas component contract with Eino's SerpApi-backed GoogleTool. - Added parameter alias mapping (query to q, max_results to num) and content formatting logic to match Python search result representation. - Registered the "Google" component and the "google" tool factory in the Go agent runtime to support web search nodes. <img width="1776" height="1092" alt="image" src="https://github.com/user-attachments/assets/e295ab88-e48c-4fe2-bcb7-47ca5b977c9b" />
This commit is contained in:
@@ -518,5 +518,6 @@ func init() {
|
||||
Register(componentNameIteration, NewIterationStub)
|
||||
Register(componentNameIterationItem, NewIterationItemStub)
|
||||
Register("BGPT", newBGPTComponent)
|
||||
Register("Google", newGoogleComponent)
|
||||
Register("YahooFinance", newYahooFinanceComponent)
|
||||
}
|
||||
|
||||
71
internal/agent/component/google_component_test.go
Normal file
71
internal/agent/component/google_component_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// 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"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoogleComponent_RegisteredAndInputForm(t *testing.T) {
|
||||
c, err := New("Google", map[string]any{
|
||||
"api_key": "",
|
||||
"country": "us",
|
||||
"language": "en",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(Google): %v", err)
|
||||
}
|
||||
if got := c.Name(); got != "Google" {
|
||||
t.Fatalf("Name() = %q, want Google", got)
|
||||
}
|
||||
formGetter, ok := c.(interface{ GetInputForm() map[string]any })
|
||||
if !ok {
|
||||
t.Fatal("Google component does not expose GetInputForm")
|
||||
}
|
||||
form := formGetter.GetInputForm()
|
||||
if _, ok := form["q"]; !ok {
|
||||
t.Fatalf("GetInputForm missing q: %+v", form)
|
||||
}
|
||||
if _, ok := c.Outputs()["formalized_content"]; !ok {
|
||||
t.Fatal("Outputs() missing formalized_content")
|
||||
}
|
||||
if _, ok := c.Outputs()["json"]; !ok {
|
||||
t.Fatal("Outputs() missing json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleComponent_MissingAPIKeyMatchesToolError(t *testing.T) {
|
||||
c, err := New("Google", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("New(Google): %v", err)
|
||||
}
|
||||
out, err := c.Invoke(context.Background(), map[string]any{"q": "ragflow"})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke returned error: %v", err)
|
||||
}
|
||||
if got, _ := out["_ERROR"].(string); !strings.Contains(got, "api_key") {
|
||||
t.Fatalf("_ERROR = %q, want api_key error (out=%+v)", got, out)
|
||||
}
|
||||
if got, ok := out["formalized_content"].(string); !ok || got != "" {
|
||||
t.Fatalf("formalized_content = %#v, want empty string", out["formalized_content"])
|
||||
}
|
||||
if got := anySlice(out["json"]); len(got) != 0 {
|
||||
t.Fatalf("json len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
@@ -79,3 +79,30 @@ func TestPhase3_6_ToolDSLLoading(t *testing.T) {
|
||||
t.Errorf("Tools not preserved: %v", captured.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_GoogleToolDSLParamsLoading(t *testing.T) {
|
||||
c := NewAgentComponent(AgentParam{
|
||||
ModelID: "stub",
|
||||
MaxRounds: 1,
|
||||
Tools: []string{"google"},
|
||||
ToolParams: map[string]map[string]any{
|
||||
"google": {
|
||||
"api_key": "KEY",
|
||||
"country": "us",
|
||||
"language": "en",
|
||||
},
|
||||
},
|
||||
})
|
||||
form := c.GetInputForm()
|
||||
googleForm, ok := form["google"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("GetInputForm missing google tool form: %+v", form)
|
||||
}
|
||||
if _, ok := googleForm["q"]; !ok {
|
||||
t.Fatalf("google tool form missing q: %+v", googleForm)
|
||||
}
|
||||
|
||||
if _, err := buildAgentTools(c.param); err != nil {
|
||||
t.Fatalf("buildAgentTools with google params: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,132 @@ func (c *tavilySearchComponent) Stream(_ context.Context, _ map[string]any) (<-c
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// googleComponent wraps internal/agent/tool/GoogleTool for canvas execution and
|
||||
// adapts the tool envelope to the Google component outputs.
|
||||
type googleComponent struct {
|
||||
inner *agenttool.GoogleTool
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
func newGoogleComponent(params map[string]any) (Component, error) {
|
||||
cloned := make(map[string]any, len(params))
|
||||
for k, v := range params {
|
||||
cloned[k] = v
|
||||
}
|
||||
return &googleComponent{inner: agenttool.NewGoogleTool(), params: cloned}, nil
|
||||
}
|
||||
|
||||
func (c *googleComponent) Name() string { return "Google" }
|
||||
|
||||
func (c *googleComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"q": "Search query.",
|
||||
"api_key": "SerpApi API key.",
|
||||
"start": "Result offset.",
|
||||
"num": "Maximum number of results.",
|
||||
"country": "Google country code.",
|
||||
"language": "Google language code.",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *googleComponent) GetInputForm() map[string]any {
|
||||
return agenttool.NewGoogleTool().InputForm()
|
||||
}
|
||||
|
||||
func (c *googleComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"formalized_content": "Rendered search results for downstream LLM prompts.",
|
||||
"json": "Raw Google organic result list.",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *googleComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
merged := make(map[string]any, len(c.params)+len(inputs)+2)
|
||||
for k, v := range c.params {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range inputs {
|
||||
merged[k] = v
|
||||
}
|
||||
if _, ok := merged["q"]; !ok {
|
||||
if query, ok := merged["query"]; ok {
|
||||
merged["q"] = query
|
||||
}
|
||||
}
|
||||
if _, ok := merged["num"]; !ok {
|
||||
if maxResults, ok := merged["max_results"]; ok {
|
||||
merged["num"] = maxResults
|
||||
}
|
||||
}
|
||||
|
||||
argsJSON, _ := json.Marshal(merged)
|
||||
out, err := c.inner.InvokableRun(ctx, string(argsJSON))
|
||||
decoded := parseToolEnvelope(out)
|
||||
results := anySlice(decoded["organic_results"])
|
||||
if len(results) == 0 {
|
||||
results = anySlice(decoded["results"])
|
||||
}
|
||||
formalized := renderGoogleResults(results)
|
||||
if existing, _ := decoded["_ERROR"].(string); strings.TrimSpace(existing) != "" {
|
||||
return map[string]any{"formalized_content": formalized, "json": results, "_ERROR": existing}, nil
|
||||
}
|
||||
if err != nil {
|
||||
if len(decoded) > 0 {
|
||||
return map[string]any{"formalized_content": formalized, "json": results, "_ERROR": decoded["_ERROR"]}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("canvas: Google: %w", err)
|
||||
}
|
||||
return map[string]any{"formalized_content": formalized, "json": results}, nil
|
||||
}
|
||||
|
||||
func (c *googleComponent) Stream(_ context.Context, _ map[string]any) (<-chan map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func renderGoogleResults(results []any) string {
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
blocks := make([]string, 0, len(results))
|
||||
for _, item := range results {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(stringParam(m["title"]))
|
||||
link := strings.TrimSpace(stringParam(m["link"]))
|
||||
content := strings.TrimSpace(stringParam(m["snippet"]))
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(googleAboutDescription(m["about_this_result"]))
|
||||
}
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lines := []string{}
|
||||
if title != "" {
|
||||
lines = append(lines, "Title: "+title)
|
||||
}
|
||||
if link != "" {
|
||||
lines = append(lines, "URL: "+link)
|
||||
}
|
||||
lines = append(lines, "Content: "+content)
|
||||
blocks = append(blocks, strings.Join(lines, "\n"))
|
||||
}
|
||||
return strings.Join(blocks, "\n\n")
|
||||
}
|
||||
|
||||
func googleAboutDescription(v any) string {
|
||||
about, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
source, ok := about["source"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return stringParam(source["description"])
|
||||
}
|
||||
|
||||
// tavilyExtractComponent delegates to internal/agent/tool/TavilyExtractTool.
|
||||
type tavilyExtractComponent struct {
|
||||
inner *agenttool.TavilyExtractTool
|
||||
@@ -1181,6 +1307,7 @@ func (c *yahooFinanceComponent) Stream(_ context.Context, _ map[string]any) (<-c
|
||||
var (
|
||||
_ Component = (*retrievalComponent)(nil)
|
||||
_ Component = (*tavilySearchComponent)(nil)
|
||||
_ Component = (*googleComponent)(nil)
|
||||
_ Component = (*tavilyExtractComponent)(nil)
|
||||
_ Component = (*exesqlComponent)(nil)
|
||||
_ Component = (*codeExecComponent)(nil)
|
||||
@@ -1190,5 +1317,6 @@ var (
|
||||
// Compile-time check that the eino InvokableTool methods we call
|
||||
// are reachable (catches a future refactor that renames them).
|
||||
var _ einotool.InvokableTool = (*agenttool.TavilyTool)(nil)
|
||||
var _ einotool.InvokableTool = (*agenttool.GoogleTool)(nil)
|
||||
var _ einotool.InvokableTool = (*agenttool.TavilyExtractTool)(nil)
|
||||
var _ einotool.InvokableTool = (*agenttool.YahooFinanceTool)(nil)
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
// 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
|
||||
// at plan completion, plus v1 fixture wrappers/stubs (including Retrieval,
|
||||
// TavilySearch, TavilyExtract, ExeSQL, Google, BGPT, YahooFinance, Generate,
|
||||
// Answer, Iteration, and IterationItem) registered by fixture_stubs.go to keep
|
||||
// the dsl-examples and canvas tool surface compiling. The test allows counts
|
||||
// between 12 (P0+P1 minus the removed ExitLoop) and 34 (the 22 plan components
|
||||
// plus the wrappers/stubs currently registered by fixture_stubs.go) to roll
|
||||
// forward as subsequent batches land.
|
||||
//
|
||||
// Note: ExitLoop is intentionally NOT in the registry anymore. The
|
||||
@@ -44,8 +45,8 @@ func TestVerifyRegistration_P1(t *testing.T) {
|
||||
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 > 33 {
|
||||
t.Errorf("expected 12-33 registered (current plan scope + v1 wrappers/stubs), got %d: %v", got, names)
|
||||
if got := len(names); got < 12 || got > 34 {
|
||||
t.Errorf("expected 12-34 registered (current plan scope + v1 wrappers/stubs), got %d: %v", got, names)
|
||||
}
|
||||
|
||||
// ExitLoop must NOT be in the registry (legacy compat lives at
|
||||
|
||||
Reference in New Issue
Block a user