- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
(本片自己不开路由)
⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
另有 design/28 的相关注册留给主会话。
385 lines
14 KiB
Go
385 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||
)
|
||
|
||
// 本文件钉住切片 WS1-A 的组合根接线:`WEB_SEARCH_ENDPOINT` / `WEB_SEARCH_TIMEOUT`
|
||
// 经 `a.ioToolOptions()` 传进 io 工具集,端点不可用时 web-search **不进注册面**。
|
||
//
|
||
// 断言落在**注册表**上(`toolNames`,即模型真正看到的那一面),而不是源码字符串 ——
|
||
// 源码接缝只作为最后一段的防回归补充。
|
||
|
||
// wsTestEnvFile 写一份只含 JWT_SECRET(唯一的必填键)加若干自定义行的 .env,
|
||
// 返回路径。走 config.Load 是为了让「未写这个键 ⇒ 用标签默认值」这条链真实发生。
|
||
func wsTestEnvFile(t *testing.T, extra ...string) string {
|
||
t.Helper()
|
||
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
|
||
path := filepath.Join(t.TempDir(), ".env")
|
||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
|
||
t.Fatalf("write .env: %v", err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
|
||
//
|
||
// DataDir 一律指到本用例自己的临时目录:web-search 的可变源
|
||
// (`$DATA_DIR/config/plugins/web-search.json`,PLG-4)会**按启动期语义**读/播种它,
|
||
// 默认的 `./data` 会让并行用例互相踩同一份文件(且把仓库工作目录写脏)。
|
||
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
|
||
t.Helper()
|
||
cfg, err := config.Load(wsTestEnvFile(t, extra...))
|
||
if err != nil {
|
||
t.Fatalf("config.Load: %v", err)
|
||
}
|
||
cfg.DataDir = t.TempDir()
|
||
return cfg
|
||
}
|
||
|
||
// wsSurfaceApp 用组合根的**同一条路**把 io 工具注册进能力面:ioToolOptions →
|
||
// iotools.New → dispatcher.Register。logOut 非空时用它当日志出口(用于断言 WARN)。
|
||
func wsSurfaceApp(t *testing.T, cfg *config.Config, logOut *bytes.Buffer) *app {
|
||
t.Helper()
|
||
caps := registry.New()
|
||
dispatcher, err := tool.New(caps)
|
||
if err != nil {
|
||
t.Fatalf("tool.New: %v", err)
|
||
}
|
||
a := &app{cfg: cfg, log: testLog()}
|
||
if logOut != nil {
|
||
a.log = logger.New(logger.Options{Out: logOut, Level: "debug"})
|
||
}
|
||
set, err := iotools.New(cfg, a.ioToolOptions()...)
|
||
if err != nil {
|
||
t.Fatalf("iotools.New: %v", err)
|
||
}
|
||
for _, tl := range set {
|
||
if rerr := dispatcher.Register(tl); rerr != nil {
|
||
t.Fatalf("register %s: %v", tl.Name(), rerr)
|
||
}
|
||
}
|
||
a.caps = caps
|
||
a.dispatcher = dispatcher
|
||
return a
|
||
}
|
||
|
||
// wsHasTool 报告注册表(模型可见的工具面)里有没有这个名字。
|
||
func wsHasTool(a *app, name string) bool {
|
||
for _, n := range toolNames(a) {
|
||
if n == name {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isOffSentinel 报告取值是否属于「显式关闭」的三个哨兵词(大小写/空白不敏感)。
|
||
func isOffSentinel(raw string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "", "off", "none":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_DefaultEndpointRegistersWebSearch 是判据 ①:**不写这个键**时
|
||
// 默认值生效(本机兜底),web-search 出现在注册面里。
|
||
func TestWireIOTools_DefaultEndpointRegistersWebSearch(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t)
|
||
const want = "http://127.0.0.1:38471"
|
||
if cfg.WebSearch.Endpoint != want {
|
||
t.Fatalf("默认端点 = %q, want %q", cfg.WebSearch.Endpoint, want)
|
||
}
|
||
a := wsSurfaceApp(t, cfg, nil)
|
||
if !wsHasTool(a, "web-search") {
|
||
t.Fatalf("默认端点下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
|
||
}
|
||
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
|
||
if !wsHasTool(a, name) {
|
||
t.Errorf("其余 io 工具不得受影响,但 %q 不在注册面里", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_UnusableEndpointOmitsWebSearch 是判据 ②④:端点显式关闭
|
||
// (空串 / off / none)或不是可用 http(s) 地址时,web-search **不进注册面** ——
|
||
// 模型看不见它,且启动不报错(是「少一把工具」,不是「启动失败」)。同时必须留下
|
||
// 一条带键名的 WARN:配了却没用,绝不静默。
|
||
func TestWireIOTools_UnusableEndpointOmitsWebSearch(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
envLine string
|
||
wantValue string
|
||
}{
|
||
{"显式留空", "WEB_SEARCH_ENDPOINT=", ""},
|
||
{"off", "WEB_SEARCH_ENDPOINT=off", "off"},
|
||
{"大写 NONE", "WEB_SEARCH_ENDPOINT=NONE", "NONE"},
|
||
{"非 http scheme", "WEB_SEARCH_ENDPOINT=ftp://searx.example.com", "ftp://searx.example.com"},
|
||
{"缺 scheme", "WEB_SEARCH_ENDPOINT=127.0.0.1:38471", "127.0.0.1:38471"},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
cfg := wsLoadConfig(t, tc.envLine)
|
||
if cfg.WebSearch.Endpoint != tc.wantValue {
|
||
t.Fatalf("端点 = %q, want %q", cfg.WebSearch.Endpoint, tc.wantValue)
|
||
}
|
||
a := wsSurfaceApp(t, cfg, &buf)
|
||
if wsHasTool(a, "web-search") {
|
||
t.Fatalf("端点不可用时 web-search 不得进注册面,实际工具面 = %v", toolNames(a))
|
||
}
|
||
// 其余工具照常(关掉的只是一件工具,不是整套 io 能力)。
|
||
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
|
||
if !wsHasTool(a, name) {
|
||
t.Errorf("%q 不得因 web-search 关闭而消失", name)
|
||
}
|
||
}
|
||
// WARN 必须点出是哪个键、为什么(不打印 URL 本身,避免 URL 里带凭据)。
|
||
out := buf.String()
|
||
if !strings.Contains(out, webSearchEndpointEnv) {
|
||
t.Fatalf("日志里没有点出 %s:%q", webSearchEndpointEnv, out)
|
||
}
|
||
if !strings.Contains(out, "web-search tool not registered") {
|
||
t.Fatalf("日志里没有说明 web-search 未注册:%q", out)
|
||
}
|
||
// 关闭哨兵词本身会出现在原因文案里("空串 / off / none"),故只对
|
||
// 「像端点/主机」的那几个取值断言它没被打印。
|
||
if !isOffSentinel(tc.wantValue) && tc.wantValue != "" && strings.Contains(out, tc.wantValue) {
|
||
t.Fatalf("日志里出现了端点值本身(URL 可能带凭据,禁止打印):%q", out)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_EndpointIsPassedThrough 是判据 ③:断言的不是「没报错」,而是
|
||
// **配置里的端点真的被用了** —— 用一个可注入的 httptest 出口收请求,核对路径/查询串。
|
||
func TestWireIOTools_EndpointIsPassedThrough(t *testing.T) {
|
||
t.Parallel()
|
||
hits := make(chan *http.Request, 1)
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
hits <- r
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
cfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL+"/search")
|
||
a := wsSurfaceApp(t, cfg, nil)
|
||
tl, err := a.caps.Get("web-search")
|
||
if err != nil {
|
||
t.Fatalf("web-search 不在注册面里: %v", err)
|
||
}
|
||
toolValue, ok := tl.Value.(interface {
|
||
Execute(context.Context, any) (any, error)
|
||
})
|
||
if !ok {
|
||
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
|
||
}
|
||
if _, err := toolValue.Execute(context.Background(), map[string]any{"query": "昔涟"}); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
select {
|
||
case r := <-hits:
|
||
if r.URL.Path != "/search" {
|
||
t.Errorf("请求路径 = %q, want /search(配置里的端点必须原样使用)", r.URL.Path)
|
||
}
|
||
if got := r.URL.Query().Get("q"); got != "昔涟" {
|
||
t.Errorf("q = %q", got)
|
||
}
|
||
if got := r.URL.Query().Get("format"); got != "json" {
|
||
t.Errorf("format = %q, want json", got)
|
||
}
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("配置里的端点一个请求都没收到 —— 配置没被透传到工具")
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_TimeoutIsWired 钉住 WEB_SEARCH_TIMEOUT 真的接到了工具上:
|
||
// 一个慢端点 + 100ms 预算必须被掐断(默认 15s 够它跑完,因此「被掐断」只可能来自
|
||
// 这个配置)。负值按未配置处理并记一条 WARN。
|
||
func TestWireIOTools_TimeoutIsWired(t *testing.T) {
|
||
t.Parallel()
|
||
release := make(chan struct{})
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
select {
|
||
case <-release:
|
||
case <-r.Context().Done():
|
||
}
|
||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||
}))
|
||
defer srv.Close()
|
||
defer close(release)
|
||
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_ENDPOINT="+srv.URL,
|
||
"WEB_SEARCH_TIMEOUT=100ms",
|
||
)
|
||
if cfg.WebSearch.Timeout != 100*time.Millisecond {
|
||
t.Fatalf("WEB_SEARCH_TIMEOUT 没被解析: %v", cfg.WebSearch.Timeout)
|
||
}
|
||
a := wsSurfaceApp(t, cfg, nil)
|
||
tl, err := a.caps.Get("web-search")
|
||
if err != nil {
|
||
t.Fatalf("web-search 不在注册面里: %v", err)
|
||
}
|
||
exec := tl.Value.(interface {
|
||
Execute(context.Context, any) (any, error)
|
||
})
|
||
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil {
|
||
t.Fatal("100ms 预算下慢端点必须被掐断(说明配置没接到工具上)")
|
||
}
|
||
|
||
// 负值 = 笔误:按未配置处理(工具仍在),并留下一条带键名的 WARN。
|
||
var buf bytes.Buffer
|
||
negCfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_ENDPOINT="+srv.URL,
|
||
"WEB_SEARCH_TIMEOUT=-5s",
|
||
)
|
||
negApp := wsSurfaceApp(t, negCfg, &buf)
|
||
if !wsHasTool(negApp, "web-search") {
|
||
t.Fatal("负超时只应退回默认,不该把工具从注册面摘掉")
|
||
}
|
||
if out := buf.String(); !strings.Contains(out, webSearchTimeoutEnv) {
|
||
t.Fatalf("负超时必须在日志里点出 %s:%q", webSearchTimeoutEnv, out)
|
||
}
|
||
}
|
||
|
||
// wsSearxngResultCount 用**裸 HTTP**问兜底服务(与工具实现无关)同一个查询,返回它此刻
|
||
// 真能产出的结果条数;不可达/应答不可解析时返回 -1。理由与命名同 io 侧的探针:上游
|
||
// 引擎被限流时兜底会「200 但 0 条」,那是外部降级,不该判成本仓的代码红。
|
||
func wsSearxngResultCount(endpoint, query string) int {
|
||
client := &http.Client{Timeout: 25 * time.Second}
|
||
resp, err := client.Get(endpoint + "/search?q=" + url.QueryEscape(query) + "&format=json")
|
||
if err != nil {
|
||
return -1
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return -1
|
||
}
|
||
var payload struct {
|
||
Results []struct {
|
||
URL string `json:"url"`
|
||
} `json:"results"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||
return -1
|
||
}
|
||
return len(payload.Results)
|
||
}
|
||
|
||
// TestWireIOTools_RealSearXNG_EndToEnd 是**真机**端到端:默认配置(一个 WEB_SEARCH_*
|
||
// 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search ⇒ 打本机兜底 SearXNG 真的拿到结果。
|
||
// 它只读外部服务,不碰后端进程、不碰仓库根产物。兜底没起、或其上游引擎被限流到产不出
|
||
// 结果时 **skip**(外部降级不判代码红;探针有结果而工具没有 = 真红)。
|
||
func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) {
|
||
t.Parallel()
|
||
const query = "昔涟 崩坏星穹铁道"
|
||
cfg := wsLoadConfig(t)
|
||
upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query)
|
||
if upstream < 0 {
|
||
t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint)
|
||
}
|
||
if upstream == 0 {
|
||
t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e")
|
||
}
|
||
|
||
a := wsSurfaceApp(t, cfg, nil)
|
||
if !wsHasTool(a, "web-search") {
|
||
t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
|
||
}
|
||
tl, err := a.caps.Get("web-search")
|
||
if err != nil {
|
||
t.Fatalf("caps.Get(web-search): %v", err)
|
||
}
|
||
exec := tl.Value.(interface {
|
||
Execute(context.Context, any) (any, error)
|
||
})
|
||
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
|
||
if err != nil {
|
||
t.Fatalf("默认端点真机查询失败: %v", err)
|
||
}
|
||
m, ok := got.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("返回值类型 = %T", got)
|
||
}
|
||
count, _ := m["count"].(int)
|
||
if count == 0 {
|
||
t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m)
|
||
}
|
||
t.Logf("真机 e2e(默认配置 %s):返回 %d 条结果", cfg.WebSearch.Endpoint, count)
|
||
}
|
||
|
||
// TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在,
|
||
// env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。
|
||
func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) {
|
||
t.Parallel()
|
||
seams := map[string][]string{
|
||
"wire_runtime.go": {
|
||
// io 工具集必须经这一个入口构造,否则端点/后端裁决会被绕过。
|
||
"iotools.New(a.cfg, a.ioToolOptions()...)",
|
||
},
|
||
"wire_io_tools.go": {
|
||
// WS1-B:各家接线 + 主 provider/降级链选定,是 web-search 后端唯一来源。
|
||
"iotools.WithSearchProviders(specs...)",
|
||
"iotools.WithSearchSelection(ws.Provider, ws.Fallback)",
|
||
"iotools.WithSearchMaxResults(ws.MaxResults)",
|
||
"iotools.WithoutSearch()",
|
||
},
|
||
}
|
||
for file, wants := range seams {
|
||
src, err := os.ReadFile(file)
|
||
if err != nil {
|
||
t.Fatalf("read %s: %v", file, err)
|
||
}
|
||
for _, want := range wants {
|
||
if !strings.Contains(string(src), want) {
|
||
t.Fatalf("%s 里找不到 %q —— web-search 的接线/开关断了", file, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
|
||
if err != nil {
|
||
t.Fatalf("read .env.example: %v", err)
|
||
}
|
||
template := string(raw)
|
||
for _, line := range []string{
|
||
webSearchProviderEnv + "=",
|
||
webSearchEndpointEnv + "=",
|
||
webSearchTimeoutEnv + "=",
|
||
webSearchMaxResultsEnv + "=",
|
||
webSearchFallbackEnv + "=",
|
||
// 默认值必须在模板里如实写出(本机兜底 + searxng 主后端),否则照模板部署的人
|
||
// 看不出它接的是什么。
|
||
webSearchEndpointEnv + "=http://127.0.0.1:38471",
|
||
webSearchProviderEnv + "=searxng",
|
||
} {
|
||
if !strings.Contains(template, line) {
|
||
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", line)
|
||
}
|
||
}
|
||
}
|