feature/tavily-search-tool #16

Merged
weichen merged 1 commits from feature/tavily-search-tool into main 2026-09-30 11:00:28 +00:00
7 changed files with 234 additions and 3 deletions
+22
View File
@@ -11,10 +11,12 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
@@ -71,6 +73,15 @@ func Run(opt Options) error {
}
setSvc := settings.NewService(st)
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
eff := setSvc.Effective(store.OwnerDefault, "")
if keys := splitKeyPool(eff.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys)
if err := tools.RegisterSearchTools(reg, provider); err != nil {
return fmt.Errorf("search tools: %w", err)
}
log.Printf("tools: tavily_search/web_search registered (%d key(s))", len(keys))
}
promptLib, err := prompts.DefaultLibrary()
if err != nil {
return fmt.Errorf("prompt library: %w", err)
@@ -260,3 +271,14 @@ func cachedInt(ttl time.Duration, fn func() int) func() int {
return val
}
}
// splitKeyPool 拆逗号分隔密钥池(trim 空段)。
func splitKeyPool(s string) []string {
var out []string
for _, k := range strings.Split(s, ",") {
if k = strings.TrimSpace(k); k != "" {
out = append(out, k)
}
}
return out
}
@@ -0,0 +1,14 @@
---
id: tool.tavily_search
layer: tool
version: 1
title: tavily_search 工具描述
description: Tavily 实时网络搜索工具的模型侧说明文案(含引用纪律)
---
实时搜索互联网获取当前信息:新闻、竞品动态、法规更新、产品规格、市场数据。参数:query(中英文关键词)、search_depth(basic 快速 / advanced 深度分析,默认 basic)、max_results(1-10,默认 5)。
使用纪律:
- 涉及"最新/当前/今天"的事实、你知识截止时间之后的事件、项目知识库未命中的外部信息,先用本工具再作答,不用记忆臆断。
- 引用搜索结果时必须附来源:结果标题(或链接域名);转述不改原意,与你的推断分开表述。
- 搜索失败(返回 [Tavily Search Failed])时依赖工作区材料与本地知识库作答,并向用户说明网络检索不可用。
- 一次一个明确检索意图;换角度改关键词再查,不重复同一查询。
@@ -0,0 +1,8 @@
---
id: tool.web_search
layer: tool
version: 1
title: web_search 工具描述
description: 网络搜索别名工具的模型侧说明文案
---
tavily_search 的别名:以关键词搜索实时互联网信息。参数 query。完整参数面(深度/条数)与引用纪律见 tavily_search——优先直接使用 tavily_search;本别名用于兼容既有调用习惯。
+3 -3
View File
@@ -14,8 +14,8 @@ func TestDefaultLibraryParses(t *testing.T) {
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(lib) < 44 {
t.Errorf("builtin library too small: %d modules (expect >=44 across layers)", len(lib))
if len(lib) < 46 {
t.Errorf("builtin library too small: %d modules (expect >=46 across layers)", len(lib))
}
seen := map[string]bool{}
validLayers := map[string]bool{core.LayerBase: true, core.LayerEnv: true, core.LayerTask: true,
@@ -46,7 +46,7 @@ func TestDefaultLibraryParses(t *testing.T) {
}
// 关键模块在位:基座五块 + 环境 + 纪要技能 + 通用子代理。
for _, id := range []string{"base.identity", "base.behavior", "base.caution", "base.tools", "base.style",
"env.runtime", "task.doc-minutes", "agent.sub.general", "aux.session-compact"} {
"env.runtime", "task.doc-minutes", "agent.sub.general", "aux.session-compact", "tool.tavily_search", "tool.web_search"} {
if !seen[id] {
t.Errorf("required module missing: %s", id)
}
+5
View File
@@ -26,6 +26,9 @@ func SystemDefaults() map[string]any {
"embedding": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
"rerank": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
},
"tools": map[string]any{
"tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具)
},
"runtime": map[string]any{
"mock": false, // true = 全部会话走 mock runner(演示/测试)
"squad": true, // 小队编排(Agent-Squad 引擎)可用
@@ -68,6 +71,8 @@ func Schema() []core.SettingDesc {
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
}
}
+97
View File
@@ -0,0 +1,97 @@
// Package tools —— 网络搜索工具:桥接 Agent-Native 的 TavilyProvider
// (密钥池轮换由其实现)到统一注册表。配置了 tools.tavily.apiKeys 才注册
// (对齐 agent-native 语义:未配置不静默降级为空结果)。
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
agenttools "agent-native/runtime/tools"
core "agent-system-core"
)
// TavilySettings 装配期读取的搜索配置。
type TavilySettings struct {
Endpoint string
APIKeys []string
}
// RegisterSearchTools 注册 tavily_search(完整参数面)与 web_search(别名)。
// 工具面与 agent-native runtime/tools/search.go 逐字段对齐(名称/参数/输出/失败降级文案)。
func RegisterSearchTools(r *Registry, provider agenttools.SearchProvider) error {
tavily := func(rc ToolExecCtx, args json.RawMessage) (string, error) {
return execSearch(provider, args)
}
if err := r.Register(core.ToolDesc{
Name: "tavily_search",
Description: "Search the live internet for real-time information, competitor intelligence, regulatory news, product specs, or market data via Tavily (with automatic API key pool rotation).",
Source: core.SourceBuiltin,
Parameters: json.RawMessage(`{
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search query keywords in Chinese or English"},
"search_depth": {"type": "string", "enum": ["basic", "advanced"], "description": "Search depth: 'basic' (fast summary) or 'advanced' (deep analysis, default 'basic')"},
"max_results": {"type": "number", "description": "Maximum number of search results to retrieve (1 to 10, default 5)"}
},
"required": ["query"]
}`),
}, tavily); err != nil {
return err
}
return r.Register(core.ToolDesc{
Name: "web_search",
Description: "Alias for tavily_search. Searches the live internet for information.",
Source: core.SourceBuiltin,
Parameters: json.RawMessage(`{
"type": "object",
"properties": {"query": {"type": "string", "description": "The search query keywords"}},
"required": ["query"]
}`),
}, tavily)
}
func execSearch(provider agenttools.SearchProvider, args json.RawMessage) (string, error) {
var params struct {
Query string `json:"query"`
SearchDepth string `json:"search_depth"`
MaxResults float64 `json:"max_results"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", err
}
query := strings.TrimSpace(params.Query)
if query == "" {
return "", fmt.Errorf("Error: query cannot be empty.")
}
resp, err := provider.Search(context.Background(), agenttools.SearchRequest{
Query: query,
Depth: params.SearchDepth,
MaxResults: int(params.MaxResults),
})
if err != nil {
// 失败对模型可见并指引回退(与 agent-native 扩展降级文案一致);
// 注册表把 err.Error() 作为输出回灌,故降级文案承载在 error 内。
return "", fmt.Errorf("[Tavily Search Failed]: %v\n(Agent should rely on workspace materials or local knowledge base as fallback.)", err)
}
return formatSearchResults(resp, query), nil
}
// formatSearchResults 输出 Markdown(对齐 agent-native 的呈现形态:答案 + 编号结果)。
func formatSearchResults(resp agenttools.SearchResponse, query string) string {
var sb strings.Builder
fmt.Fprintf(&sb, "## 🌐 Tavily 实时网络搜索结果: %q\n\n", query)
if strings.TrimSpace(resp.Answer) != "" {
fmt.Fprintf(&sb, "**概要**:%s\n\n", resp.Answer)
}
if len(resp.Results) == 0 {
sb.WriteString("(无命中结果)\n")
return sb.String()
}
for i, r := range resp.Results {
fmt.Fprintf(&sb, "%d. %s\n %s\n\n", i+1, r.Title, strings.TrimSpace(r.Content))
}
return sb.String()
}
+85
View File
@@ -0,0 +1,85 @@
package tools
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
agenttools "agent-native/runtime/tools"
core "agent-system-core"
)
type fakeProvider struct {
resp agenttools.SearchResponse
err error
}
func (f *fakeProvider) Search(ctx context.Context, req agenttools.SearchRequest) (agenttools.SearchResponse, error) {
return f.resp, f.err
}
func newSearchRegistry(t *testing.T, p agenttools.SearchProvider) *Registry {
t.Helper()
reg := NewRegistry()
if err := reg.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
if err := RegisterSearchTools(reg, p); err != nil {
t.Fatal(err)
}
return reg
}
func TestSearchToolsRegistered(t *testing.T) {
reg := newSearchRegistry(t, &fakeProvider{})
for _, name := range []string{"tavily_search", "web_search"} {
if _, _, ok := reg.Get(name); !ok {
t.Errorf("tool %s not registered", name)
}
}
}
func execSearchTool(t *testing.T, reg *Registry, name, args string) core.ToolCallResult {
t.Helper()
return reg.Execute(nil, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)})
}
func TestSearchExecuteFormatsResults(t *testing.T) {
reg := newSearchRegistry(t, &fakeProvider{resp: agenttools.SearchResponse{
Answer: "概要答案",
Results: []agenttools.SearchResult{
{Title: "结果一", Content: "内容甲", Score: 0.9},
{Title: "结果二", Content: "内容乙", Score: 0.8},
},
}})
res := execSearchTool(t, reg, "tavily_search", `{"query":"超声 法规","search_depth":"basic","max_results":5}`)
if res.Status != "ok" {
t.Fatalf("status=%s output=%q", res.Status, res.Output)
}
for _, want := range []string{"Tavily 实时网络搜索结果", "概要答案", "结果一", "内容乙"} {
if !strings.Contains(res.Output, want) {
t.Errorf("output missing %q:\n%s", want, res.Output)
}
}
}
func TestSearchEmptyQuery(t *testing.T) {
reg := newSearchRegistry(t, &fakeProvider{})
res := execSearchTool(t, reg, "web_search", `{"query":" "}`)
if res.Status == "ok" || !strings.Contains(res.Output, "query cannot be empty") {
t.Errorf("empty query: status=%s out=%q", res.Status, res.Output)
}
}
func TestSearchFailureFallback(t *testing.T) {
reg := newSearchRegistry(t, &fakeProvider{err: errors.New("all keys failed")})
res := execSearchTool(t, reg, "tavily_search", `{"query":"q"}`)
if res.Status == "ok" {
t.Error("provider failure should surface as tool error status")
}
if !strings.Contains(res.Output, "[Tavily Search Failed]") || !strings.Contains(res.Output, "fallback") {
t.Errorf("fallback copy missing: %q", res.Output)
}
}