feature/tavily-search-tool #16
@@ -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;本别名用于兼容既有调用习惯。
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms); 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user