package io // 本文件钉住「配置的条数上限就是**上限**」(主人 2026-10-07 裁定)。 // // 旧口径(`n = count`)让模型给的 `count` 直接**顶掉**配置里的 `WEB_SEARCH_MAX_RESULTS` // —— 与 `.env.example` 写的「条数上限」语义不符,是一条已登记的口径缺陷。新口径: // 模型只能把条数**要少**,要多了只给上限,缺省/非法一律用上限。 // // 与 provider 自己的条数收敛(bocha/zhipu 等把 Count 收进官方区间)无关:这里判的是 // **工具层最终返回给模型的条数**(`search.Finalize` 的 Limit),上限在 provider 之上。 import ( "context" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" ) // capUpstream 造一个 searxng 假上游:恒返回 n 条结果。 func capUpstream(t *testing.T, n int) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, capResultsBody(n)) })) t.Cleanup(srv.Close) return srv } // capResultsBody 造 n 条 searxng 形状的结果。link 逐条不同 —— `Finalize` 按 link 去重, // 重复的 link 会被当成同一条(那会让「截断到 N」的断言看不出效果)。 func capResultsBody(n int) string { items := make([]string, 0, n) for i := 0; i < n; i++ { items = append(items, fmt.Sprintf( `{"title":"T%d","url":"https://r%d.example/%d","content":"c%d"}`, i, i, i, i)) } return `{"results":[` + strings.Join(items, ",") + `]}` } // capCount 用「上游恒返回 upstream 条」的假 searxng 跑一次 web-search,返回线格式的 // `count`。args 为 nil 或没给 `count` 键都表示「模型没给 count」。 func capCount(t *testing.T, upstream int, opts []Option, args map[string]any) int { t.Helper() srv := capUpstream(t, upstream) ws := webSearchToolForTest(t, append([]Option{ WithSearchEndpoint(srv.URL), WithHTTPClient(srv.Client()), }, opts...)...) call := map[string]any{"query": "昔涟"} for k, v := range args { call[k] = v } got, err := ws.Execute(context.Background(), call) if err != nil { t.Fatalf("Execute: %v", err) } m, ok := got.(map[string]any) if !ok { t.Fatalf("工具返回值类型 = %T", got) } return m["count"].(int) } // TestWebSearchCountIsCappedByConfiguredLimit 是本次裁定的主判据(①~④): // // ① 默认上限 = 10; // ② 模型要 50 ⇒ 只给上限 10(**上限是真上限**); // ③ 模型要 3 ⇒ 给 3(「少一点」是合理需求); // ④ `count` 缺省 / 非法(0 / 负数 / 非数值)⇒ 用上限 10。 func TestWebSearchCountIsCappedByConfiguredLimit(t *testing.T) { t.Parallel() const cap10 = 10 capped := []Option{WithSearchMaxResults(cap10)} for _, tt := range []struct { name string upstream int opts []Option args map[string]any want int }{ // ① 默认上限:不传任何条数选项 ⇒ io 包的默认(= 10),模型也没给 count。 {"① 默认上限 10", 30, nil, nil, cap10}, {"① 默认上限 10(模型要 50 也一样)", 30, nil, map[string]any{"count": 50}, cap10}, // ② 模型要 50 ⇒ 只给上限。 {"② 模型要 50 ⇒ 上限 10", 30, capped, map[string]any{"count": 50}, cap10}, {"② 模型要 11 ⇒ 上限 10(边界)", 30, capped, map[string]any{"count": 11}, cap10}, {"② 模型要 10 ⇒ 上限 10(正好等于上限)", 30, capped, map[string]any{"count": 10}, cap10}, // ③ 模型要得更少 ⇒ 尊重模型。 {"③ 模型要 3 ⇒ 给 3", 30, capped, map[string]any{"count": 3}, 3}, {"③ 模型要 1 ⇒ 给 1", 30, capped, map[string]any{"count": 1}, 1}, // ④ 缺省 / 非法 ⇒ 上限。 {"④ count 缺省 ⇒ 上限 10", 30, capped, map[string]any{}, cap10}, {"④ count=0(非法)⇒ 上限 10", 30, capped, map[string]any{"count": 0}, cap10}, {"④ count=-5(非法)⇒ 上限 10", 30, capped, map[string]any{"count": -5}, cap10}, {"④ count 非数值(非法)⇒ 上限 10", 30, capped, map[string]any{"count": "abc"}, cap10}, // 上游本来就不够:截断不是「凑数」,如实返回上游给的条数。 {"上游只有 2 条 ⇒ 如实返回 2", 2, capped, map[string]any{"count": 3}, 2}, } { t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := capCount(t, tt.upstream, tt.opts, tt.args); got != tt.want { t.Fatalf("count = %d, want %d", got, tt.want) } }) } } // TestResultLimit_Table 直接钉住收敛函数的四条分支(含上限缺失时的兜底), // 不必每次都起一个 HTTP 上游。 func TestResultLimit_Table(t *testing.T) { t.Parallel() for _, tt := range []struct { name string limit int args map[string]any want int }{ {"缺省用上限", 10, map[string]any{}, 10}, {"缺省用上限(args 为 nil)", 10, nil, 10}, {"要得多只用上限", 10, map[string]any{"count": 50}, 10}, {"要得少尊重模型", 10, map[string]any{"count": 3}, 3}, {"非数值用上限", 10, map[string]any{"count": "many"}, 10}, {"零用上限", 10, map[string]any{"count": 0}, 10}, {"负数用上限", 10, map[string]any{"count": -1}, 10}, {"上限为 0 时兜底到 1(不退化成不截断)", 0, map[string]any{}, 1}, {"上限为负时兜底到 1", -3, map[string]any{"count": 5}, 1}, {"面板把上限设成 20 ⇒ 生效值 20", 20, map[string]any{"count": 50}, 20}, } { t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := resultLimit(tt.limit, tt.args); got != tt.want { t.Fatalf("resultLimit(%d, %v) = %d, want %d", tt.limit, tt.args, got, tt.want) } }) } }