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/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. 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 } cfg := capacity.Config{ Enabled: true, MinConfidence: a.cfg.Capacity.MinConfidence, ToolSets: sets, } 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 } // 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) }