断点(M29 勘测定论):后台批次结算后 `subagent.result` 发了、hub 也把它中继成 WS 帧,但**没有任何代码把结果回填父会话** —— `chat_messages` 无行、模型下一轮 看不到,于是「她拍了个子代理出去,但没有下文」。 回填拆成互不冒充的两条面(人格=身份红线:子代理结论绝不写成她的人格发言): - 可观测/历史面(新文件 subagent_backfill.go):独立订阅者(不塞进 hub)订阅 `subagent.result`,写父会话一条 `role=system` 注记(内容以 `[子代理结果]` 开头 并点名 `sub-<id>`),随后走既有 `message_persisted` 扇出 ⇒ 刷新/重连后仍在; - 模型面:同一行经转录播种成为下一回合的**系统注记**(`model.RoleSystem`), 不是「她说过的话」。 落库口径:角色恒为 system(不冒充 user/assistant)、PersonaID 与 TurnID 留空 (注记不属于任何角色、也不属于任何一路回合)。 记忆抽取排除(新文件 subagent_backfill_extract.go):`WithoutSubagentNotes` 装饰 `memory.Extractor`,判据是元数据(role=system + 注记标记,与 loop 的 summaryMarker 同一手法),组合根经 `loopMemoryExtractor` 套上 ⇒ 子代理结论绝不进记忆库;LLM 提取器的异步能力原样保留,压缩摘要(同样是 system)不被误剔。 可观测补齐(M29 §7):`SpawnTool.await` 发布成功补带 source 的 INFO,`b==nil` 与 「无结果」的静默 return 改为 WARN,发布失败 WARN 带上 count/session;回填订阅者 顺手读 `bus.Dropped()`,涨了就记一条 WARN(总线丢事件不再是黑洞)。 测试(均可变异打红):父会话恰一条 system 行且零伪造助手发言;注记/摘要/批次正文 有界且逐代理点名;不可用载荷与落库失败各留 WARN 且不广播;Start 真订阅总线; 结果不进记忆抽取(同步/异步/端到端接缝);组合根真装配(真 bus 发布 → 父会话多 一行)。回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿。 不做:不改 `subagent_result` 帧形状、不加进 frameReachesAll、不新开路由/帧族、 不注入人设、不改 Docs(契约归一建议报塔)。
305 lines
15 KiB
Go
305 lines
15 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// capacityEmitter 是面向网关的 capacity_awaken 广播出口。
|
||
// *gateway.Server 实现它;测试用假实现替换以捕获入参。
|
||
type capacityEmitter interface {
|
||
BroadcastCapacityAwaken(sessionID, fromState, toState, tool string)
|
||
}
|
||
|
||
// wireFrontend 用内嵌/外部 SPA 文件系统与 JWT/RBAC 认证器构建网关。
|
||
// Session/Persona/Memories 接到具体的管理器上,管理端/用户端的列表接口才会
|
||
// 返回真实数据。
|
||
func (a *app) wireFrontend() error {
|
||
jwt := auth.New(a.cfg.Auth)
|
||
apiKeys := a.wireAPIKeys()
|
||
// 插件管理面板声明中枢(design/09 §5.2.1;切片 PLG-2)必须挂在**同一个**插件管理器上
|
||
// (两处各存一份生命周期事实必然分叉),且在 gateway.New 之前接好:网关的两条面板端点
|
||
// 都经 s.plugins.Panels() 取它。见 wirePanelHub。
|
||
wirePanelHub(a)
|
||
ragStore := knowledge.New(knowledge.Deps{
|
||
Projects: a.projStore,
|
||
Retriever: a.retriever,
|
||
DefaultPersona: a.defaultPersonaID(),
|
||
VectorOn: a.vindex != nil,
|
||
Log: a.log,
|
||
})
|
||
auditStore, aerr := gateway.NewSQLiteAuditStore(context.Background(), a.db, a.log)
|
||
if aerr != nil {
|
||
a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr)
|
||
auditStore = nil
|
||
}
|
||
// 不透明刷新令牌(B2)与主 SQLite 句柄共用同一连接。这里失败要优雅降级:
|
||
// 登录只是不下发 refreshToken,/auth/refresh 报「未配置」,而不是让整个
|
||
// 启动失败。store 以接口形式持有,构造失败就保持 nil(若存成带类型的 nil
|
||
// 指针,接口值非 nil,首次调用会 panic)。
|
||
var refreshStore gateway.RefreshGrantStore
|
||
if store, rerr := auth.NewRefreshStore(context.Background(), a.db, a.log); rerr != nil {
|
||
a.log.Warn("refresh token store unavailable", "error", rerr)
|
||
} else {
|
||
refreshStore = store
|
||
}
|
||
// a.history 在 wireAuxiliary 里构建,让渠道桥(wireChannels)与网关共用同一个
|
||
// 持久化存储。IM 通路之前 History 是个空转依赖,因此把它的创建顺序提前不会
|
||
// 影响任何下游。
|
||
//
|
||
// 记忆整理「试算」出口(契约 v0.75;切片 MT1):注入的正是低峰心跳跑的那个
|
||
// *memory.TidyService(同一套规则、同一份配置)。**必须**在它为 nil 时留成
|
||
// **真 nil 接口** —— 带类型的 nil 指针会让网关的 `== nil` 判断失效,端点就会
|
||
// 去调一个空接收者。
|
||
var tidyDryRunner gateway.MemoryTidyDryRunner
|
||
if a.memoryTidy != nil {
|
||
tidyDryRunner = a.memoryTidy
|
||
}
|
||
gw, err := gateway.New(a.cfg, gateway.Deps{
|
||
Log: logger.WithSource(a.log, "gateway"),
|
||
Auth: jwt,
|
||
Bus: a.bus,
|
||
Registry: a.caps,
|
||
Loop: a.loop,
|
||
Session: a.sessions,
|
||
Personas: a.personas,
|
||
Memories: a.memStore,
|
||
History: a.history,
|
||
IoT: a.iot,
|
||
Scheduler: a.scheduler,
|
||
Tasks: a.tasks,
|
||
Automations: a.automation,
|
||
Users: apiKeys,
|
||
Refresh: refreshStore,
|
||
Backthink: a.backthink,
|
||
RAG: ragStore,
|
||
LLM: a.llm,
|
||
// EnvPath 与 main.go 启动时 config.Load(envPath) 用同一条路径:面板
|
||
// 「重置为 env 默认」(切片 R1)按既有解析入口重读同一份 `.env` 声明,
|
||
// 网关自己不 fopen `.env`。
|
||
EnvPath: envPath,
|
||
Channels: a.channels,
|
||
Plugins: a.plugins,
|
||
OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log},
|
||
AuditStore: auditStore,
|
||
Streaming: true,
|
||
Frontend: resolveFrontendFS(a.log),
|
||
Webhooks: a.channelBridge.WebhookHandlers(),
|
||
DatabasePath: a.dbPath,
|
||
DataDir: resolveDataDir(a.cfg),
|
||
Database: a.db,
|
||
Vector: a.vectorState(),
|
||
// Flow 是「最近一轮链路图」的只读出口(契约 v0.71):注入的正是上面
|
||
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
|
||
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
|
||
Flow: a.flow,
|
||
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;切片 F2):注入的正是
|
||
// 发布器每轮落盘用的**同一个**存储句柄,因此 `?turnId=` 与 `turns[]` 读到的就是
|
||
// 收尾时写进去的历史轮。未接线时为真 nil(端点回退到只有内存的行为)。
|
||
FlowGraphs: a.flowGraphStore(),
|
||
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
|
||
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
|
||
// usage_unavailable,不回假汇总)。
|
||
Usage: model.NewUsageReader(a.db),
|
||
// EventStore 是渠道事件留存的只读查询出口(design/26 §4.5;切片 E6):
|
||
// 复用组合根已按 CHANNEL_EVENT_* 建好的那份 store。未接线时为**真 nil**
|
||
// (`channelEventStore()` 的取舍),端点据此回 500 channel_events_unavailable,
|
||
// 绝不回空 200 假装「没有事件」。
|
||
EventStore: a.channelEventStore(),
|
||
// MemoryTidy 是记忆整理「试算」的只读出口(契约 v0.75;切片 MT1):上面按
|
||
// **真 nil** 取舍过的接口值。
|
||
MemoryTidy: tidyDryRunner,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("gateway: %w", err)
|
||
}
|
||
gw.SetVoice(a.voice)
|
||
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
|
||
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
|
||
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
|
||
wireGatewayTraceSink(a, gw)
|
||
if a.channelBridge != nil {
|
||
// 把渠道桥中「主人私聊」持久化的消息接入编排器所用的同一个
|
||
// `message_persisted` 广播,让 Web 客户端用同一套统一帧来镜像 IM 消息
|
||
// (design/16 §4.4)。
|
||
a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted)
|
||
// IM 路由的**主动推送**另走一条通知通道(切片 P0b):它落的也是主会话,但帧顶
|
||
// 带结构化判据 `proactive:true`,客户端据此在非前台补一发浏览器通知。此前 IM
|
||
// 路由只有上面那条普通帧 ⇒ 走 IM 的主动消息一条都不弹(而 `auto` 提示在主
|
||
// 人有已连接 IM 渠道时优先走 IM,恰恰是最常见的路径)。
|
||
a.channelBridge.SetOnProactivePersisted(a.gw.BroadcastProactivePersisted)
|
||
// design/16 §4.6 §6:被平台白/黑名单(allow/deny)丢弃的消息会记入网关审计
|
||
// 轨迹,这样即使它从未到达循环,这次拒绝也「audit 可查」。
|
||
a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) {
|
||
a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason)
|
||
})
|
||
}
|
||
a.capacitySink = gw
|
||
a.moodSink = gw
|
||
|
||
// 网关 hub(活跃客户端通路)与渠道桥(IM 回落)都已在位,现在接线 backthink 的
|
||
// 主动推送出口。backthink.New 执行时它们都还不存在(hub 需要循环,桥需要循环与
|
||
// 主人档案,这些都更晚才组装),若不在此处补接线,推送器会解析出 RouteNone。
|
||
// 每个 setter 都在服务的生命周期锁下重建推送器。
|
||
if a.backthink != nil {
|
||
a.backthink.SetActiveSink(gw)
|
||
if a.channelBridge != nil {
|
||
a.backthink.SetIMSink(a.channelBridge)
|
||
}
|
||
// design/12 §N C.5:自排的四个动作(queued/coalesced/capped/denied)走与渠道
|
||
// 黑名单拒绝同一条审计链(谁/什么/何时/结果),因此它们**面板可查**,不需要
|
||
// 新端点(GET /api/v1/admin/observability/audit 已存在)。同样是晚绑定:
|
||
// backthink.New 时网关还没组装。
|
||
a.backthink.SetAuditSink(gw)
|
||
}
|
||
// 子代理结果回填(M32;design/05 §2/§5/§9):网关 **已存在**(回填的实时出口就是
|
||
// 它的 `message_persisted` 扇出)之后才能接线。它订阅 `subagent.result`,把后台
|
||
// 批次的结论写成父会话的一条 **system 注记**(不冒充 user/assistant、不带 turn_id、
|
||
// 绝不进记忆抽取),因此「她拍了个子代理出去,然后没有下文」这件事在历史与下一轮
|
||
// 上下文里都有落点。装配细节与降级口径见 wire_subagent_backfill.go。
|
||
wireSubagentBackfill(a, gw)
|
||
return nil
|
||
}
|
||
|
||
// wirePanelHub 给已构造的插件管理器接上面板声明中枢,并把它接到既有进程内 bus 的
|
||
// `ws.panels_changed` 上(design/09 §5.2.1;切片 PLG-2)。之后再把内置插件的**动作
|
||
// 处理器**登记到管理器上(切片 L1/PLG-2 的执行面接线):没有这一步,面板的每个动作都
|
||
// 只能诚实回 501(声明在、没人执行)。
|
||
//
|
||
// 三条约束都在这里落地:
|
||
// - **不新开端口**:热更新信号只走进程内 bus(hub 订阅 ws.> ⇒ 帧类型 panels_changed),
|
||
// 与既有实时帧同一根管子;
|
||
// - **帧不带内容**:载荷只有 reason / pluginId,前端按自己的角色重拉一次
|
||
// GET /api/v1/admin/panels,避免在帧里再实现一遍角色过滤(也就不会漏过门);
|
||
// - **接线失败不阻断启动**:中枢构造失败只告警(网关随后对两条面板端点诚实回 501),
|
||
// 与 refresh store / key store 的降级口径一致。
|
||
func wirePanelHub(a *app) {
|
||
if a.plugins == nil {
|
||
return
|
||
}
|
||
log := logger.WithSource(a.log, "capability/plugin/panels")
|
||
hub, err := pluginmgr.NewPanels(log)
|
||
if err != nil {
|
||
a.log.Warn("panel hub unavailable, plugin panel endpoints will answer 501", "error", err)
|
||
return
|
||
}
|
||
hub.SetPublisher(func(ch pluginmgr.PanelChange) {
|
||
if a.bus == nil {
|
||
return
|
||
}
|
||
if err := a.bus.Publish(context.Background(), "ws.panels_changed", map[string]any{
|
||
"reason": ch.Reason,
|
||
"pluginId": ch.PluginID,
|
||
}); err != nil {
|
||
a.log.Warn("panels_changed publish failed", "reason", ch.Reason, "pluginId", ch.PluginID, "error", err)
|
||
}
|
||
})
|
||
a.plugins.AttachPanels(hub)
|
||
declareBuiltinPanels(hub, log)
|
||
// 「MCP 服务器」页(切片 M20):声明 + 动作处理器成对装配,且必须在中枢接线之后
|
||
// (AttachMCPPanel 经插件管理器取中枢,未接线时会响亮报错而不是静默不挂)。
|
||
// 它归属插件管理器自己的领域数据(Statuses/Install/SetEnabled/Uninstall),因此
|
||
// 与 web-search 的纯声明不同:这里一处调用同时完成声明与动作登记。
|
||
if err := a.plugins.AttachMCPPanel(); err != nil {
|
||
log.Warn("mcp server panel unavailable; its actions will answer 501", "error", err)
|
||
}
|
||
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
|
||
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
|
||
//
|
||
// 为什么要挡一下 `cfg == nil`:浏览器面板的配置路径与表单默认值**全部**由
|
||
// `$DATA_DIR` 推出(wire_browser_panel.go 里 `resolveDataDir(a.cfg)`),而部分只读
|
||
// 夹具(wire_panels_test.go / wire_plugin_actions_test.go 只填了 plugins/bus/log)
|
||
// 没有配置 ⇒ 直调会解空指针。生产路径上 wireFrontend 跑在 config.Load 之后,
|
||
// `a.cfg` 恒非 nil,因此这一支只为「不完整夹具」存在(同 wireBrowser 的取舍)。
|
||
if a.cfg != nil {
|
||
wireBrowserPanel(hub, a, a.log)
|
||
}
|
||
// 登记动作处理器必须在**声明之后**:登记期会核对「登记的处理器有没有对应声明」,
|
||
// 顺序反过来只会得到一串「声明还没到」的 WARN。
|
||
// 日志来源另标一处(动作接线 ≠ 面板中枢):`source=cmd/cyrene/wire_plugin_actions`
|
||
// 让「谁登记了哪些动作 / 哪些声明没人实现」能在日志里独立定位。
|
||
wireBuiltinPluginActions(a.plugins, hub, a, logger.WithSource(a.log, actionWireLogSource))
|
||
}
|
||
|
||
// declareBuiltinPanels 把**编译进二进制的内置插件**的面板声明交给中枢
|
||
// (design/09 §5.5 的 dogfood:「网络搜索 → 搜索提供商」页)。
|
||
//
|
||
// 为什么在组合根做这一步:内置插件的声明与它自己的能力同源(`plugins/tools/io/search`
|
||
// 里就摆着 `AdminPanels()`),没有卸载/安装路径 ⇒ 只有「装配期登记一次」这一种生命周期。
|
||
// 热装 MCP 插件的声明走另一条路(manifest 读取 + 生命周期重算,后续切片)。
|
||
//
|
||
// 声明被拒只告警、不阻断启动:一个坏声明不该让整个后端起不来(§5.2.1 的口径是
|
||
// 「该面板不注册 + 显式报错」,不是「宿主陪葬」)。
|
||
func declareBuiltinPanels(hub *pluginmgr.Panels, log *slog.Logger) {
|
||
declarations := []struct {
|
||
pluginID string
|
||
panels []sdk.Panel
|
||
}{
|
||
{search.PanelPluginID, search.AdminPanels()},
|
||
}
|
||
for _, d := range declarations {
|
||
accepted, err := hub.Declare(d.pluginID, d.panels)
|
||
if err != nil {
|
||
log.Warn("builtin panel declaration rejected",
|
||
"pluginId", d.pluginID, "accepted", accepted, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key
|
||
// 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点
|
||
// 长凭据的语义与「重启即作废」自相矛盾)。
|
||
//
|
||
// 降级口径与上面的 refresh store 一致:构造或载入失败**不阻断启动**,key 仍可签发
|
||
// 与校验,只是不跨重启存活;后果写进日志,不静默。注意 AttachKeyStore 在 Load 失败
|
||
// 时仍会把 store 接上(见其注释)——那样之后的签发会响亮地失败,而不是又一次
|
||
// 「面板报成功、重启全 401」。
|
||
func (a *app) wireAPIKeys() *auth.APIKey {
|
||
keys := auth.NewAPIKey(a.cfg.Auth)
|
||
if a.db == nil {
|
||
return keys
|
||
}
|
||
store, err := auth.NewSQLiteKeyStore(a.db, a.log)
|
||
if err != nil {
|
||
a.log.Warn("api key store unavailable, issued keys will not survive restart", "error", err)
|
||
return keys
|
||
}
|
||
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
|
||
a.log.Warn("api key store load failed, keys issued earlier may be rejected", "error", err)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// vectorState 把「PG 增强到底有没有生效」的**运行时真相**报给网关,供管理侧
|
||
// 「存储状态」只读端点作答(design/03 §3;面板首页的存储卡)。
|
||
//
|
||
// 真源就在本组合根,且只有两项事实,因此这里**复用**运行时状态而不新造探测:
|
||
//
|
||
// - Configured = `DB_PG_URL` 是否配置(非空即算)—— 开关在不在;
|
||
// - Live = 活着的向量索引确实落在 pgvector 后端上 —— 真的在不在跑。
|
||
//
|
||
// 两者会不一致:配置了 PG 但连接/schema 准备失败时,wireMemory 把 vindex 留成
|
||
// nil(语义检索降级),此时 Configured=true、Live=false,面板据此显示「已配置但
|
||
// 未就绪」,而不是把「配了」当成「生效了」。这里绝不新开 PG 连接去探测:那既多
|
||
// 一次网络往返,也可能与运行中的索引给出不同答案。
|
||
func (a *app) vectorState() gateway.VectorState {
|
||
_, live := a.vindex.(*vector.PGVectorIndex)
|
||
return gateway.VectorState{
|
||
Configured: strings.TrimSpace(a.cfg.DB.PGURL) != "",
|
||
Live: live,
|
||
}
|
||
}
|