package main // 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及 // **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源)。 // // 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限, // 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置 // ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。 // // 五条口径: // // 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链 // 怎么走,各家的 key 与基地址从哪来(design/27 §3)。 // 2. **配置有可变源**(design/09 §5.7 E):先读 `$DATA_DIR/config/plugins/web-search.json` // —— 面板可改、0600 + temp+rename;文件**缺失/为空**时由 `.env` 播种(`.env` 从此只是 // 「最小化功能可跑」的引导面,本片**不新增任何 env 键**);文件**非法**时记 WARN 回落 // `.env`,且**不覆盖**原文件(损坏的配置可以人工救回)。 // 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭 // (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** —— // 模型根本看不见它,而不是看得见、一调就报错。 // 4. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点), // false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在 // **启动期报错**,绝不静默回落到兜底。 // 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有 // 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。 // // 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由 // `buildWebSearchTool` 按**当前落盘配置**重建这一把工具并替换注册表条目,因此 // 「启动期的接线」与「保存后的接线」恒为同一条路径(不是两份语义)。两份调用的差别 // 只有日志里的 cause 字段(startup / panel-save),便于排障时区分。 import ( "log/slog" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // web-search 的 env 键名:与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目), // 由 wire_web_search_test.go 逐字核对。它们仍然是**引导面**的键(.env 冻结后不再新增)。 const ( webSearchProviderEnv = "WEB_SEARCH_PROVIDER" webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT" webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT" webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS" webSearchFallbackEnv = "WEB_SEARCH_FALLBACK" ) // ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。 const ioLogSource = "cmd/cyrene/wire_io_tools" // 生效配置的来源标签(写进启动日志:一眼看出「这次的 provider 是 .env 给的还是面板给的」)。 const ( // webSearchSourceEnv 表示本次的配置来自 `.env`(含「文件缺失、刚由 .env 播种」这一步)。 webSearchSourceEnv = "env" // webSearchSourcePanel 表示本次的配置来自面板可变源(面板是唯一来源)。 webSearchSourcePanel = "panel" ) // 解析一次 web-search 配置的**触发场景**(只进日志的 cause 字段)。 const ( // webSearchCauseStartup 是启动期装配(`ioToolOptions`)。 webSearchCauseStartup = "startup" // webSearchCausePanelSave 是面板保存后的热重载(`buildWebSearchTool`,见 websearch_reload.go)。 webSearchCausePanelSave = "panel-save" ) // webSearchToolName 是 web-search 工具在能力注册表里的名字。 // // 它与 io 包 `func (s *webSearchTool) Name()` 里的字面量同源,而那个名字没有导出常量 // (`plugins/tools/io` 不在本片文件面内,不去改它):因此这里用字面量,并由 // websearch_reload_test.go 钉住 —— 名字一旦漂移,热重载会立刻找不到工具(测试红)。 const webSearchToolName = "web-search" // ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New, // 因此「注册面里有哪些 io 工具」只由这一处决定。 func (a *app) ioToolOptions() []iotools.Option { opts := []iotools.Option{ iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log), } return append(opts, a.webSearchOptions(webSearchCauseStartup)...) } // buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它 // 重建工具,见 websearch_reload.go)。 // // 三种返回值,调用方必须分开处置: // - (tool, nil) ⇒ 正常,工具该在注册面里; // - (nil, nil) ⇒ 当前配置下它**不该在注册面里**(端点/主 provider 显式关闭,或选定 // provider 没配齐被 WithoutSearch 摘掉)—— 调用方据此把旧条目摘掉; // - (nil, err) ⇒ 这份配置会让后端**下次启动直接失败**(主 provider 未登记/未配置, // design/27 §3 的 fail-fast)—— 调用方必须如实报错,绝不留下一个起不来的文件。 // // 为什么要构造整份 io 工具集再挑出 web-search:io 包的公开构造入口只有 // `iotools.New(cfg, opts...)`(整份集合),没有「只造 web-search」的入口,而 // `plugins/tools/io` 不在本片文件面内。代价是一次 New(几个工具对象 + 一个 http.Client // 壳;Transport 是共享的 http.DefaultTransport,不新开连接池)—— 面板保存是低频动作, // 付得起,换来的是**重载后的工具与启动期构造的逐字段同源**(不是第二份接线逻辑)。 func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) { set, err := iotools.New(a.cfg, a.webSearchOptions(cause)...) if err != nil { return nil, err } for _, t := range set { if t.Name() == webSearchToolName { return t, nil } } return nil, nil } // webSearchOptions 把生效配置翻译成 io 选项。cause 说明**为什么**解析这一次(启动装配 / // 面板保存后的热重载),只进日志:同一个函数被两处调用,日志里必须能分清是哪一次。 // 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。 func (a *app) webSearchOptions(cause string) []iotools.Option { log := logger.WithSource(a.log, ioLogSource) ws, source := a.effectiveWebSearch(log) // ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。 if decision := ws.ResolveProvider(); decision.Disabled { // 配置值**绝不写进日志**(只记键名与原因)。 log.Warn("web-search tool not registered", "selectorKey", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "configSource", source, "cause", cause, "effect", "the model will not see a web-search tool at all") return []iotools.Option{iotools.WithoutSearch()} } specs := searchProviderSpecs(ws) // ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s) // 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置 // 错误,因此不 fatal,只留一条带键名的 WARN。 if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) { log.Warn("web-search tool not registered", "endpointKey", webSearchEndpointEnv, "reason", endpoint.Reason, "configSource", source, "cause", cause, "effect", "the model will not see a web-search tool at all") return []iotools.Option{iotools.WithoutSearch()} } // primary 是**生效值**(可能来自面板文件、也可能来自 `.env` 播种)⇒ 它旁边的键名只是 // 「这个键在哪份配置里」,而 source 才是「本次的值从哪来」。两者都打出来,不混为一谈。 log.Info("web-search providers configured", "cause", cause, "selectorKey", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)), "fallbackKey", webSearchFallbackEnv, "fallback", ws.Fallback, "configSource", source, "available", availableProviderNames(specs)) opts := []iotools.Option{ iotools.WithSearchProviders(specs...), // 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key) // ⇒ io 工具集在启动期报错(不许静默回落)。 iotools.WithSearchSelection(ws.Provider, ws.Fallback), iotools.WithSearchMaxResults(ws.MaxResults), } switch { case ws.Timeout > 0: opts = append(opts, iotools.WithSearchTimeout(ws.Timeout)) case ws.Timeout < 0: log.Warn("web-search timeout is negative, treated as unset", "key", webSearchTimeoutEnv, "configSource", source, "cause", cause, "effect", "io package default timeout applies") } return opts } // effectiveWebSearch 解析 web-search 的**生效配置**,并报告它是从哪儿来的: // // - 面板文件存在且合法 ⇒ 它是唯一来源(design/09 §5.7 E 的「先读可变源」); // - 文件缺失/为空 ⇒ 用 `.env` 播种落盘,本次行为与 `.env` 完全一致(「缺失回落 `.env` // 播种」,照 models.json materialize 范式); // - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。 // // 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。 func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, string) { env := a.cfg.WebSearch path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg)) f, found, err := runtimeconfig.LoadWebSearch(path) switch { case err != nil: log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)", "path", path, "error", err) return env, webSearchSourceEnv case !found: return a.seedWebSearchConfig(log, path, env), webSearchSourceEnv } warnUnknownSearchProviders(log, path, f) eff, convErr := effectiveSearchConfig(env, f) if convErr != nil { log.Warn("web-search 面板配置无法生效,本次按 .env 运行", "path", path, "error", convErr) return env, webSearchSourceEnv } log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers)) return eff, webSearchSourcePanel } // seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。 // // 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。 func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebSearchConfig) config.WebSearchConfig { seed := seedWebSearchFile(env) if err := runtimeconfig.SaveWebSearch(path, seed); err != nil { log.Warn("web-search 面板配置播种失败(本次按 .env 运行)", "path", path, "error", err) return env } log.Info("web-search 面板配置已按 .env 播种(此后面板是唯一来源)", "path", path, "providers", len(seed.Providers)) return env } // seedWebSearchFile 把 `.env` 的 web-search 段投影成可变源的初始形状。 // // **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」, // 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。 func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile { enabled := runtimeconfig.EnableFlag(true) entries := make([]runtimeconfig.ProviderEntry, 0, len(searchProviderVector(ws))+1) // searxng 的基地址 = `WEB_SEARCH_ENDPOINT`。关闭哨兵(空 / off / none)播成空串: // 两者语义相同(都表示不出这家),但面板里不该显示一个不是地址的 "off"。 entries = append(entries, runtimeconfig.ProviderEntry{ Provider: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint), Enabled: enabled, }) for _, p := range searchProviderVector(ws) { entries = append(entries, runtimeconfig.ProviderEntry{ Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled, }) } return runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: ws.Provider, Fallback: ws.Fallback, MaxResults: ws.MaxResults, Timeout: seedTimeout(ws.Timeout), Providers: entries, } } // effectiveSearchConfig 把面板文件翻译成工具要的配置。 // // **权威边界**(写清楚,免得「谁覆盖谁」靠猜): // // - **结构以文件为准**:主 provider / 降级链 / 各家条目都来自文件 —— 文件里没有的家就是 // 没有(绝不从 `.env` 复活),这正是「面板可改」的意思; // - **凭据以文件为准**:某家的 apiKey 为空 = 这家没配(绝不拿 `.env` 的 key 顶上, // 否则「我在面板里删了它」会变成一句假话); // - **只有「没写」才回落 `.env`(= 代码默认值)**:maxResults / timeout / 云家的 baseUrl。 // 前两个标量在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分; // 云家 baseUrl 回落到厂商默认地址(它是代码默认值,不是凭据),searxng 的地址**不回落** // —— 在 searxng 身上「地址为空」就是「关掉这家」的开关语义,回落会让"清空地址"变成假话。 func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (config.WebSearchConfig, error) { out := env out.Provider = f.Provider out.Fallback = append([]string(nil), f.Fallback...) if f.MaxResults > 0 { out.MaxResults = f.MaxResults } d, err := f.TimeoutDuration() if err != nil { return env, err } if d > 0 { out.Timeout = d } // 凭据一律清零后按条目回填:文件没给的家就是没配。 out.ZhipuAPIKey = "" out.AliyunIQSAPIKey = "" out.BochaAPIKey = "" out.VolcengineAPIKey = "" out.BaiduQianfanAPIKey = "" out.Endpoint = "" for _, e := range f.Providers { if !e.IsEnabled() { continue } name := strings.ToLower(strings.TrimSpace(e.Provider)) base := strings.TrimSpace(e.BaseURL) switch name { case search.NameSearXNG: out.Endpoint = base case search.NameZhipu: out.ZhipuAPIKey = e.APIKey out.ZhipuBaseURL = orDefault(base, out.ZhipuBaseURL) case search.NameAliyunIQS: out.AliyunIQSAPIKey = e.APIKey out.AliyunIQSBaseURL = orDefault(base, out.AliyunIQSBaseURL) case search.NameBocha: out.BochaAPIKey = e.APIKey out.BochaBaseURL = orDefault(base, out.BochaBaseURL) case search.NameVolcengine: out.VolcengineAPIKey = e.APIKey out.VolcengineBaseURL = orDefault(base, out.VolcengineBaseURL) case search.NameBaiduQianfan: out.BaiduQianfanAPIKey = e.APIKey out.BaiduQianfanBaseURL = orDefault(base, out.BaiduQianfanBaseURL) } } return out, nil } // warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。 // // 它们不可能被选中(选中的家由 io 工具集在启动期校验),但「配了却不生效」必须说出来 —— // 静默忽略正是「我明明配了 Tavily 却搜不出」那类报障的来源。 func warnUnknownSearchProviders(log *slog.Logger, path string, f runtimeconfig.WebSearchFile) { for _, e := range f.Providers { name := strings.ToLower(strings.TrimSpace(e.Provider)) if name == "" || search.Known(name) { continue } log.Warn("web-search 面板配置里有未登记的提供商,已忽略", "path", path, "provider", name, "known", search.KnownNames()) } } // selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。 func selectsSearXNG(ws config.WebSearchConfig) bool { return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG) } // searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。 // searxng 不在这张表里(它的「基地址」就是 `WEB_SEARCH_ENDPOINT`,由 ResolveEndpoint 裁决)。 type searchProviderWire struct { Name string APIKey string BaseURL string } // searchProviderVector 是本期内置各家的配置投影(**一家一行**)。 // // 它同时服务两处:装配接线表(searchProviderSpecs)与 `.env` 播种(seedWebSearchFile)—— // 一家一处,新增一家 = 这里加一行 + search 包加一个文件。 func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire { return []searchProviderWire{ {Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL}, {Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL}, {Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL}, {Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL}, {Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL}, } } // searchProviderSpecs 把生效配置翻译成各家接线。 // // Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key; // false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。** func searchProviderSpecs(ws config.WebSearchConfig) []iotools.SearchProviderSpec { endpoint := ws.ResolveEndpoint() specs := []iotools.SearchProviderSpec{{ Name: search.NameSearXNG, BaseURL: endpoint.URL, Configured: endpoint.Enabled, }} // 各家独立一组键(design/27 §3):新增一家 = searchProviderVector 加一行 + 一个文件。 for _, p := range searchProviderVector(ws) { specs = append(specs, iotools.SearchProviderSpec{ Name: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Configured: search.Configured(p.Name, p.APIKey, p.BaseURL), }) } return specs } // availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。 // **不含任何密钥** —— 只回名字。 func availableProviderNames(specs []iotools.SearchProviderSpec) []string { out := make([]string, 0, len(specs)) for _, s := range specs { if s.Configured { out = append(out, s.Name) } } return out } // seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串。 func seedEndpoint(endpoint string) string { if config.IsWebSearchOffSentinel(endpoint) { return "" } return strings.TrimSpace(endpoint) } // seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。 func seedTimeout(d time.Duration) string { if d <= 0 { return "" } return d.String() } // orDefault 在 value 去空白后非空时用它,否则用 fallback(云家 baseUrl 的「没写就回落 // 代码默认值」口径,见 effectiveSearchConfig 的权威边界)。 func orDefault(value, fallback string) string { if s := strings.TrimSpace(value); s != "" { return s } return fallback }