Files
Cyrene_v2/internal/runtime/capacity/capacity.go
T
Aska 6dbb4a2e18
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。

【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。

【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。

【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。

【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。

【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。

实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
2026-09-06 21:54:40 +08:00

329 lines
12 KiB
Go

// 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 time.Time
}
// 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
}