Files
Cyrene_v2/internal/runtime/capacity/capacity.go
T
Aska 7e39153a69
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 修复审计落差 #1/#2/#3/#6 + 管理面板移动端适配
#1 工具布尔标记生效(agent-106, 安全红线): loop toolSchemas/runToolCalls 加 allow-set 安全门 restrictSchemas/applyToolSecurityGate——群聊/陌生人只暴露允许工具(默认关), 被拒not_allowed软提示+AuditEvent, owner全工具不变; 身份靠 ActorEnv(非提示词)
#2 checkpoint 恢复(agent-109): run.go LoadLatest + resumableStopReason门控(只恢复in-flight工具循环,不出答案不续跑), 幂等不重放副作用, 新会话不误恢复; resume_test 4用例
#3 tool_result(agent-108, 协议层): Anthropic 原生tool_result块(tool_use_id/is_error)非user纯文本 + OpenAI tool消息补tool_call_id; Content加ToolCallID/IsError等; (loop接线层2处留待,见备注)
#6 capacity_awaken推送(agent-107): onCapacity从只Debug改推WS hub(BroadcastCapacityAwaken, sessionId定向+payload对齐前端AwakenPayload)
管理面板移动端(agent-110): AdminApp 汉堡菜单+抽屉侧栏+标题truncate防换行, RBAC守卫不受影响
全仓 build/vet/test绿 + 前端build绿; 前端无涉差异已对齐

备注: #3 loop接线层2处(assistant工具轮append历史 + Message.ToolCallID/IsError)留给后续; #4代审/#5子代理触发/#7拆文件后置(主人说后三不急)
2026-09-07 13:43:18 +08:00

335 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 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
}