Files
Cyrene_v2/cmd/cyrene/wire_frontend.go
T
AskaEth 3acc81cec8 feat(gateway): 子代理结果回填父会话(系统注记)+ 记忆抽取排除(M32)
断点(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(契约归一建议报塔)。
2026-10-24 22:02:11 +08:00

305 lines
15 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"
"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,
}
}