Files
Cyrene_v2/cmd/cyrene/wire_io_tools.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

416 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 工具」只由这一处决定。
//
// 它**同时是切片 V5 的 browser 接线挂点**:组合根在启动期只调用这一个归属本片的装配
// 函数(wire_runtime.go 的 `a.ioToolOptions()`),而 browser 的接线不得改他人拥有的
// `wire_runtime.go` / `wire.go` ⇒ 由这里转交给 `wireBrowser()`(见 wire_browser.go 的
// 口径说明)。转交对 io 选项**零影响**(它不改、不追加任何 iotools.Option),并且
// **幂等**:同一个 app 上重复调用只会发现工具已注册并跳过。
func (a *app) ioToolOptions() []iotools.Option {
a.wireBrowser()
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
}