#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拆文件后置(主人说后三不急)
335 lines
12 KiB
Go
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
|
|
}
|