Files
Cyrene_v2/plugins/tools/io/web_search.go
T
AskaEth bff57bd1ae
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 结果条数上限 5 → 10,并让「上限」真的是上限
主人原话:「返回五条结果会不会太少了?」。裁定:**上限抬高到 10,且它是真上限**
(模型要 50 也只给 10);模型要更少(3)时**尊重模型**;缺省/非法(≤0/非数)⇒ 上限。

- plugins/tools/io/web_search.go:`n := resultLimit(s.maxResults, m)` 一处收口
  (原来是 `if count>0 { n = count }` ⇒ 模型的 count 能顶掉配置上限,与
  .env.example 写的「条数上限」不符 —— 那是一条真口径缺陷,本次一并修正)。
- internal/config/websearch.go:`WEB_SEARCH_MAX_RESULTS` 代码默认值 5 → 10
  (三处口径现已一致:env 默认 10 / io 包 defaultMaxResults 10 / 各家发给上游的
  请求区间是另一回事,未动)。
- 生产:`data/config/plugins/web-search.json` 的 `maxResults` 5 → 10 + 一次
  saveProvider 触发**热重载**(applied+registered),盘上已生效。

验收(主会话复跑 + 亲自打两个变异):
- io 包 / internal/config / cmd/cyrene 相关用例全绿;
- 变异 A(拆掉上限、恒取 50)⇒ **4 条用例变红**(含「默认上限 10」「count 非数值
  ⇒ 上限」「count=0 ⇒ 上限」「既有 MaxResultsOption」);
- 变异 B(默认值退回 5)⇒ internal/config 的默认值断言变红。

🔴 顺带挖出一条真缺陷(**本片只报不修,等派**):`maxResults` **在面板 UI 上
根本改不了** —— ① `search/panel.go` 的字段表里没有这个字段(输入框不显示);
② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;③ `mergeSelectionPatch`
只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**(「我改了它却没变」
成立)。修它要给面板加一个数字字段 + 三态落 `f.MaxResults`,属**管理面板对外契约
变更**(须同步 Docs/api-client.md),留给主会话。
2026-10-07 10:58:40 +08:00

352 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package io
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// Compile-time assertion that webSearchTool implements sdk.Tool.
var _ sdk.Tool = (*webSearchTool)(nil)
// webSearchTool 是**一把**能力面对模型的工具(design/27 §1.1:能力 ≠ 单一后端,
// 换 provider 不改工具 schema)。它把「选谁当后端」交给注入的注册表,
// 并按 主 provider → 降级链 依次尝试(design/27 §3)。
type webSearchTool struct {
client *http.Client
registry *search.Registry
// primary 是主 provider 名;fallback 是降级链(按序)。
primary string
fallback []string
// allowedHosts 是**本工具自己**那份 SSRF 白名单(含配置里各 provider 的主机),
// 不是 http / web-fetch 共用的那份(见 newWebSearchTool 的说明)。
allowedHosts []string
// endpoint 是 searxng provider 的端点(WS1-A 的 WEB_SEARCH_ENDPOINT 语义,
// 保留为字段以便既有测试与诊断继续可用)。
endpoint string
maxBytes int64
maxResults int
log *slog.Logger
// timeout 是**每次**上游尝试的超时预算(0 = 沿用共享客户端自己的超时)。
// 按「每次尝试」而不是「整个调用」计,降级链才真的有意义:主家超时后,
// 备家仍有自己的预算可用。
timeout time.Duration
}
// newWebSearchTool 构造工具:汇总接线 → 校验选定 → 建注册表 → 按各家主机加宽
// **本工具自己**的 SSRF 白名单。
//
// SSRF 说明(沿用 WS1-A 的口径):端点是**运维配置**、不是模型输入(模型的 query 只
// 进查询串),所以把配置里的 provider 主机并入 web-search 自己的白名单**不会**给模型
// 打开新的目标面——工具能去的主机恒等于配置里的那几个。http / web-fetch 共用白名单
// 保持不变(TestWebSearch_DoesNotWidenSharedAllowlist 钉住)。
func newWebSearchTool(t *tools) (*webSearchTool, error) {
wires := t.searchWires()
primary := strings.TrimSpace(t.searchPrimary)
if primary == "" {
primary = search.NameSearXNG
}
// 校验只在「配置路径」生效(组合根显式给了选择或接线表):io 包的既有默认构造
// (WS1-A 之前就在的调用方与测试)刻意保留「工具在、调用时如实报错」的历史行为。
if t.searchSelected || len(t.searchProviders) > 0 {
if err := checkSearchSelection(primary, wires); err != nil {
return nil, err
}
}
allowedHosts := t.allowedHosts
for _, w := range wires {
allowedHosts = withSearchEndpointHost(allowedHosts, w.BaseURL)
}
client := t.clientFor(allowedHosts)
maxResults := t.maxResults
if t.searchMaxResults > 0 {
maxResults = t.searchMaxResults
}
return &webSearchTool{
client: client,
registry: search.Build(wires, client, t.maxHTTPBytes, t.searchGuard(allowedHosts)),
primary: primary,
fallback: t.searchFallback,
allowedHosts: allowedHosts,
// endpoint 是**searxng provider 实际用的端点**(诊断/既有测试用):从接线表里
// 取,因此无论走 WithSearchEndpoint 还是组合根的接线表,它都反映真实取值。
endpoint: searxngEndpoint(wires),
maxBytes: t.maxHTTPBytes,
maxResults: maxResults,
log: t.log,
timeout: t.searchTimeout,
}, nil
}
// searxngEndpoint 从接线表里取出 searxng 那一条的基地址(没有则空串)。
func searxngEndpoint(wires []search.Wire) string {
for _, w := range wires {
if strings.EqualFold(w.Name, search.NameSearXNG) {
return w.BaseURL
}
}
return ""
}
// checkSearchSelection 是启动期校验(design/27 §3 的语义硬约束):
//
// - 名字**不认识**(写错了 / 本期没做,如 tavily)⇒ ErrUnknownProvider;
// - 名字认识但**没配齐**(云家缺 key、searxng 缺端点)⇒ ErrNotConfigured。
//
// 两者都让启动显式失败,**绝不静默回落到兜底** —— 否则「我配了 Tavily」就是一句假话。
func checkSearchSelection(primary string, wires []search.Wire) error {
if !search.Known(primary) {
return fmt.Errorf("io: web-search: %w: %q (known: %s)",
search.ErrUnknownProvider, primary, strings.Join(search.KnownNames(), ", "))
}
for _, w := range wires {
if strings.EqualFold(w.Name, primary) && w.Configured {
return nil
}
}
return fmt.Errorf("io: web-search: %w: %q (its API key / base URL is missing)", search.ErrNotConfigured, primary)
}
// searchGuard 造**本工具自己**的 SSRF 守卫:复用 io 包既有的 validateRequestURL 与
// 「配置里各 provider 主机」那份加宽白名单(allowedHosts),因此
// - 端点/基地址是**运维配置**(不是模型输入)⇒ 回环的本机兜底仍然可达;
// - 模型只能改查询串,改不了主机 ⇒ 放行这几个主机不给模型开新的目标面;
// - http / web-fetch 共用的那份白名单**不受影响**(见 clientFor 的分工)。
func (t *tools) searchGuard(allowedHosts []string) search.Guard {
return func(ctx context.Context, rawURL string) error {
if _, err := validateRequestURL(ctx, rawURL, allowedHosts); err != nil {
// 守卫的判定已在 validateRequestURL 里,这里只补上是谁拦的(保持 %w 链)。
return fmt.Errorf("io: web-search: %w", err)
}
return nil
}
}
// Name implements sdk.Tool.
func (s *webSearchTool) Name() string { return "web-search" }
// Description implements sdk.Tool. 工具面对模型只有这一把,后端由运维配置决定。
func (s *webSearchTool) Description() string {
return "Search the web through the configured search provider and return ranked results."
}
// Parameters implements sdk.Tool. Schema 与既往逐字一致(design/27 §4:不破坏契约)。
func (s *webSearchTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Search query."},
"count": map[string]any{"type": "integer", "description": "Max results to return."},
},
"required": []any{"query"},
}, nil
}
// Execute implements sdk.Tool.
func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("io: web-search: args must be an object")
}
query := strings.TrimSpace(argString(m, "query"))
if query == "" {
return nil, fmt.Errorf("io: web-search: query is required")
}
n := resultLimit(s.maxResults, m)
raw, provider, err := s.dispatch(ctx, query, n)
if err != nil {
return nil, err
}
// 归一化之后**统一做一次**后处理:域名过滤/去重/排序/截断(design/27 §4)。
// ⚠️ 这里判的是「**归一化之后**确实一条都没有」,不是「上游没给链接」—— 后者曾被
// Finalize 当成没有结果(2026-10-07 智谱真机缺陷),现在无链接的条目照原样保留。
results := search.Finalize(raw, search.Options{Limit: n})
if len(results) == 0 {
// 空结果 ≠ 成功(design/27 §1.4):如实报「一家都没查到」,绝不冒充成功。
// 它同样**不**触发降级链前进(那是「不可用」的处置,见 dispatch)。
return nil, fmt.Errorf("io: web-search: %w (provider=%s)", search.ErrNoResults, provider)
}
return map[string]any{
"query": query,
"count": len(results),
"results": toWireResults(results),
// truncated 沿用既有字段(成功路径恒为 false:超限在 provider 层就是错误)。
"truncated": false,
// provider 是**如实标注**:降级链里谁应答就写谁,绝不拿兜底冒充主力。
"provider": provider,
}, nil
}
// resultLimit 把模型的 `count` 收进配置上限(主人 2026-10-07 裁定:上限抬高到 10,
// 并且它是**真上限**):
//
// - `count` **缺省 / 非法**(≤0、非数值)⇒ 用上限;
// - `count` **小于上限** ⇒ 尊重模型 —— 「只要 3 条」是合理需求,不该被抬到 10;
// - `count` **大于上限** ⇒ **只给上限**(模型要 50、配了 10 ⇒ 10)。
//
// 上限本身是**运维配置**给的(`WEB_SEARCH_MAX_RESULTS` / 面板文件 `maxResults`),
// 模型顶不掉它。旧口径 `n = count`(`if ok && v > 0 { n = v }`)让模型直接越过配置,
// 与 `.env.example` 写的「条数上限」语义不符 —— 那是一条已登记的口径缺陷。
//
// 上限 ≤0 时取 1(不该发生:两条构造路径的默认值都 ≥1):它是「一条都别给」与
// 「截断关掉」之间唯一说得通的那个,避免 `Limit ≤ 0` 退化成**不截断**。
func resultLimit(limit int, args map[string]any) int {
if limit < 1 {
limit = 1
}
n, ok := argInt(args, "count")
if !ok || n < 1 || n > limit {
return limit
}
return n
}
// dispatch 按 主 provider → 降级链 依次尝试,返回结果与**实际应答的 provider 名**。
//
// 只有「该家不可用」才前进一格(unavailable:无 key / 鉴权失败 / 限流 / 额度 / 网络 /
// 上游错误);「空结果」与「预算耗尽」**不**前进 —— 空是空,不是不可用(design/27 §1.4/§3)。
func (s *webSearchTool) dispatch(ctx context.Context, query string, count int) ([]search.Result, string, error) {
names := make([]string, 0, len(s.fallback)+1)
names = append(names, s.primary)
names = append(names, s.fallback...)
attempted := make([]string, 0, len(names))
var lastErr error
for _, name := range names {
p, ok := s.registry.Lookup(name)
if !ok {
// 未配置/未登记的家在构造期就不在注册表里,链上直接跳过(不是错误)。
s.log.Debug("web-search: provider not available, skipped",
"provider", name, "available", s.registry.Names())
continue
}
if err := ctx.Err(); err != nil {
// 上游预算已耗尽,再开一格也没有意义。
lastErr = err
break
}
attempted = append(attempted, name)
res, err := s.searchWith(p, ctx, query, count)
if err == nil {
return res, name, nil
}
lastErr = err
if !searchUnavailable(err) {
// 空结果 / 上下文取消:停在原地,如实返回。
return nil, name, fmt.Errorf("io: web-search: %w", err)
}
// 错误文案**不含密钥**(密钥只在请求头里,从不进错误/日志)。
s.log.Warn("web-search: provider unavailable, advancing fallback chain",
"provider", name, "err", err)
}
if lastErr == nil {
lastErr = search.ErrNotConfigured
}
if len(attempted) == 0 {
return nil, "", fmt.Errorf("io: web-search: %w (no provider available)", lastErr)
}
return nil, "", fmt.Errorf("io: web-search: %w (tried: %s)", lastErr, strings.Join(attempted, ", "))
}
// searchWith 调一家 provider,并按**这一次尝试**的预算加超时。
//
// 「上游正常应答但一条都没查到」在这里就被点成 ErrNoResults(并**带上是谁答的**)——
// 它是「空」不是「成功」,也不是「该家不可用」:dispatch 据此**停在原地**、如实报出去,
// 而不是换一家假装就有结果了(design/27 §1.4/§3)。把这次判定放在这里,是为了让
// 「空结果不前进」这条规则只有一处,且真的被执行到。
func (s *webSearchTool) searchWith(p search.Provider, ctx context.Context, query string, count int) ([]search.Result, error) {
callCtx := ctx
if s.timeout > 0 {
var cancel context.CancelFunc
callCtx, cancel = context.WithTimeout(ctx, s.timeout)
defer cancel()
}
res, err := p.Search(callCtx, search.Query{Text: query, Count: count})
if err != nil {
return nil, err
}
if len(res) == 0 {
return nil, fmt.Errorf("%w (provider=%s)", search.ErrNoResults, p.Name())
}
return res, nil
}
// searchUnavailable 报告一个错误是否属于「该家不可用」。
//
// 上下文取消/超时**不算**:那是本次调用的预算花光了,换一家也一样没时间,
// 应该如实报出去而不是掩盖成「前进一格」。
func searchUnavailable(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
return search.Unavailable(err)
}
// searchResult 是一条结果的**线格式**(既有字段一字不改;published/score 是本期追加的
// 可选字段 —— design/27 §4:只许追加,不许改名)。
type searchResult struct {
Title string `json:"title"`
// URL 是结果链接。⚠️ **可能是空串**:上游(如智谱 `search_std`)真机上经常不给 link,
// 此时如实回空串(`title`/`content` 仍可用),**绝不**编一个 URL 出来。
// 客户端按「空 url = 这条没有可点链接」处理,不要把它当成失败或省略字段。
URL string `json:"url"`
Content string `json:"content"`
Snippet string `json:"snippet"`
// Published 是发布时间(各家口径不一:ISO 字符串或毫秒时间戳)。
Published string `json:"published,omitempty"`
// Score 是上游给的排序分(多数家不给,此时为 0 并被省略)。
Score float64 `json:"score,omitempty"`
}
// toWireResults 把归一化结果映射成线格式(追加 provider/score 等字段,不改既有字段名)。
func toWireResults(results []search.Result) []searchResult {
out := make([]searchResult, 0, len(results))
for _, r := range results {
out = append(out, searchResult{
Title: r.Title,
URL: r.Link,
Content: r.Content,
Snippet: r.Snippet,
Published: r.Published,
Score: r.Score,
})
}
return out
}
// withSearchEndpointHost 把**配置里给定的**provider 主机并入 web-search 自己的
// SSRF 白名单(返回新切片,不改动入参切片——组合根可能把它交给别的工具)。
// 端点解析不出主机、或主机已在名单里时原样返回。
//
// 理由见 newWebSearchTool:本机兜底端点必然是回环地址(127.0.0.1:38471),而
// SSRF 守卫默认拒绝回环;端点是运维配置而非模型输入,因此对它放行**不会**给模型
// 打开新的目标面——工具能去的主机恒等于配置里的那几个。
func withSearchEndpointHost(allowed []string, endpoint string) []string {
host := searchEndpointHost(endpoint)
if host == "" || isHostAllowed(host, allowed) {
return allowed
}
out := make([]string, 0, len(allowed)+1)
out = append(out, allowed...)
return append(out, host)
}
// searchEndpointHost 取出 provider 基地址的主机名(去端口、去 IPv6 方括号);解析不出来
// 或没有主机时返回空串。
func searchEndpointHost(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return ""
}
return hostOnly(u.Host)
}