package main import ( "encoding/json" "fmt" "log/slog" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" ) // 本文件拥有唤醒式能力(capacity)决策层的装配:判定器构造、工具集 JSON 解析、 // L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出 // (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。 // buildCapacity 在启用时构造唤醒式能力判定器;未启用则返回 nil(loop 保持默认的 // 「全工具」行为)。它由 CAPACITY_ENABLED 显式开启;允许集映射取自 // CAPACITY_TOOL_SETS,解析失败时回落到内置默认值。 // // 模型辅助的 L3 层(裁决 14.4.4)是第二个、彼此独立的开关 // (CAPACITY_L3_ENABLED,默认 false):即使能力层已开,L3 仍保持静默,除非被 // 显式要求;其判定结果的缓存 TTL(CAPACITY_L3_CACHE_TTL,默认 5m)在此解析, // 因为配置加载器只承载标量。 // // 人格级唤醒覆盖(裁决 14.4.3)分两处进入决策层。本函数填**全局**兜底 // (Config.Awakening),取自当前主角色 YAML 里的块(见 defaultAwakening): // 未声明该块的人格继承的就是它。每个 turn 的取值由 awakeningFor 解析,loop 经 // loop.Options.PersonaAwakening 对该 turn 自己的角色调用它——没有它,非默认 // 角色的 `awakening` 块会被解析、校验,然后被静默忽略(design/01 §2.1 禁止)。 func (a *app) buildCapacity() *capacity.Decider { if !a.cfg.Capacity.Enabled { return nil } sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON) if err != nil { a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err) sets = nil } awakening := a.defaultAwakening() if !awakening.IsZero() { logger.WithSource(a.log, "cmd/cyrene").Info("capacity awakening override applied", "default_state", awakening.DefaultState, "min_confidence", awakening.MinConfidence, "force_state", awakening.ForceState, "persona", a.defaultPersonaID()) } cfg := capacity.Config{ Enabled: true, MinConfidence: a.cfg.Capacity.MinConfidence, ToolSets: sets, Awakening: awakening, } if a.cfg.Capacity.L3Enabled { cfg.L3Enabled = true cfg.L3Classifier = capacity.NewLLMClassifier(a.modelAdapter, a.log) cfg.L3CacheTTL = parseL3CacheTTL(a.cfg.Capacity.L3CacheTTL, a.log) } return capacity.New(cfg, capacity.WithLogger(a.log)) } // parseL3CacheTTL 解析 CAPACITY_L3_CACHE_TTL 这个时长字符串。空值选代码默认 // (5m,裁决 14.4.4 的落地建议);无法解析的值会告警并回落到同一默认,而不是 // 静默关掉缓存。 func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration { raw = strings.TrimSpace(raw) if raw == "" { return capacity.DefaultL3CacheTTL } d, err := time.ParseDuration(raw) if err != nil { logger.WithSource(log, "cmd/cyrene").Warn("capacity L3 cache ttl unparsable, using the default", "value", raw, "default", capacity.DefaultL3CacheTTL.String(), "error", err) return capacity.DefaultL3CacheTTL } return d } // parseCapacityToolSets 解码 CAPACITY_TOOL_SETS 的 JSON 映射:态 → 允许的工具名 // 列表。空白串产出 nil(用内置默认);某态取 null 表示不受限(全部工具)。 func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) { if strings.TrimSpace(raw) == "" { return nil, nil } var m map[string][]string if err := json.Unmarshal([]byte(raw), &m); err != nil { return nil, fmt.Errorf("capacity tool sets: %w", err) } out := make(map[capacity.State][]string, len(m)) for k, v := range m { out[capacity.State(k)] = v } return out, nil } // defaultAwakening 取当前主角色(`configs/users/owner.yaml` 的 `defaultPersona`, // 未配置时回落 `cyrene`)档案里的 `awakening` 块。它填的是决策层的**全局兜底** // (capacity.Config.Awakening);每个 turn 真正生效的档位由 awakeningFor 按该 // turn 的角色解析(裁决 14.4.3 的后半)。两者共用同一处映射,因此「全局兜底」 // 与「按角色取值」的口径绝不会分叉。 // // 未装配人格注册表(降级启动 / 单测构造的 app)时返回零值 —— 注册表缺席时连 // 默认角色都问不出来,此时不补任何档位。 func (a *app) defaultAwakening() capacity.Awakening { if a.personas == nil { return capacity.Awakening{} } return a.awakeningFor(a.defaultPersonaID()) } // awakeningFor 取**该角色**档案里的 `awakening` 块,映射为决策层的人格档位覆盖 // (design/14 §3,裁决 14.4.3)。它是 loop.Options.PersonaAwakening 的实现:循环 // 每个 turn 按该 turn 的角色 id 调它,因此绑定非默认角色的会话用**自己**的档位 // 判定能力面,而不是只吃默认角色的全局兜底。 // // 下列情形一律返回零值 = **无覆盖**,由决策层走全局默认(Config.Awakening + // 全局阈值),绝不在此处凭空补档位: // - 尚未装配人格注册表(降级启动 / 单测构造的 app); // - 角色 id 为空或该角色不在注册表里(未知/已删除人格); // - 该角色档案里没写 `awakening` 块(缺席就是没有)。 // // 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。 func (a *app) awakeningFor(personaID string) capacity.Awakening { if a.personas == nil || personaID == "" { return capacity.Awakening{} } p, ok := a.personas.Persona(personaID) if !ok || p == nil { return capacity.Awakening{} } return a.awakeningOf(p) } // awakeningOf 把一份人格档案的 `awakening` 块映射为决策层的档位覆盖。映射不了 // 的档位名按「无该档位」处理(awakeningStateOf 会告警),因此非法值绝不会被塞进 // 决策层(未知态会落进「不受限工具集」分支)。 func (a *app) awakeningOf(p *loader.Persona) capacity.Awakening { if p == nil || p.Awakening == nil { return capacity.Awakening{} } aw := capacity.Awakening{ DefaultState: a.awakeningStateOf(p.Awakening.DefaultState, p.ID, "default_state"), } if ov := p.Awakening.Overrides; ov != nil { aw.MinConfidence = ov.MinConfidence aw.ForceState = a.awakeningStateOf(ov.ForceState, p.ID, "overrides.force_state") } return aw } // awakeningStateOf 映射单个档位字面量;映射不了就**告警**再按「无该档位」处理。 // 经 loader.Load 进来的人格不可能走到告警分支(载入期已归一化并拒绝非法值), // 因此它是给「代码构造的人格」留的响铃:宁可吵一声,也不让配置静默失效 // (design/01 §2.1 禁止静默无效的配置)。 func (a *app) awakeningStateOf(raw, personaID, field string) capacity.State { if raw == "" { return "" } state := awakeningState(raw) if state == "" { logger.WithSource(a.log, "cmd/cyrene").Warn("capacity awakening state unrecognized, treated as no override", "value", raw, "field", field, "persona", personaID) } return state } // awakeningState 把 loader 归一化后的档位名映射到决策层的态。空串(未声明、或未知 // 值)返回零值态——映射层不做默认填充,缺失的档位就是缺失。 func awakeningState(raw string) capacity.State { switch raw { case loader.AwakeningStateQuiet: return capacity.StateQuiet case loader.AwakeningStateAwake: return capacity.StateAwake case loader.AwakeningStateFull: return capacity.StateFull default: return "" } } // onCapacity 是 loop 的 OnCapacity 出口:它观测一次 capacity_awaken 跃迁,并以 // `capacity_awaken` 帧推给 Web UI(design/14 §5)。loop 只在真实发生状态跃迁时 // 才发(绝非每轮都发);事件携带发起会话 id,因此路由到那个会话而非全局广播。 // 载荷是前端的 AwakenPayload {fromState,toState,tool}。 func (a *app) onCapacity(ev capacity.Event) { logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event", "type", ev.Type, "from", ev.FromState, "to", ev.ToState, "tool", ev.Tool, "at", ev.At, "session", ev.SessionID) if a.capacitySink == nil { return } a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool) }