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:
Hz_
2026-07-09 18:58:55 +08:00
committed by GitHub
parent 0083ad0deb
commit 5c8b51cbbf
8 changed files with 487 additions and 119 deletions

View File

@@ -518,5 +518,6 @@ func init() {
Register(componentNameIteration, NewIterationStub)
Register(componentNameIterationItem, NewIterationItemStub)
Register("BGPT", newBGPTComponent)
Register("Google", newGoogleComponent)
Register("YahooFinance", newYahooFinanceComponent)
}

View 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))
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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