// Package capacity implements the "awakened capacity" decision layer // (DevDocs/design/14-唤醒式能力渐进.md). It sits outside the loop core: every // round, it classifies the conversation into one of three states — quiet / // awake / full — and yields the set of tools that this round may expose to the // model. The loop then advertises only that allowed set to the model (a view // over the dispatcher, never a hard boundary: Invoke is left unblocked so an // emergency call still works — capacity is a decision, not a capability wall). // // Intent is judged in layers and defaults to conservative (低置信→静息): L1 // fast heuristics (strong action signals → awake/full, clear companion signals → // quiet), falling to L2 context trend (recent turns), then L4 learned-habit // alignment (recalled user habits correct/fill the intent — design §2.2, §2.4), // before the persona awakening override (design §3). It never uses intent for // identity — identity stays on channel actor metadata (project red line). package capacity import ( "context" "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // State is one of the three awakened-capacity states (design §1). type State string const ( // StateQuiet is the default: 静·陪伴. Only a narrow, companion-safe tool face // is exposed (terminal + file edit + memory/emotion companion). StateQuiet State = "quiet" // StateAwake is 醒·办事: a real task is detected and tools wake one by one // (web retrieval, file, terminal). StateAwake State = "awake" // StateFull is 全·复杂任务: the complete capability window. StateFull State = "full" ) // EventType labels a state-change event. const EventAwaken = "capacity_awaken" // Event describes a visible capacity transition (design §1/§4): a state changed // and a tool was lit up, surfaced to the frontend. A change to quiet does not // emit (only awakenings are surfaced). type Event struct { Type string FromState State ToState State // Tool is the tool that this transition lit up (点亮工具). Empty when no // single tool can be named. Tool string // At is the wall-clock time the transition was observed. At time.Time // SessionID is set by the runtime layer (loop) to the conversation session the // transition belongs to, so the frontend targets the correct session rather // than treating the event as global. It is empty when the emitter never ran // inside a session-scoped turn (e.g. a direct toolSchemas call in a unit test). SessionID string } // IsAwaken reports whether the event is a capacity_awaken transition. func (e Event) IsAwaken() bool { return e.Type == EventAwaken } // Decision is the outcome of one Decide call (design §4). Judgment is the raw // intent-layer classification (before any persona override), suitable for write-back // into memory so the L4 layer learns the user's task-expression habits (design §2.4). type Decision struct { State State AllowedTools []string Events []Event Confidence float64 Judgment Judgment } // Awakening is the persona-level override block (design §3, // configs/personas/*.yaml → awakening). It is per-persona; the zero value means // "no override" so the policy falls back to conservative defaults. type Awakening struct { // DefaultState is the persona's resting tendency (default_state) used when // the intent layers are ambiguous. Empty = quiet. DefaultState State // MinConfidence overrides the global wake threshold; a positive value wins // over Config.MinConfidence. Below it, the intent is forced toward the // persona default (conservative, 不打扰). MinConfidence float64 // ForceState, when set, unconditionally pins a state (force_state), e.g. a // "干练助理" persona defaults to awake regardless of intent. ForceState State } // IsZero reports whether no override is configured. func (a Awakening) IsZero() bool { return a.DefaultState == "" && a.MinConfidence == 0 && a.ForceState == "" } // Config configures a Decider. A zero value disables capacity (all tools always // surface), so the loop's pre-existing behavior is preserved until enabled. type Config struct { // Enabled gates the whole decision layer. When false Decide returns every // tool and emits no events. Enabled bool // MinConfidence is the wake threshold below which the state is forced toward // the persona default (设计 §2.3 默认保守). Zero selects the default 0.6. MinConfidence float64 // ToolSets maps a State to the allowed tool names. A missing entry selects // the built-in default set for that state; a nil entry means "no // restriction" (all tools); an empty non-nil slice means "no tools". StateFull // maps to nil (complete window) unless overridden. ToolSets map[State][]string // Awakening carries the persona-level override (design §3). Zero = no // override. Awakening Awakening } // Input is everything Decide needs to classify one round (design §4). type Input struct { // Messages is the conversation text in chronological order (oldest first); // the last element is the current turn. Text is extracted by the caller so // the capacity package stays decoupled from the model message type. Messages []string // CurrentState is the state the agent occupied at the start of this round, // used purely to emit the FromState of a transition. An empty value is // treated as StateQuiet. CurrentState State // Awakening, when non-zero, overrides the Config.Awakening for this call // (per-persona override). Awakening Awakening // AllToolNames is the full set of known tool-spec names the dispatcher can // advertise; the allowed set is derived by intersecting the state's set // with it (preserving AllToolNames order). AllToolNames []string // Recall carries the owner's recalled habit memories for this round (design // §2.2 L4), filled by the caller from Retriever.Retrieve. L4 uses it to correct // or fill in the intent with learned user habits. Nil or empty keeps L4 silent, // so L1/L2 behavior is unchanged (向后兼容). Recall []RecallItem } // Option configures a Decider at construction time. type Option func(*Decider) // Decider is the awakened-capacity decision engine. It is immutable after New // and safe for concurrent Decide calls. type Decider struct { cfg Config log *slog.Logger } // sourcedLog returns a logger tagged with the module source (project rule §10), // replacing a nil logger with a discard one so decide can always log safely. func sourcedLog(l *slog.Logger) *slog.Logger { if l == nil { l = slog.New(slog.NewTextHandler(discard{}, nil)) } return logger.WithSource(l, "runtime/capacity") } // discard is an io.Writer that drops everything; it sits behind a nil logger so // no log dependency is forced on a caller that never logs. type discard struct{} func (discard) Write(p []byte) (int, error) { return len(p), nil } // WithLogger injects the base logger. Nil is replaced with a discard logger // tagged with source "runtime/capacity". func WithLogger(l *slog.Logger) Option { return func(d *Decider) { d.log = sourcedLog(l) } } // New builds a Decider from cfg, applying opts in order. It applies the // default MinConfidence of 0.6 when the config leaves it at zero. func New(cfg Config, opts ...Option) *Decider { if cfg.MinConfidence <= 0 { cfg.MinConfidence = DefaultMinConfidence } d := &Decider{cfg: cfg} d.log = sourcedLog(nil) for _, opt := range opts { if opt != nil { opt(d) } } return d } // DefaultMinConfidence is the conservative wake threshold (design §2.3). const DefaultMinConfidence = 0.6 // Enabled reports whether the decision layer is active. func (d *Decider) Enabled() bool { return d.cfg.Enabled } // Decide classifies one round and returns the state, allowed tool set and any // transition events. When capacity is disabled it returns the full tool set and // no events, preserving the loop's default behavior. func (d *Decider) Decide(ctx context.Context, in Input) Decision { if !d.cfg.Enabled { return Decision{State: in.CurrentState, AllowedTools: append([]string(nil), in.AllToolNames...)} } state, conf, tool := d.intent(in) j := judgmentFor(in, state, conf, tool) state, conf = d.applyAwakening(state, conf, in) allowed := d.allowedTools(state, in.AllToolNames) events := d.stateEvents(effectiveState(in.CurrentState), state, tool, allowed) d.log.Debug("capacity decide", "from", effectiveState(in.CurrentState), "to", state, "confidence", conf, "allowed", len(allowed), "tool", tool, "is_task", j.IsTask, "task_kind", j.TaskKind) return Decision{State: state, AllowedTools: allowed, Events: events, Confidence: conf, Judgment: j} } // effectiveAwakening returns the per-call Awakening when set, else the config's. func (d *Decider) effectiveAwakening(in Input) Awakening { if !in.Awakening.IsZero() { return in.Awakening } return d.cfg.Awakening } // applyAwakening overlays the persona override (design §3, B) on the intent // layer result (A): force_state pins unconditionally; otherwise a result below // the effective threshold falls back to the persona default (conservative). func (d *Decider) applyAwakening(state State, conf float64, in Input) (State, float64) { aw := d.effectiveAwakening(in) if aw.ForceState != "" { return aw.ForceState, conf } minConf := d.cfg.MinConfidence if aw.MinConfidence > 0 { minConf = aw.MinConfidence } if conf < minConf || state == "" { if def := aw.DefaultState; def != "" { return def, conf } return StateQuiet, conf } return state, conf } // allowedTools returns the state's allowed set intersected with the known tool // names, preserving AllToolNames order. StateFull (and any state whose set is // nil) is unrestricted and returns the full tool list. func (d *Decider) allowedTools(state State, all []string) []string { set := d.toolSetFor(effectiveState(state)) if set == nil { return all } return intersectOrdered(set, all) } // toolSetFor returns the configured or built-in allowed set for state. A nil // return means unrestricted (all tools). func (d *Decider) toolSetFor(state State) []string { if ts, ok := d.cfg.ToolSets[state]; ok { if ts == nil { return nil } return ts } switch state { case StateQuiet: return defaultQuietTools() case StateAwake: return defaultAwakeTools() default: return nil // StateFull → complete window } } // stateEvents builds the transition event when the state actually changed to a // more-awake state. tool is the lit tool, taken from the intent or, when empty, // the first allowed tool in the target state's canonical set (deterministic). A // change to quiet emits nothing. func (d *Decider) stateEvents(from, to State, tool string, toAllowed []string) []Event { if from == to || to == StateQuiet { return nil } if tool == "" { tool = firstAllowedCanonical(d.toolSetFor(to), toAllowed) } return []Event{{Type: EventAwaken, FromState: from, ToState: to, Tool: tool, At: time.Now()}} } // firstAllowedCanonical returns the first tool in the canonical state set that is // actually allowed (present in allowed). An unrestricted set (nil canonical) // falls back to the first tool of allowed. It is deterministic because the // canonical sets use a fixed order, unlike the registry's map iteration order. func firstAllowedCanonical(canonical, allowed []string) string { if len(canonical) == 0 { if len(allowed) > 0 { return allowed[0] } return "" } allowedSet := make(map[string]bool, len(allowed)) for _, n := range allowed { allowedSet[n] = true } for _, n := range canonical { if allowedSet[n] { return n } } return "" } // intersectOrdered keeps the tools of all that are in allow, preserving the // all order so the advertised schema list stays stable. func intersectOrdered(allow, all []string) []string { if len(allow) == 0 || len(all) == 0 { return []string{} } allowSet := make(map[string]bool, len(allow)) for _, a := range allow { allowSet[a] = true } out := make([]string, 0, len(all)) for _, name := range all { if allowSet[name] { out = append(out, name) } } return out } // effectiveState returns StateQuiet for an empty state, so the CurrentState of // a nil input is always a valid resting state. func effectiveState(s State) State { if s == "" { return StateQuiet } return s }