From 0d55ebb94824561d453937efd75dcd876dc7d684 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 11:00:11 +0000 Subject: [PATCH] Wire Tavily web search through the prompt-backed tool face Why: Agent-Native ships a Tavily integration (key-pool rotation, markdown output, fallback copy) that the backend never registered, so the catalog's WebSearch prompt had no carrier; wiring the tool gives it one and gives the agent live-web reach. Evidence: 4 new tool tests (registration, markdown formatting, empty query, failure fallback carried inside the error to match registry replay semantics); go test ./... 16 packages ok; live boot check - no keys means not registered, two keys log registration with both tools in /api/tools and 46 library modules in /api/prompts. Impact: tools.tavily.apiKeys/endpoint settings (restart effect; empty pool means the tool is absent, no silent degradation); tool.tavily_search and tool.web_search prompt modules inject citation discipline (cite sources, keep inference separate, fall back to workspace materials on failure); library floor raised to 46. Refs: feature/tavily-search-tool task card; guide disposition table in Documents. --- internal/app/app.go | 22 +++++ .../prompts/library/tool-tavily_search.md | 14 +++ internal/prompts/library/tool-web_search.md | 8 ++ internal/prompts/prompts_test.go | 6 +- internal/settings/settings.go | 5 + internal/tools/search.go | 97 +++++++++++++++++++ internal/tools/search_test.go | 85 ++++++++++++++++ 7 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 internal/prompts/library/tool-tavily_search.md create mode 100644 internal/prompts/library/tool-web_search.md create mode 100644 internal/tools/search.go create mode 100644 internal/tools/search_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 889cba7..597b8a0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/prompts/library/tool-tavily_search.md b/internal/prompts/library/tool-tavily_search.md new file mode 100644 index 0000000..088334a --- /dev/null +++ b/internal/prompts/library/tool-tavily_search.md @@ -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])时依赖工作区材料与本地知识库作答,并向用户说明网络检索不可用。 +- 一次一个明确检索意图;换角度改关键词再查,不重复同一查询。 diff --git a/internal/prompts/library/tool-web_search.md b/internal/prompts/library/tool-web_search.md new file mode 100644 index 0000000..9f4a2fd --- /dev/null +++ b/internal/prompts/library/tool-web_search.md @@ -0,0 +1,8 @@ +--- +id: tool.web_search +layer: tool +version: 1 +title: web_search 工具描述 +description: 网络搜索别名工具的模型侧说明文案 +--- +tavily_search 的别名:以关键词搜索实时互联网信息。参数 query。完整参数面(深度/条数)与引用纪律见 tavily_search——优先直接使用 tavily_search;本别名用于兼容既有调用习惯。 diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index 10afc8e..83b158f 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -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) } diff --git a/internal/settings/settings.go b/internal/settings/settings.go index ed2d655..c5d0d79 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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}, } } diff --git a/internal/tools/search.go b/internal/tools/search.go new file mode 100644 index 0000000..e4b3a97 --- /dev/null +++ b/internal/tools/search.go @@ -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() +} diff --git a/internal/tools/search_test.go b/internal/tools/search_test.go new file mode 100644 index 0000000..a72a740 --- /dev/null +++ b/internal/tools/search_test.go @@ -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) + } +}