主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。 - 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次** - 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义) - 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」) - 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉) - searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变) - 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
152 lines
6.7 KiB
Go
152 lines
6.7 KiB
Go
package main
|
||
|
||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
|
||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层)。
|
||
//
|
||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||
// 而本片新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||
// ⇒ 启动报错)需要一个能单测的落点。
|
||
//
|
||
// 四条口径:
|
||
//
|
||
// 1. **后端由配置选定**:`WEB_SEARCH_PROVIDER` 选主 provider,`WEB_SEARCH_FALLBACK`
|
||
// 给降级链;各家的 key/base URL 走 `WEB_SEARCH_<NAME>_*`(design/27 §3)。
|
||
// 2. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
||
// 模型根本看不见它,而不是看得见、一调就报错。
|
||
// 3. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
||
// **启动期报错**,绝不静默回落到兜底。
|
||
// 4. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时;
|
||
// 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// web-search 的 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||
// 由 wire_web_search_test.go 逐字核对。
|
||
const (
|
||
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
|
||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
|
||
webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS"
|
||
webSearchFallbackEnv = "WEB_SEARCH_FALLBACK"
|
||
)
|
||
|
||
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||
const ioLogSource = "cmd/cyrene/wire_io_tools"
|
||
|
||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||
func (a *app) ioToolOptions() []iotools.Option {
|
||
opts := []iotools.Option{
|
||
iotools.WithKnowledgeRetriever(a.retriever),
|
||
iotools.WithLogger(a.log),
|
||
}
|
||
return append(opts, a.webSearchOptions()...)
|
||
}
|
||
|
||
// webSearchOptions 把 web-search 的配置翻译成 io 选项。
|
||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||
func (a *app) webSearchOptions() []iotools.Option {
|
||
log := logger.WithSource(a.log, ioLogSource)
|
||
ws := a.cfg.WebSearch
|
||
|
||
// ① 后端显式关闭(WEB_SEARCH_PROVIDER = 空 / off / none)⇒ 整件工具不注册。
|
||
if decision := ws.ResolveProvider(); decision.Disabled {
|
||
// 配置值**绝不写进日志**(只记键名与原因)。
|
||
log.Warn("web-search tool not registered",
|
||
"env", webSearchProviderEnv, "reason", config.WebSearchReasonOff,
|
||
"effect", "the model will not see a web-search tool at all")
|
||
return []iotools.Option{iotools.WithoutSearch()}
|
||
}
|
||
|
||
specs := a.searchProviderSpecs()
|
||
|
||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && a.selectsSearXNG() {
|
||
log.Warn("web-search tool not registered",
|
||
"env", webSearchEndpointEnv, "reason", endpoint.Reason,
|
||
"effect", "the model will not see a web-search tool at all")
|
||
return []iotools.Option{iotools.WithoutSearch()}
|
||
}
|
||
|
||
log.Info("web-search providers configured",
|
||
"env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||
"env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback,
|
||
"available", availableProviderNames(specs))
|
||
|
||
opts := []iotools.Option{
|
||
iotools.WithSearchProviders(specs...),
|
||
// 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key)
|
||
// ⇒ io 工具集在启动期报错(不许静默回落)。
|
||
iotools.WithSearchSelection(ws.Provider, ws.Fallback),
|
||
iotools.WithSearchMaxResults(ws.MaxResults),
|
||
}
|
||
switch {
|
||
case ws.Timeout > 0:
|
||
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
||
case ws.Timeout < 0:
|
||
log.Warn("web-search timeout is negative, treated as unset",
|
||
"env", webSearchTimeoutEnv, "effect", "io package default timeout applies")
|
||
}
|
||
return opts
|
||
}
|
||
|
||
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
||
func (a *app) selectsSearXNG() bool {
|
||
selected := strings.TrimSpace(a.cfg.WebSearch.Provider)
|
||
return strings.EqualFold(selected, search.NameSearXNG)
|
||
}
|
||
|
||
// searchProviderSpecs 把 `WEB_SEARCH_*` 配置翻译成各家接线(一家一行)。
|
||
//
|
||
// Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key;
|
||
// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。**
|
||
func (a *app) searchProviderSpecs() []iotools.SearchProviderSpec {
|
||
ws := a.cfg.WebSearch
|
||
endpoint := ws.ResolveEndpoint()
|
||
|
||
specs := []iotools.SearchProviderSpec{{
|
||
Name: search.NameSearXNG,
|
||
BaseURL: endpoint.URL,
|
||
Configured: endpoint.Enabled,
|
||
}}
|
||
// 各家独立一组键(design/27 §3):新增一家 = 这里加一行 + search 包加一个文件。
|
||
for _, p := range []struct{ name, key, base string }{
|
||
{search.NameZhipu, ws.ZhipuAPIKey, ws.ZhipuBaseURL},
|
||
{search.NameAliyunIQS, ws.AliyunIQSAPIKey, ws.AliyunIQSBaseURL},
|
||
{search.NameBocha, ws.BochaAPIKey, ws.BochaBaseURL},
|
||
{search.NameVolcengine, ws.VolcengineAPIKey, ws.VolcengineBaseURL},
|
||
{search.NameBaiduQianfan, ws.BaiduQianfanAPIKey, ws.BaiduQianfanBaseURL},
|
||
} {
|
||
specs = append(specs, iotools.SearchProviderSpec{
|
||
Name: p.name,
|
||
APIKey: p.key,
|
||
BaseURL: p.base,
|
||
Configured: search.Configured(p.name, p.key, p.base),
|
||
})
|
||
}
|
||
return specs
|
||
}
|
||
|
||
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
|
||
// **不含任何密钥** —— 只回名字。
|
||
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
||
out := make([]string, 0, len(specs))
|
||
for _, s := range specs {
|
||
if s.Configured {
|
||
out = append(out, s.Name)
|
||
}
|
||
}
|
||
return out
|
||
}
|