Files
Cyrene_v2/cmd/cyrene/wire_capacity.go
T
AskaEth 7afef8d037
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx)
  → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处)
  · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构
  · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致)
- **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉
  awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。
  新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障)
- 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop
  流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同);
  **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑)
- 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略,
  需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
2026-10-04 13:07:52 +08:00

203 lines
8.5 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 (
"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 constructs the awakened-capacity decider when enabled, or nil
// (which keeps the loop's default all-tools behavior). It is opt-in via
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and
// falls back to built-in defaults on a parse error.
//
// The model-assisted L3 layer (裁决 14.4.4) is a second, independent switch
// (CAPACITY_L3_ENABLED, default false): even with capacity on, L3 stays silent
// unless it was explicitly asked for, and its verdict cache TTL
// (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader
// only carries scalars.
//
// The persona-level awakening override (裁决 14.4.3) enters the decision layer in
// two places. This function fills the *global* fallback (Config.Awakening) from the
// current main persona's YAML block (see defaultAwakening): it is what a persona
// declaring no block inherits. The per-turn value is resolved by awakeningFor,
// which the loop calls for every turn's own role through
// loop.Options.PersonaAwakening — without it, a non-default character's
// `awakening` block would be parsed, validated and then silently ignored
// (forbidden by 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 parses the CAPACITY_L3_CACHE_TTL duration string. An empty
// value selects the code default (5m, the landing suggestion of 裁决 14.4.4); an
// unparsable value is reported and falls back to the same default rather than
// silently disabling the cache.
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 decodes the CAPACITY_TOOL_SETS JSON map of
// state→allowed tool name list. A blank string yields nil (built-in defaults); a
// null value for a state means unrestricted (all tools).
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 is the loop's OnCapacity sink: it observes a capacity_awaken
// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken`
// frame. The loop only emits on a real state transition (never every round), and
// the event carries the originating session id so it routes to that session, not
// globally. The payload is the frontend 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)
}