fix(deepsearch): 只关自己拉起的 SearXNG;条数改由插件侧截断

两个都由「真调用/真测试」暴露,且都会让线上搜索表现为「后端不可用」。

一、归属:接管不等于拥有(例:E2E 测试把生产后端带走)
  旧实现只要探活成功就认领关闭责任 → 同机第二个实例(测试拉起的插件、另一个 daemon)
  退出时就 docker compose stop 掉**线上正在用的**后端。实测:跑一次
  `go test ./internal/plugins/ -run TestRealPlugin_DeepSearch`,teardown 即关停
  127.0.0.1:8888,用户看到的就是「搜索后端起不来」。
  修:只有真正执行过 `docker compose up -d` 的实例才算「我们起的」;探到已在运行只接管。

二、条数:SearXNG 不认 count/limit(count/max_results 形同虚设)
  实测 ?count=3、?limit=3、不带参数返回**完全相同的 35 条**,所以截断必须在插件里做。
  旧实现把 count 当 limit 参数发给 SearXNG 就以为生效了 → 模型每次吞 35~58 条带摘要结果,
  还会把「命中 N 条」当成「拿到了 N 条」报给用户(实测发生过)。
  修:新增 limitResults(默认取 max_results,上限 20);输出改成
  「命中 N 条,返回前 M 条」;不再发无意义的 limit 参数。

验证:22 项单测全过、-race 干净、vet/gofmt 干净;两条归属测试做过扰动(把旧语义放回
去后必红,并如实打出它执行的 `docker compose stop -t 2`);内核 E2E 三条通过且
**跑完 healthz 仍 200、容器未重启**;线上 1.1.2 实测 count=3 → 「命中 40 条,返回前 3 条」。

版本 1.1.0 → 1.1.2。
This commit is contained in:
JianFeeeee
2026-09-13 09:55:27 +08:00
parent 934eb4da7d
commit e50bffa34f
6 changed files with 148 additions and 20 deletions

View File

@ -14,4 +14,8 @@ require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0

View File

@ -2,7 +2,7 @@
"name": "deepsearch",
"name_zh": "联网检索",
"name_en": "Deep Search",
"version": "1.1.0",
"version": "1.1.2",
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
"author": "HomeAgent",
"entry": "plugin.bin",

View File

@ -11,6 +11,7 @@ package main
// 配置项(全部可在插件配置界面改):
// searxng_url 本地/远端 SearXNG 基地址(默认 http://127.0.0.1:8888
// manage_searxng 是否由本插件托管搜索后端(默认 true插件启动时拉起、停止时关闭
// (只关**自己拉起**的实例;发现已在运行则只接管,不认领关闭责任)
// searxng_dir 托管时使用的 compose 目录(默认 /root/searxng-agent
// stop_searxng_on_exit 停止插件时是否关闭后端(默认 true关掉可避免重载时反复重启
// max_results 默认返回条数(控制上下文体积)
@ -53,7 +54,10 @@ const (
cfgUserAgent = "user_agent"
defaultSearxURL = "http://127.0.0.1:8888"
defaultUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
// maxSearchResults 单次返回给模型的条数上限(保护上下文体积)
maxSearchResults = 20
defaultUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
)
type Plugin struct {
@ -365,10 +369,39 @@ func dedupResults(in []searxResult) []searxResult {
return out
}
// limitResults 把结果截断到请求条数。
//
// 为什么必须在插件侧截断SearXNG 的 /search 没有「返回条数」参数 ——
// `?count=3`、`?limit=3` 与不带参数返回的条数一模一样(实测 2026-09-1335 条)。
// 少了这一步,`count` 与插件配置 `max_results` 全部形同虚设:模型每次吞下 35~58 条
// 带摘要结果(还误以为「要 3 条给了 47 条」,我实测被 agent 当成事实报给用户过)。
func (p *Plugin) limitResults(args map[string]interface{}, in []searxResult) []searxResult {
n := p.maxItems
if n <= 0 {
n = 8
}
if v := argInt(args, "count", 0); v > 0 {
n = v
}
if n > maxSearchResults {
n = maxSearchResults // 保护上下文:不因为模型要 200 条就真给 200 条
}
if len(in) > n {
return in[:n]
}
return in
}
// formatResults 返回给模型的紧凑文本(每条:序号/标题/URL/摘要/时间)
func formatResults(q string, res []searxResult, unresponsive [][]string, elapsed time.Duration) string {
// total 是去重后的命中总数res 是实际返回给模型的那几条(可能已被截断)。
// 两者必须分开说:写成「命中 N 条」而实际只给几条,模型会把它当成"拿到了 N 条"。
func formatResults(q string, res []searxResult, total int, unresponsive [][]string, elapsed time.Duration) string {
var b strings.Builder
fmt.Fprintf(&b, "检索 %q命中 %d 条(%s\n", q, len(res), elapsed.Round(time.Millisecond))
if total > len(res) {
fmt.Fprintf(&b, "检索 %q命中 %d 条,返回前 %d 条(%s\n", q, total, len(res), elapsed.Round(time.Millisecond))
} else {
fmt.Fprintf(&b, "检索 %q命中 %d 条(%s\n", q, len(res), elapsed.Round(time.Millisecond))
}
engSet := map[string]bool{}
withSnippet := 0
for _, r := range res {
@ -637,7 +670,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
st.RegisterDef(sdk.ConfigDef{
Key: cfgManageSearx, Type: "bool", Default: "true",
DisplayName: "托管 SearXNG",
Description: "开启后插件启动时自动拉起搜索后端docker compose up -d插件停止时关闭。关掉则假定后端由外部维护(如 systemd",
Description: "开启后插件启动时自动拉起搜索后端docker compose up -d插件停止时关闭**自己拉起的**实例。发现后端已在运行则只接管、不关闭(否则会带走别的实例的服务)。关掉则假定后端由外部维护(如 systemd",
Category: "deepsearch",
})
st.RegisterDef(sdk.ConfigDef{
@ -675,7 +708,7 @@ func (p *Plugin) registerTools() {
"需要事实、新闻、文档、报错信息时用它,而不是抓搜索引擎页面",
Parameters: schemas(map[string]interface{}{
"query": pStr("检索词;中文/英文都可"),
"count": pInt("条数默认取插件配置8"),
"count": pInt("返回条数默认取插件配置 max_results上限 20"),
"engines": pStr("指定引擎,逗号分隔(如 duckduckgo,brave,quark留空用默认聚合"),
"category": pStr("类别general默认| news | it | science | images"),
"time_range": pStr("时间范围day|week|month|year新闻类很有用"),
@ -732,9 +765,8 @@ func (p *Plugin) registerTools() {
func (p *Plugin) buildParams(args map[string]interface{}, forceCategory string, forceRange string) url.Values {
v := url.Values{}
v.Set("q", argStr(args, "query"))
if n := argInt(args, "count", p.maxItems); n > 0 {
v.Set("limit", strconv.Itoa(n)) // SearXNG 用 limit 控制返回条数
}
// 注意SearXNG 的 /search **不认** count/limit 参数(实测 ?count=3 / ?limit=3 与不带
// 参数返回完全相同的条数),所以「要几条」由插件自己截断,见 limitResults。
if e := argStr(args, "engines"); e != "" {
v.Set("engines", e)
}
@ -781,7 +813,9 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
if len(results) == 0 {
return map[string]interface{}{"content": p.emptyHint(query, resp)}, nil
}
txt := formatResults(query, results, resp.UnresponsiveEngine, time.Since(start))
total := len(results)
results = p.limitResults(args, results)
txt := formatResults(query, results, total, resp.UnresponsiveEngine, time.Since(start))
if len(resp.Answers) > 0 {
txt = "直接答案:" + strings.Join(resp.Answers, "") + "\n\n" + txt
}
@ -833,7 +867,9 @@ func (p *Plugin) handleNews(args map[string]interface{}) (interface{}, error) {
if len(results) == 0 {
return map[string]interface{}{"content": p.emptyHint(query, resp)}, nil
}
return map[string]interface{}{"content": formatResults(query+"(新闻)", results, resp.UnresponsiveEngine, time.Since(start))}, nil
total := len(results)
results = p.limitResults(args, results)
return map[string]interface{}{"content": formatResults(query+"(新闻)", results, total, resp.UnresponsiveEngine, time.Since(start))}, nil
}
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
@ -883,6 +919,8 @@ func (p *Plugin) handleDeep(args map[string]interface{}) (interface{}, error) {
if len(results) == 0 {
return map[string]interface{}{"content": p.emptyHint(query, resp)}, nil
}
candidates := len(results)
results = p.limitResults(args, results) // count 只影响候选池,精读条数另有 top_k
type doc struct {
idx int
@ -917,7 +955,7 @@ func (p *Plugin) handleDeep(args map[string]interface{}) (interface{}, error) {
wg.Wait()
var b strings.Builder
fmt.Fprintf(&b, "深检索 %q —— 候选 %d 条,已读 %d %s\n\n", query, len(results), len(docs), time.Since(start).Round(time.Millisecond))
fmt.Fprintf(&b, "深检索 %q —— 命中候选 %d 条,取前 %d 条精读%s\n\n", query, candidates, len(results), time.Since(start).Round(time.Millisecond))
b.WriteString("【候选清单】\n")
for i, r := range results {
fmt.Fprintf(&b, "%d. %s\n %s\n", i+1, strings.TrimSpace(r.Title), r.URL)

View File

@ -59,8 +59,10 @@ func TestSearchDedupAndFormat(t *testing.T) {
if gotQuery.Get("format") != "json" {
t.Errorf("必须要求 json 输出,实际 %q", gotQuery.Get("format"))
}
if gotQuery.Get("limit") != "5" {
t.Errorf("limit 未生效: %q", gotQuery.Get("limit"))
// SearXNG 的 /search **不认** count/limit实测两者都返回同样的条数
// 所以「要几条」必须由插件侧截断 —— 也不要再发这种无意义参数(曾以为它生效过)。
if gotQuery.Get("limit") != "" || gotQuery.Get("count") != "" {
t.Errorf("不应依赖 SearXNG 的条数参数(它不认): %q", gotQuery.Encode())
}
txt := res.(map[string]interface{})["content"].(string)
// utm_source 应被规范化掉,重复项只剩一条
@ -286,3 +288,56 @@ func TestSearchRawMode(t *testing.T) {
t.Errorf("结构化结果应可序列化: %v", err)
}
}
// 13) 条数截断SearXNG 不认条数参数,插件必须自己截,并且**如实说明**给了几条
func TestSearchTruncatesToCountAndSaysSo(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(sampleResponse)) // 4 条,去重后 3 条
})
res, err := p.handleSearch(map[string]interface{}{"query": "deepin", "count": float64(2)})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
// 必须明确区分「命中几条」与「返回几条」:写成「命中 N 条」而实际给了 M<N 条,
// 模型会把 N 当成拿到手的条数(实测被 agent 当成事实报给用户)。
if !strings.Contains(txt, "命中 3 条,返回前 2 条") {
t.Errorf("应如实说明命中数与返回数:\n%s", txt)
}
// 按 score 排序后的前两条zhihu(9.5)、163(7.2);第三条 bbs.deepin(2.0) 必须被截掉
if !strings.Contains(txt, "统信内核开发工程师") || !strings.Contains(txt, "离谱!") {
t.Errorf("前两条(按分数)应在:\n%s", txt)
}
if strings.Contains(txt, "deepin官方论坛") {
t.Errorf("第 3 条score 最低)超出了 count=2不该出现:\n%s", txt)
}
// 条目行数也要正好 2 条(防「头部说 2 条、正文还是全量」)
if n := strings.Count(txt, "\n http"); n != 2 {
t.Errorf("正文应恰好 2 条,实际 %d 条:\n%s", n, txt)
}
}
// 14) 条数上限:不因为模型要 200 条就真给 200 条
func TestLimitResultsCapsAndDefaults(t *testing.T) {
p := &Plugin{name: "deepsearch", maxItems: 8}
many := make([]searxResult, 30)
for i := range many {
many[i] = searxResult{URL: "https://e.test/", Title: "t"}
}
if got := len(p.limitResults(map[string]interface{}{}, many)); got != 8 {
t.Errorf("未指定 count 时应取配置的 max_items=8实际 %d", got)
}
if got := len(p.limitResults(map[string]interface{}{"count": float64(3)}, many)); got != 3 {
t.Errorf("count=3 应返回 3 条,实际 %d", got)
}
if got := len(p.limitResults(map[string]interface{}{"count": float64(200)}, many)); got != maxSearchResults {
t.Errorf("超过上限应收敛到 %d 条,实际 %d", maxSearchResults, got)
}
// 结果比 count 少时不能造数据
few := many[:2]
if got := len(p.limitResults(map[string]interface{}{"count": float64(5)}, few)); got != 2 {
t.Errorf("结果不足时应原样返回,实际 %d", got)
}
}

View File

@ -8,6 +8,11 @@ package main
// - stdin 关闭(内核消失)同样会跑 handlers + Stop()
//
// 因此这里的关闭动作必须**有界**searxShutdownBudget 取 4s留 1s 余量。
//
// 归属规则(谁拉起谁关):**只有本插件真正执行了 `docker compose up -d` 的实例才算「我们起的」**。
// 探活发现已在运行的实例只「接管」——不认领关闭责任。否则同一台机器上的第二个实例
// E2E 测试拉起的插件、另一个 daemon退出时会把生产后端一起带走实测就是这条把
// 线上搜索服务反复关停的(测试实例用默认配置,测试结束就 `docker compose stop`)。
// 若插件是被 kill -9 / OOM 带走的,关闭动作不会执行 —— SearXNG 会留在运行态;
// 下次 Start 探测到它在跑就直接接管,这是更安全的失败方向。
@ -99,8 +104,8 @@ func (p *Plugin) ensureSearxng() {
return
}
if p.searxReachable(b.probe) {
log.Printf("[%s] SearXNG 已在运行(%s直接接管", p.name, p.searxURL)
p.markSearxOwned()
// 只接管,不认领:不是我们拉起来的,就不能由我们关掉
log.Printf("[%s] SearXNG 已在运行(%s直接接管不认领关闭责任", p.name, p.searxURL)
return
}
ctx, cancel := context.WithTimeout(context.Background(), b.up)

View File

@ -65,8 +65,12 @@ func TestEnsureSearxngStartsWhenUnreachable(t *testing.T) {
}
}
// 2) 后端已在跑 → 不重启,直接接管
func TestEnsureSearxngAdoptsRunningBackend(t *testing.T) {
// 2) 后端已在跑 → 不重启,**且不认领关闭责任**
//
// 这条是关键同一台机器上会有第二个实例E2E 测试拉起的插件、另一个 daemon
// 如果「接管」也算「我拥有」,任一实例退出就会把生产后端关掉 —— 线上实测就是
// 测试实例在 teardown 时 `docker compose stop`,把搜索服务反复关停。
func TestEnsureSearxngAdoptsRunningBackendWithoutOwning(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
_, _ = w.Write([]byte("OK"))
@ -87,8 +91,30 @@ func TestEnsureSearxngAdoptsRunningBackend(t *testing.T) {
if len(calls) != 0 {
t.Errorf("已在跑就不该重启它,实际执行了:%v", calls)
}
if !p.searxOwned {
t.Error("接管后也应负责停止(与「不重启」不冲突")
if p.searxOwned {
t.Error("不是我们拉起的,就不能认领关闭责任(否则退出时会带走别人的后端")
}
}
// 2b) 接管的实例退出时,一个 docker 命令都不能发
func TestAdoptedBackendSurvivesShutdown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("OK"))
}))
defer srv.Close()
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: srv.URL, searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
}
p.ensureSearxng()
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if len(calls) != 0 {
t.Errorf("接管来的后端在退出时必须留着,实际执行了:%v", calls)
}
}