feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)

主人定调「先把原生的做进插件,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),并有「未登记⇒启动报错」的防回归断言
This commit is contained in:
AskaEth committed 2026-10-05 22:38:20 +08:00
1 parent 236b202fda
commit 69a3b73777
24 files changed
+3251 -171

No files matched your search

+95 -11
View File
@@ -10,6 +10,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// source tags every log record emitted by this package (project rule §10).
@@ -55,6 +56,29 @@ type tools struct {
// searchTimeout 是 web-search 单次请求的超时覆盖(0 = 沿用本套工具的
// httpTimeout,也就是 io 包的既有超时 —— 这里刻意不另造默认值)。
searchTimeout time.Duration
// searchProviders 是组合根给的各家接线(WS1-B:provider 由配置选定);
// searchPrimary / searchFallback 是主 provider 与降级链。
searchProviders []SearchProviderSpec
searchPrimary string
searchFallback []string
// searchSelected 报告组合根是否**显式**选过 provider:只有显式选择才做启动期
// fail-fast 校验(io 包的默认构造路径保留 WS1-A 的历史行为:工具在、调用时报错)。
searchSelected bool
// searchMaxResults 是 web-search 自己的条数上限(0 = 沿用 maxResults)。
// 与 WithMaxResults 分开,是因为后者会一起改到 knowledge 工具的条数。
searchMaxResults int
}
// SearchProviderSpec 是组合根给 web-search 的**一家** provider 的接线(一家一行)。
//
// Name 必须是 plugins/tools/io/search 的接入位(search.Known);Configured 由组合根
// 用 search.Configured 判定后显式给出 —— 未配置的家不进注册表、不出现在可用清单里
// (design/27 §1.2 的 fail-closed)。
type SearchProviderSpec struct {
Name string
APIKey string
BaseURL string
Configured bool
}
// Option customises the tool set built by New.
@@ -86,10 +110,14 @@ func WithAllowedHosts(hosts ...string) Option {
return func(t *tools) { t.allowedHosts = append(t.allowedHosts, hosts...) }
}
// WithSearchEndpoint sets the web-search endpoint (a SearXNG-compatible URL).
// WithSearchEndpoint("") clears it so web-search reports a clear error.
// WithSearchEndpoint sets the **searxng provider's** base endpoint (a SearXNG-compatible
// URL). WithSearchEndpoint("") clears it so web-search reports a clear error.
//
// 语义与既往逐字不变。**注意**:置空只是「端点为空」,工具仍在注册面里;
// 语义与既往逐字不变(design/27 §4:现有 WithSearchEndpoint 保留为 searxng provider 的
// 配置)。组合根走配置路径时用 WithSearchProviders 给出 searxng 的端点与可用性(单一
// 事实来源);本选项保留给 io 包的既有调用方与测试。
//
// **注意**:置空只是「端点为空」,工具仍在注册面里;
// 要「模型根本看不见它」请用 WithoutSearch(组合根在端点显式关闭 / 非法时的用法)。
func WithSearchEndpoint(u string) Option {
return func(t *tools) { t.searchURL = u }
@@ -110,7 +138,8 @@ func WithoutSearch() Option {
// define a second default for search (project rule §10: one default per knob).
//
// 它只约束 web-search 这一次请求:与 WithHTTPTimeout(整套工具共用的客户端
// 超时,会同时改到 http / web-fetch)不是同一件事。
// 超时,会同时改到 http / web-fetch)不是同一件事。WS1-B 起它按**每一次上游尝试**
// 生效(降级链里每家都各有自己的预算)。
func WithSearchTimeout(d time.Duration) Option {
return func(t *tools) {
if d > 0 {
@@ -119,6 +148,35 @@ func WithSearchTimeout(d time.Duration) Option {
}
}
// WithSearchProviders 声明 web-search 可用的各家接线(WS1-B,design/27 §3)。
// 未传时退回 WS1-A 的既有路径:只有 searxng 一家(端点来自 WithSearchEndpoint)。
//
// 传进来的顺序即「可用清单」的展示顺序;Configured=false 的家不进注册表。
func WithSearchProviders(specs ...SearchProviderSpec) Option {
return func(t *tools) { t.searchProviders = append(t.searchProviders, specs...) }
}
// WithSearchSelection 选定主 provider 与降级链(primary + fallback,按序)。
// 它同时打开**启动期 fail-fast 校验**:主 provider 未登记(如 tavily)或未配置(缺 key)
// 时 New 返回错误,绝不静默回落到兜底(design/27 §3)。
func WithSearchSelection(primary string, fallback []string) Option {
return func(t *tools) {
t.searchPrimary = primary
t.searchFallback = append([]string(nil), fallback...)
t.searchSelected = true
}
}
// WithSearchMaxResults overrides web-search's own result cap (WEB_SEARCH_MAX_RESULTS).
// Non-positive values are ignored (the set's shared cap applies).
func WithSearchMaxResults(n int) Option {
return func(t *tools) {
if n > 0 {
t.searchMaxResults = n
}
}
}
// WithKnowledgeRetriever wires the knowledge tool to a runtime memory retriever.
// WithKnowledgeRetriever(nil) makes knowledge report "no knowledge base".
func WithKnowledgeRetriever(r KnowledgeRetriever) Option {
@@ -210,7 +268,7 @@ func New(cfg *config.Config, opts ...Option) ([]sdk.Tool, error) {
if t.client == nil {
t.client = newHTTPClient(t)
}
return t.build(), nil
return t.build()
}
// Tools returns the tool set built from platform defaults. Production wiring
@@ -238,24 +296,50 @@ func Register(reg sdk.Registrar) error {
}
// build assembles the tools sharing this set's client, limits and logger.
func (t *tools) build() []sdk.Tool {
func (t *tools) build() ([]sdk.Tool, error) {
file := newFileTool(t)
set := []sdk.Tool{
file,
newCropTool(t, file),
newHTTPTool(t),
}
// web-search 只有在**确实有可用端点**时才进注册面:端点显式关闭或非法时由
// 组合根传 WithoutSearch,模型便根本看不见它。顺序与既往一致(http 之后、
// web-fetch 之前),启用态下工具表的形状逐字不变。
// web-search 只有在**确实有可用后端**时才进注册面:端点显式关闭或选定 provider
// 未登记/未配置时由组合根传 WithoutSearch,模型便根本看不见它。顺序与既往一致
// (http 之后、web-fetch 之前),启用态下工具表的形状逐字不变。
if !t.searchDisabled {
set = append(set, newWebSearchTool(t))
ws, err := newWebSearchTool(t)
if err != nil {
// 启动期 fail-fast(design/27 §3):选定的 provider 有问题就不许静默回落。
return nil, err
}
set = append(set, ws)
}
return append(set,
newWebFetchTool(t),
newKnowledgeTool(t),
newDocumentReadTool(t),
)
), nil
}
// searchWires 汇总要交给搜索注册表的接线(WS1-B):
//
// - 组合根给了接线表(WithSearchProviders)⇒ 用它,是否可用由 Configured 决定;
// - 没给 ⇒ 退回 WS1-A 的既有路径:只有 searxng 一家,且**始终登记**(哪怕端点是
// 空串)—— 那是历史行为:工具在注册面里、调用时如实报「no search endpoint」。
func (t *tools) searchWires() []search.Wire {
if len(t.searchProviders) == 0 {
return []search.Wire{{Name: search.NameSearXNG, BaseURL: t.searchURL, Configured: true}}
}
wires := make([]search.Wire, 0, len(t.searchProviders))
for _, s := range t.searchProviders {
wires = append(wires, search.Wire{
Name: s.Name,
APIKey: s.APIKey,
BaseURL: s.BaseURL,
Configured: s.Configured,
})
}
return wires
}
// newHTTPClient builds a client with a unified timeout and an SSRF-guarded