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)。
298 lines
12 KiB
Go
298 lines
12 KiB
Go
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
|
||
}
|