Files
Cyrene_v2/cmd/cyrene/wire_services.go
T
AskaEth 2d211c2d16 feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
2026-10-06 21:16:23 +08:00

298 lines
12 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
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// wireAuxiliary 组装执行提供者、任务管理器、IoT 管理器、渠道会话管理器、
// 自动化服务与后台思考服务。
func (a *app) wireAuxiliary() error {
provider, err := exec.New(a.cfg.ExecSandbox, a.log)
if err != nil {
return fmt.Errorf("exec provider: %w", err)
}
a.execProv = provider
tmgr, err := task.New(a.cfg, provider, a.log, task.WithBus(a.bus))
if err != nil {
return fmt.Errorf("task manager: %w", err)
}
a.tasks = tmgr
iotMgr, err := iot.New(a.cfg.IoT, a.log, iot.WithBus(a.bus))
if err != nil {
return fmt.Errorf("iot: %w", err)
}
a.iot = iotMgr
sess, err := session.New(context.Background(), a.db, a.log)
if err != nil {
return fmt.Errorf("session manager: %w", err)
}
a.sessions = sess
hist, herr := history.NewStore(context.Background(), a.db, a.log,
history.WithDocumentRefs(documents.NewRefCounter(a.log)))
if herr != nil {
return fmt.Errorf("history store: %w", herr)
}
a.history = hist
automation, err := automations.NewService(automations.Config{}, automations.Deps{
Bus: a.bus,
Loop: a.loop,
Log: logger.WithSource(a.log, "services/automations"),
})
if err != nil {
return fmt.Errorf("automations: %w", err)
}
a.automation = automation
bt, err := backthink.New(a.backthinkTuning(), backthink.Deps{
Bus: a.bus,
Thinker: backthink.NewLoopThinker(a.loop, a.history),
Recent: a.history,
State: backthink.NewIoTStateProvider(iotMgr, a.log),
Exporter: backthink.NewPushOutlet(a.pushOutletConfig()),
DB: a.db,
Log: logger.WithSource(a.log, "services/backthink"),
})
if err != nil {
return fmt.Errorf("backthink: %w", err)
}
a.backthink = bt
a.wireVoice()
a.wireMCP()
a.wireSkills()
// SK-5(design/09 §6.3):技能注册表填好之后,把技能面接到循环的模型面装配上
// —— 清单投影 + 命中才展开正文 + 技能轮次的工具面收缩。技能来源就是上面刚
// 注册进去的能力注册表(每轮实时读,因此面板热装的技能下一轮即出现在清单里)。
// 必须排在 wireSkills 之后、且在任何 Run 之前(wireAuxiliary 仍在装配期)。
a.loop.SetSkillFace(a.buildSkillFace())
a.wireTerminalTool()
a.wireSubagentTool()
a.wireSessionReadTool()
// X1(裁决 15):把外围子系统(IoT)以 KindIoT 放进能力面,并注册只读的
// capability_list 工具。排在最后,是为了让它列出的清单已经包含前面各步
// 注册进来的工具(terminal / subagent / session_read 等)。
a.wireCapabilitySurface()
// design/12 §N C.3:把「自排一次思考」暴露成能力注册表里的 request_think 工具。
// 排在 wireCapabilitySurface 之后无妨 —— 清单是调用时实时读注册表的,顺序不影响
// 它列出谁。
a.wireSelfWakeTool()
return nil
}
// wireSelfWakeTool 把 request_think 注册进能力面(design/12 §N C.3):
// `a.dispatcher.Register(backthink.NewThinkTool(a.backthink))`。
//
// 时序安全:a.backthink 在 wireAuxiliary 上半段(本函数里)就建好了,而 a.loop /
// a.dispatcher 在更早的 wireRuntime 已就位 ⇒ 这一步不需要新的装配顺序。
//
// 与 capability_list 同口径:**失败只告警、绝不阻塞启动** —— 少一个工具不是启动失败
// 的理由(自排的其余面:判定、闸、审计都已在 backthink 内部就绪)。
func (a *app) wireSelfWakeTool() {
log := logger.WithSource(a.log, "services/backthink")
if a.backthink == nil {
log.Warn("self wake tool register skipped: no backthink service")
return
}
if a.dispatcher == nil {
log.Warn("self wake tool register skipped: no dispatcher")
return
}
if err := a.dispatcher.Register(backthink.NewThinkTool(a.backthink)); err != nil {
log.Warn("self wake tool register skipped", "error", err)
return
}
log.Info("self wake tool registered", "tool", backthink.ThinkToolName)
}
// buildSkillFace 构造技能面(SK-5,design/09 §6.3):来源是能力注册表里的
// KindSkill 条目 —— 启动时 wireSkills 载入的与面板热装的技能都在同一处,且
// **每轮实时读取**,因此热装技能下一轮就出现在模型清单里,无需重启。
//
// 这里只给来源与日志,不配任何旋钮:单条清单上限(1536 字符)、清单条数上限、
// 正文注入字节上限都由代码常量给出(技能包的 listing.go 与 loop 的
// assemble_skill.go 各有带依据的注释),既不新增 `.env` 键,也不引入第二套
// 配置面(主人 2026-10-06 红线:.env 冻结为最小化引导面)。
func (a *app) buildSkillFace() *loop.SkillFace {
return loop.NewSkillFace(loop.SkillFaceOptions{
Source: func() []sdk.Skill {
if a.caps == nil {
return nil
}
caps := a.caps.List(registry.KindSkill)
out := make([]sdk.Skill, 0, len(caps))
for _, cap := range caps {
// 注册表里不是技能的条目(理论上不会有,KindSkill 条目按契约就是
// sdk.Skill)直接跳过:宁可少一个技能,也不让类型断言失败的条目
// 把整轮装配拖垮。
if s, ok := cap.Value.(sdk.Skill); ok && s != nil {
out = append(out, s)
}
}
return out
},
Log: logger.WithSource(a.log, "cmd/cyrene/skills"),
})
}
// backthinkTuning 组装后台思考的调优配置:env 引导面(心跳周期 / 安静时段 / 推送频次)
// 由 ConfigFromBackground 映射,**自排成本闸**(design/12 §N C.5)从
// `$DATA_DIR/config/backthink.json` 读 —— 零新 env 键(主人 2026-10-06 `.env` 冻结红线)。
//
// 文件缺失/全空白 ⇒ 保持零值,由 backthink 的 normalize 回落代码默认(4 轮/小时;
// 「≤0 回落默认」在服务侧统一执行,这里不再造第二套默认值)。存在却解不开 ⇒ 记一条
// 带 source 的 WARN 并回落默认:坏配置**不许**静默变成「无上限」。
//
// 与 QUIET_* 同口径:启动期读一次,**改文件需重启**。
func (a *app) backthinkTuning() backthink.Config {
cfg := backthink.ConfigFromBackground(a.cfg.Background)
tuningPath := runtimeconfig.DefaultBackthinkPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadBackgroundTuning(tuningPath)
if err != nil {
logger.WithSource(a.log, "services/backthink").Warn(
"backthink tuning unreadable; falling back to code defaults", "path", tuningPath, "error", err)
return cfg
}
cfg.SelfWakeLimit = tuning.SelfWakePerWindow
cfg.SelfWakeWindow = time.Duration(tuning.SelfWakeWindowSec) * time.Second
return cfg
}
// pushOutletConfig 把环境变量里的推送出口配置块映射为 backthink 的导出器配置。
// 个推(Getui)凭据只被 config 读取、并不消费:备用 SDK 尚未接入,因此出口在
// 待接入该档位时记日志,而不是伪造一次推送。token 只透传,绝不写日志。
func (a *app) pushOutletConfig() backthink.PushOutletConfig {
po := a.cfg.PushOutlet
return backthink.PushOutletConfig{
Enabled: po.Enabled,
URL: po.URL,
Token: po.Token,
Timeout: time.Duration(po.TimeoutSec) * time.Second,
Log: a.log,
}
}
// close 按依赖顺序关闭每个子系统并释放资源。
func (a *app) close(ctx context.Context) {
// 文档回收循环要先用连接池(每轮一次查询),因此排在关库之前。
a.docGC.stop()
// 用量落库与保留期清理同样要用连接池:先把队列里剩下的样本写完,再停清理。
a.stopUsage()
if a.backthink != nil {
_ = a.backthink.Stop(ctx)
}
// 记忆整理与 backthink 同属后台定时任务:先停它,再关库(它每轮只有
// 查询与候选写入,且 Stop 会等本轮跑完)。
if a.memoryTidy != nil {
_ = a.memoryTidy.Stop(ctx)
}
// 入库前审查与整理同属记忆后台任务:先停它(Stop 会等正在审的那一条跑完),
// 再关库 —— 否则正在写 memory_items 的审查会撞上已关闭的连接池。
if a.memoryReview != nil {
_ = a.memoryReview.Stop(ctx)
}
if a.automation != nil {
_ = a.automation.Stop(ctx)
}
if a.channelBridge != nil {
a.channelBridge.Stop(ctx)
}
if a.scheduler != nil {
_ = a.scheduler.CancelAll(ctx)
}
if a.gw != nil {
a.gw.Close()
}
// 浏览器会话(切片 X3 把 V5 留下的挂点接在这里):`browser` 工具持有**进程外**
// 资源 —— 我们起的真 Chromium 子进程、一条 CDP 连接、profile 上的 SingletonLock,
// 因此必须显式关掉,而不是交给进程退出。
//
// 位置的三条理由(顺序在这里是有含义的):
// 1. **排在渠道 / 调度器 / 网关之后**:这三步停掉的是「还会发起工具调用的入口」
// (IM → 子代理 → Web),此刻浏览器不会再从某个正在跑的回合底下被抽走;
// 2. **排在 MCP / 插件 / 任务之前**:它属于能力面,与那几步同类,且不碰存储
// (db / bus 都在后面),提前关不影响任何后续关停;
// 3. **绝不能拖到进程退出以后**:Windows 上父进程退出不会带走子进程,孤儿 Chrome
// 会继续占着 profile 锁,下次启动就得靠「认领」绕过去 —— 早一行关掉,孤儿就
// 少一分。`Manager.Close` 自带超时(不等 shutCtx),所以这一步不会被关停预算卡住。
//
// 工具对象没有单独的 app 字段(能力注册面就是它的家),因此按工具名取回:没注册
// (本机没浏览器)就是空操作,关闭链不因它存在与否改道。
if a.caps != nil {
if entry, err := a.caps.Get(browsertools.ToolName); err == nil {
if closer, ok := entry.Value.(io.Closer); ok {
if cerr := closer.Close(); cerr != nil {
logger.WithSource(a.log, "cmd/cyrene").Warn("browser tool close failed", "error", cerr)
} else {
// 一行可追溯的收尾记录:排障时「退出后还有没有残留的浏览器」看的
// 就是它(孤儿 Chrome 会占着 profile 锁,下一次启动要靠认领绕过去)。
logger.WithSource(a.log, "cmd/cyrene").Info("browser tool closed")
}
}
}
}
for _, reg := range a.mcpRegs {
_ = reg.Unregister(ctx)
}
if a.plugins != nil {
a.plugins.Close(ctx)
}
if a.tasks != nil {
_ = a.tasks.Close()
}
if a.vindex != nil {
_ = a.vindex.Close()
}
if a.bus != nil {
_ = a.bus.Close()
}
if a.db != nil {
_ = a.db.Close()
}
a.log.Info("cyrene shutdown complete")
}
// resolveDataDir 返回配置的数据目录(来自 .env / 环境变量,经 cfg.DataDir);
// 只有在没有配置目录时才回落到与操作系统相称的默认值。
func resolveDataDir(cfg *config.Config) string {
if cfg.DataDir != "" {
return cfg.DataDir
}
return platform.DefaultDataDir()
}
// resolveSQLitePath 返回实际使用的 SQLite 路径:当配置里仍是默认值时,
// 把它推导到数据目录下。
func resolveSQLitePath(cfg *config.Config, dataDir string) string {
p := cfg.DB.SQLitePath
if strings.TrimSpace(p) == "" || p == defaultSQLitePath {
return filepath.Join(dataDir, "cyrene.db")
}
return p
}