package main // 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及 // **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层)。 // // 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限, // 而本片新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置 // ⇒ 启动报错)需要一个能单测的落点。 // // 四条口径: // // 1. **后端由配置选定**:`WEB_SEARCH_PROVIDER` 选主 provider,`WEB_SEARCH_FALLBACK` // 给降级链;各家的 key/base URL 走 `WEB_SEARCH__*`(design/27 §3)。 // 2. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭 // (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** —— // 模型根本看不见它,而不是看得见、一调就报错。 // 3. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点), // false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在 // **启动期报错**,绝不静默回落到兜底。 // 4. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时; // 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。 import ( "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" 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 逐字核对。 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" // 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()...) } // webSearchOptions 把 web-search 的配置翻译成 io 选项。 // 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。 func (a *app) webSearchOptions() []iotools.Option { log := logger.WithSource(a.log, ioLogSource) ws := a.cfg.WebSearch // ① 后端显式关闭(WEB_SEARCH_PROVIDER = 空 / off / none)⇒ 整件工具不注册。 if decision := ws.ResolveProvider(); decision.Disabled { // 配置值**绝不写进日志**(只记键名与原因)。 log.Warn("web-search tool not registered", "env", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "effect", "the model will not see a web-search tool at all") return []iotools.Option{iotools.WithoutSearch()} } specs := a.searchProviderSpecs() // ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s) // 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置 // 错误,因此不 fatal,只留一条带键名的 WARN。 if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && a.selectsSearXNG() { log.Warn("web-search tool not registered", "env", webSearchEndpointEnv, "reason", endpoint.Reason, "effect", "the model will not see a web-search tool at all") return []iotools.Option{iotools.WithoutSearch()} } log.Info("web-search providers configured", "env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)), "env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback, "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", "env", webSearchTimeoutEnv, "effect", "io package default timeout applies") } return opts } // selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。 func (a *app) selectsSearXNG() bool { selected := strings.TrimSpace(a.cfg.WebSearch.Provider) return strings.EqualFold(selected, search.NameSearXNG) } // searchProviderSpecs 把 `WEB_SEARCH_*` 配置翻译成各家接线(一家一行)。 // // Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key; // false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。** func (a *app) searchProviderSpecs() []iotools.SearchProviderSpec { ws := a.cfg.WebSearch endpoint := ws.ResolveEndpoint() specs := []iotools.SearchProviderSpec{{ Name: search.NameSearXNG, BaseURL: endpoint.URL, Configured: endpoint.Enabled, }} // 各家独立一组键(design/27 §3):新增一家 = 这里加一行 + search 包加一个文件。 for _, p := range []struct{ name, key, base string }{ {search.NameZhipu, ws.ZhipuAPIKey, ws.ZhipuBaseURL}, {search.NameAliyunIQS, ws.AliyunIQSAPIKey, ws.AliyunIQSBaseURL}, {search.NameBocha, ws.BochaAPIKey, ws.BochaBaseURL}, {search.NameVolcengine, ws.VolcengineAPIKey, ws.VolcengineBaseURL}, {search.NameBaiduQianfan, ws.BaiduQianfanAPIKey, ws.BaiduQianfanBaseURL}, } { specs = append(specs, iotools.SearchProviderSpec{ Name: p.name, APIKey: p.key, BaseURL: p.base, Configured: search.Configured(p.name, p.key, p.base), }) } 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 }