feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【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。
This commit is contained in:
Aska committed 2026-09-06 21:54:40 +08:00
1 parent e0a543cc37
commit 6dbb4a2e18
51 files changed
+5395 -287

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
// 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
}
+234
View File
@@ -0,0 +1,234 @@
package capacity
import (
"context"
"reflect"
"testing"
)
// allTools is a representative registration-order tool list for tests; it covers
// the basic/io/terminal plugins actually shipped by the repo.
func allTools() []string {
return []string{
"terminal", "file", "knowledge", "text",
"web-search", "web-fetch", "http",
"calculator", "datetime", "random", "json", "markdown",
}
}
func activeConfig() Config {
return Config{Enabled: true, MinConfidence: 0.6}
}
func in(messages ...string) Input {
return Input{Messages: messages, CurrentState: StateQuiet, AllToolNames: allTools()}
}
func contains(s []string, want string) bool {
for _, v := range s {
if v == want {
return true
}
}
return false
}
func TestDecide_Disabled_ReturnsAllToolsNoEvents(t *testing.T) {
d := New(Config{}) // Enabled zero → disabled
dec := d.Decide(context.Background(), in("帮我查一下资料"))
if !reflect.DeepEqual(dec.AllowedTools, allTools()) {
t.Fatalf("disabled allowed = %v, want all %v", dec.AllowedTools, allTools())
}
if len(dec.Events) != 0 {
t.Fatalf("disabled should emit no events, got %+v", dec.Events)
}
}
func TestDecide_Chitchat_StaysQuiet(t *testing.T) {
d := New(activeConfig())
for _, msg := range []string{"你好", "抱抱", "嗯嗯", "晚安", "今天好累呀"} {
if got := d.Decide(context.Background(), in(msg)); got.State != StateQuiet {
t.Errorf("chitchat %q woke to %s, want quiet", msg, got.State)
}
}
}
func TestDecide_Chitchat_NoAwakenEvent(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("你好"))
if len(dec.Events) != 0 {
t.Fatalf("quiet chat should not emit capacity_awaken, got %+v", dec.Events)
}
}
func TestDecide_Query_Awakens(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("帮我查一下今天的天气"))
if dec.State != StateAwake {
t.Fatalf("query state = %s, want awake", dec.State)
}
if dec.Confidence < 0.6 {
t.Fatalf("query confidence = %v, want >= 0.6", dec.Confidence)
}
if !contains(dec.AllowedTools, "web-search") {
t.Fatalf("awake allowed should include web-search, got %v", dec.AllowedTools)
}
if len(dec.Events) != 1 || dec.Events[0].FromState != StateQuiet || dec.Events[0].ToState != StateAwake {
t.Fatalf("expected a quiet→awake event, got %+v", dec.Events)
}
}
func TestDecide_EditFile_Awakens(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("改一下 cx.go 这个函数"))
if dec.State != StateAwake {
t.Fatalf("edit state = %s, want awake", dec.State)
}
if !contains(dec.AllowedTools, "file") {
t.Fatalf("awake allowed should include file, got %v", dec.AllowedTools)
}
if len(dec.Events) != 1 || dec.Events[0].Tool != "file" {
t.Fatalf("edit event should light up file, got %+v", dec.Events)
}
}
func TestDecide_ComplexTask_Full(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("帮我重构整个项目,迁移到新架构"))
if dec.State != StateFull {
t.Fatalf("complex state = %s, want full", dec.State)
}
if !reflect.DeepEqual(dec.AllowedTools, allTools()) {
t.Fatalf("full allowed = %v, want all %v", dec.AllowedTools, allTools())
}
}
func TestDecide_L1_UndecidedDeferstoL2_WakesOnTaskTrend(t *testing.T) {
d := New(activeConfig())
// Last turn (这个) is undecided by L1; the recent history is task-leaning.
dec := d.Decide(context.Background(), in("帮我查一下资料", "还有吗", "这个"))
if dec.State != StateAwake {
t.Fatalf("task-trend state = %s, want awake", dec.State)
}
}
func TestDecide_L2_CompanionTrend_Quiet(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("你好", "抱抱", "嗯嗯", "这个"))
if dec.State != StateQuiet {
t.Fatalf("companion-trend state = %s, want quiet", dec.State)
}
}
func TestDecide_L2_Followup_Wakes(t *testing.T) {
d := New(activeConfig())
// A clear previous task plus a bare continuation cue (继续) stays awake —
// L2 picks up the follow-up on the previous round's task (design §2.2).
dec := d.Decide(context.Background(), in("帮我查一下资料", "继续"))
if dec.State != StateAwake {
t.Fatalf("followup state = %s, want awake", dec.State)
}
}
func TestDecide_Undecided_ConservativeQuiet(t *testing.T) {
d := New(activeConfig())
// Entirely neutral turns with no signal at all.
dec := d.Decide(context.Background(), in("嗯", "这个", "然后"))
if dec.State != StateQuiet {
t.Fatalf("undecided state = %s, want quiet (conservative)", dec.State)
}
}
func TestDecide_PersonaForceState(t *testing.T) {
d := New(activeConfig())
inp := in("你好")
inp.Awakening = Awakening{ForceState: StateAwake}
dec := d.Decide(context.Background(), inp)
if dec.State != StateAwake {
t.Fatalf("force_state state = %s, want awake", dec.State)
}
}
func TestDecide_PersonaDefaultState(t *testing.T) {
// An assistant-type persona rests at awake; an undecided round lands there.
d := New(Config{Enabled: true, MinConfidence: 0.6, Awakening: Awakening{DefaultState: StateAwake}})
dec := d.Decide(context.Background(), in("这个"))
if dec.State != StateAwake {
t.Fatalf("assistant default_state = %s, want awake", dec.State)
}
}
func TestDecide_PersonaMinConfidence_ForcesQuiet(t *testing.T) {
// A high threshold forces even a strong task signal to stay quiet. Low conf.
d := New(Config{Enabled: true, MinConfidence: 0.6})
inp := in("帮我查一下资料")
inp.Awakening = Awakening{MinConfidence: 0.99}
if dec := d.Decide(context.Background(), inp); dec.State != StateQuiet {
t.Fatalf("high min_confidence should force quiet, got %s", dec.State)
}
// The default threshold lets the same strong signal wake.
if dec := d.Decide(context.Background(), in("帮我查一下资料")); dec.State != StateAwake {
t.Fatalf("default min_confidence should wake, got %s", dec.State)
}
}
func TestDecide_AllowedSets(t *testing.T) {
d := New(activeConfig())
quiet := d.Decide(context.Background(), in("你好")).AllowedTools
if !reflect.DeepEqual(quiet, []string{"terminal", "file", "knowledge", "text"}) {
t.Fatalf("quiet allowed = %v", quiet)
}
awake := d.Decide(context.Background(), in("帮我查一下资料")).AllowedTools
wantAwake := []string{"terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http"}
if !reflect.DeepEqual(awake, wantAwake) {
t.Fatalf("awake allowed = %v, want %v", awake, wantAwake)
}
if contains(awake, "json") || contains(awake, "markdown") || contains(awake, "calculator") {
t.Fatalf("awake must not expose heavy utility tools, got %v", awake)
}
full := d.Decide(context.Background(), in("帮我重构整个项目")).AllowedTools
if !reflect.DeepEqual(full, allTools()) {
t.Fatalf("full allowed = %v, want all %v", full, allTools())
}
}
func TestDecide_Event_LitToolIsAllowed(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("帮我查一下资料"))
if len(dec.Events) != 1 {
t.Fatalf("expected one event, got %+v", dec.Events)
}
ev := dec.Events[0]
if !ev.IsAwaken() || ev.Tool == "" {
t.Fatalf("event should be an awaken with a lit tool, got %+v", ev)
}
if !contains(dec.AllowedTools, ev.Tool) {
t.Fatalf("lit tool %q not in allowed set %v", ev.Tool, dec.AllowedTools)
}
}
func TestDecide_CustomToolSets(t *testing.T) {
d := New(Config{
Enabled: true,
MinConfidence: 0.6,
ToolSets: map[State][]string{StateQuiet: {"terminal"}},
})
dec := d.Decide(context.Background(), in("你好"))
if !reflect.DeepEqual(dec.AllowedTools, []string{"terminal"}) {
t.Fatalf("custom quiet allowed = %v, want [terminal]", dec.AllowedTools)
}
}
func TestDecide_NilToolNames_EmptyAllowed(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), Input{Messages: []string{"你好"}, CurrentState: StateQuiet})
if dec.State != StateQuiet {
t.Fatalf("state = %s, want quiet", dec.State)
}
if len(dec.AllowedTools) != 0 {
t.Fatalf("allowed with no known tools = %v, want empty", dec.AllowedTools)
}
}
+325
View File
@@ -0,0 +1,325 @@
package capacity
import (
"math"
"strconv"
"strings"
)
// RecallItem is one recalled memory hit handed to the decider for L4 habit
// alignment (design §2.2 L4). It is a decoupling view over memory.Result so the
// capacity package stays independent of the memory subsystem; the loop builds it
// from Retriever.Retrieve. Scope carries the memory scope value ("user" / "persona"
// / "project") so L4 can honor the isolation red line (学行为习惯, 不读陌生人敏感).
type RecallItem struct {
Content string
Scope string
Kind string
Tags []string
Score float64
}
// Judgment is the intent outcome of one round, meant to be written back into
// memory so the L4 layer learns the user's task-expression habits over time
// (design §2.4 闭环学习). It records the raw intent-layer classification (before
// any persona override) because the closed loop learns the user's intent pattern,
// not the persona's presentation. Text is the user expression that was judged — the
// habit key L4 re-matches on later rounds.
type Judgment struct {
IsTask bool
TaskKind string
State State
Confidence float64
Text string
}
// L4 tuning constants.
const (
// minHabitOverlap is the minimum longest-common-substring length (in runes) a
// recalled habit must share with the current turn before L4 treats it as a
// matching habit. 3 runes covers a short Chinese phrase (搞一下) while staying
// conservative against token noise.
minHabitOverlap = 3
// taskHabitConfidence is the wake confidence L4 assigns when it matches a
// habit-style memory (not a canonical intent record) that leans 办事. It sits
// above the default wake threshold so the habit can actually wake a missed task.
taskHabitConfidence = 0.7
// intent kind tokens stored in an intent record and understood by awakenVerdict.
taskKindComplex = "complex"
taskKindCode = "code"
taskKindQuery = "query"
taskKindOps = "ops"
// intentRecordPrefix marks a canonical intent record written back by RecordIntent.
intentRecordPrefix = "intent:"
)
// RecordIntent formats a judgment into the canonical intent-record string written
// back to memory (design §2.4). Only task judgments are worth learning — a quiet /
// non-task judgment returns "" so callers can skip persisting noise. The format is
// machine-parseable by parseIntentRecord, which L4 re-reads on later rounds to
// close the learning loop. The user expression is written in plain text so the
// keyword retriever can still surface it against a later similar turn.
func (d *Decider) RecordIntent(j Judgment) string {
if !j.IsTask {
return ""
}
return formatIntentRecord(j)
}
// formatIntentRecord encodes j as one parseable line. The text field comes last and
// is taken verbatim (it may contain ';' or '='), so parseIntentRecord reads the
// fixed prefix and keeps the remainder as the expression.
func formatIntentRecord(j Judgment) string {
return intentRecordPrefix +
"isTask=" + strconv.FormatBool(j.IsTask) +
";taskKind=" + j.TaskKind +
";confidence=" + strconv.FormatFloat(j.Confidence, 'f', -1, 64) +
";text=" + j.Text
}
// intentRecord is the parsed form of a canonical intent record.
type intentRecord struct {
isTask bool
taskKind string
confidence float64
text string
}
// parseIntentRecord decodes a canonical intent record written by formatIntentRecord.
// ok is false when content is not a well-formed intent record (missing isTask).
func parseIntentRecord(content string) (intentRecord, bool) {
rest, ok := strings.CutPrefix(content, intentRecordPrefix)
if !ok {
return intentRecord{}, false
}
// text is last and taken verbatim, so a ';' or '=' inside it cannot split it.
head, text := rest, ""
if idx := strings.Index(rest, ";text="); idx >= 0 {
head = rest[:idx]
text = rest[idx+len(";text="):]
}
rec := intentRecord{text: text}
seenTask := false
for _, f := range strings.Split(head, ";") {
k, v, cut := strings.Cut(f, "=")
if !cut {
continue
}
switch k {
case "isTask":
rec.isTask = v == "true"
seenTask = true
case "taskKind":
rec.taskKind = v
case "confidence":
if x, err := strconv.ParseFloat(v, 64); err == nil {
rec.confidence = x
}
}
}
if !seenTask {
return intentRecord{}, false
}
return rec, true
}
// l4 is the L4 habit-alignment layer (design §2.2, §2.4): it uses recalled user
// habits to correct or fill in the intent that L1/L2 could not settle. It returns
// ok=false when no habit applies, so the caller's L1/L2 verdict (or the
// conservative fallback) stands — L4 is silent unless a learned habit matches
// (向后兼容, 数据缺失时回退).
func (d *Decider) l4(current string, recall []RecallItem) (State, float64, string, bool) {
text := strings.TrimSpace(current)
if text == "" || len(recall) == 0 {
return "", 0, "", false
}
bestState, bestConf, bestTool := State(""), 0.0, ""
matched := false
for _, it := range recall {
if !allowedHabitScope(it.Scope) {
continue
}
if rec, ok := parseIntentRecord(it.Content); ok {
// A canonical record written back by RecordIntent: it knows the exact
// task kind and confidence. Only a task record with a matching expression
// testifies to task; the stored text is plaintext so overlap is matched
// against it (tolerating paraphrase).
if !rec.isTask || rec.text == "" || overlapLen(text, rec.text) < minHabitOverlap {
continue
}
state, tool := awakenVerdict(rec.taskKind)
conf := clampConf(rec.confidence)
if conf == 0 {
conf = taskHabitConfidence
}
if conf > bestConf {
bestState, bestConf, bestTool, matched = state, conf, tool, true
}
continue
}
// Habit-style memory (not written by us): treat it as a task habit only when
// it leans 办事 and shares a phrase with the current turn, so an unrelated
// preference (coffee habit) never triggers a wake.
if !leansTask(it.Content) || overlapLen(text, it.Content) < minHabitOverlap {
continue
}
conf := taskHabitConfidence
if it.Score > 0 {
conf = clampConf(taskHabitConfidence + it.Score*0.15)
}
state, tool := StateAwake, inferTool(text)
if isComplex(text) {
state = StateFull
}
if conf > bestConf {
bestState, bestConf, bestTool, matched = state, conf, tool, true
}
}
if !matched {
return "", 0, "", false
}
return bestState, bestConf, bestTool, true
}
// mergeL4 combines an L1/L2 verdict with the L4 habit verdict (design §2.2 合并规则).
// It is conservative: a strong learned habit may count a task the heuristics missed
// (quiet → awake, a 举手示意 but never a jump straight to full) or confirm/boost one
// they caught, up to full when the habit says complex; it never drags a clear task
// down to quiet (学行为习惯,不误打断).
func (d *Decider) mergeL4(s State, c float64, t string, l4s State, l4c float64, l4t string, l4ok bool) (State, float64, string) {
if !l4ok || (l4s != StateAwake && l4s != StateFull) {
return s, c, t
}
if s == StateQuiet || s == "" {
// 保守红线:静息→唤醒是"举手示意",即便习惯标记复杂也不直接跳到全能。
return StateAwake, l4c, l4t
}
st := s
if l4s == StateFull {
st = StateFull
}
return st, boostConf(c, l4c), pickTool(t, l4t)
}
// awakenVerdict maps an intent-record task kind back to a state + lit tool.
func awakenVerdict(kind string) (State, string) {
switch kind {
case taskKindComplex:
return StateFull, ""
case taskKindCode:
return StateAwake, "file"
case taskKindQuery:
return StateAwake, "web-search"
case taskKindOps:
return StateAwake, "terminal"
default:
return StateAwake, ""
}
}
// taskKindFor maps a (state, tool) to the intent kind recorded for writeback.
func taskKindFor(state State, tool string) string {
if state == StateFull {
return taskKindComplex
}
switch tool {
case "file":
return taskKindCode
case "web-search", "web-fetch", "http":
return taskKindQuery
case "terminal":
return taskKindOps
default:
return ""
}
}
// allowedHabitScope reports the scopes L4 may read for user habits. ScopeUser is the
// owner's shared habit (primary); ScopePersona / ScopeProject are readable per design
// §2.2; an empty scope (recall without scope metadata) is tolerated. Any other scope
// is refused, so a stranger / owner-private memory can never reach L4 here (design
// §2.2 红线: 学行为习惯, 不读陌生人敏感).
func allowedHabitScope(scope string) bool {
switch scope {
case "", "user", "persona", "project":
return true
default:
return false
}
}
// leansTask reports whether a habit memory is about the user's task-expression
// pattern rather than an unrelated preference, so L4 does not wake on a coffee habit.
func leansTask(content string) bool {
return containsAny(content, []string{
"办事", "任务", "布置", "要办", "让我", "要我", "去做", "来办", "干活", "做工",
"习惯用", "习惯说", "让我做", "叫我",
// ASCII
"task", "asks me to", "want me to", "has me", "habit",
})
}
// overlapLen returns the length (in runes) of the longest common substring of a and
// b — the lexical-overlap measure L4 uses to detect "the user expression this round
// echoes a phrase the memory recorded", tolerating paraphrase (搞一下那个部署 vs
// 搞一下这个部署 share 搞一下).
func overlapLen(a, b string) int {
ar, br := []rune(a), []rune(b)
if len(ar) == 0 || len(br) == 0 {
return 0
}
prev := make([]int, len(br)+1)
best := 0
for i := 1; i <= len(ar); i++ {
cur := make([]int, len(br)+1)
for j := 1; j <= len(br); j++ {
if ar[i-1] == br[j-1] {
cur[j] = prev[j-1] + 1
if cur[j] > best {
best = cur[j]
}
}
}
prev = cur
}
return best
}
// boostConf raises a confirmed task confidence above the stronger of the two sources,
// reflecting corroboration (设计: 越用越准), capped at 1.
func boostConf(a, b float64) float64 { return clampConf(math.Max(a, b) + 0.05) }
// clampConf clamps c to [0,1].
func clampConf(c float64) float64 {
if c < 0 {
return 0
}
if c > 1 {
return 1
}
return c
}
// pickTool prefers the learned habit's lit tool when non-empty, else the caller's.
func pickTool(t, l4t string) string {
if l4t != "" {
return l4t
}
return t
}
// judgmentFor builds the write-back judgment from the raw intent-layer result.
func judgmentFor(in Input, state State, conf float64, tool string) Judgment {
j := Judgment{State: state, Confidence: conf}
if n := len(in.Messages); n > 0 {
j.Text = in.Messages[n-1]
}
j.IsTask = state == StateAwake || state == StateFull
if j.IsTask {
j.TaskKind = taskKindFor(state, tool)
}
return j
}
+177
View File
@@ -0,0 +1,177 @@
package capacity
import (
"context"
"testing"
)
// uRecall builds an Input over messages with the given recall (L4 feed), mirroring
// the in() helper but adding the recall hook.
func inRecall(messages []string, recall []RecallItem) Input {
in := in(messages...)
in.Recall = recall
return in
}
// taskRecord is a canonical intent record written back by RecordIntent, carrying a
// learned task expression for the closed loop.
func taskRecord(expr string, conf float64) string {
return formatIntentRecord(Judgment{IsTask: true, TaskKind: taskKindOps, Confidence: conf, Text: expr})
}
func TestDecide_L4_WakesMissedTask_CanonicalRecord(t *testing.T) {
d := New(activeConfig())
// "部署" is a strong verb but "搞一下那个部署" carries no imperative marker or
// object noun, so L1 is undecided and L2 (single turn) is undecided too. A learned
// habit (canonical record) that "搞一下这个部署" is a task must wake it (L4 fills
// the gap 设计 §2.2).
recall := []RecallItem{{
Content: taskRecord("搞一下这个部署", 0.85),
Scope: "user",
Score: 0.8,
}}
dec := d.Decide(context.Background(), inRecall([]string{"搞一下那个部署"}, recall))
if dec.State != StateAwake {
t.Fatalf("state = %s, want awake (L4 habit learned 搞一下=办事)", dec.State)
}
if !contains(dec.AllowedTools, "terminal") {
t.Fatalf("awake allowed should include terminal (ops habit), got %v", dec.AllowedTools)
}
if dec.Confidence < 0.6 {
t.Fatalf("confidence = %v, want >= 0.6", dec.Confidence)
}
if !dec.Judgment.IsTask {
t.Fatalf("judgment.IsTask = false, want true")
}
}
func TestDecide_L4_WakesMissedTask_HabitMemory(t *testing.T) {
d := New(activeConfig())
// A habit-style memory (not written by RecordIntent) that leans 办事 and shares the
// phrase must also wake, without L1/L2 support.
recall := []RecallItem{{
Content: "用户习惯:他习惯说搞一下就是要我办事",
Scope: "user",
Score: 0.5,
}}
dec := d.Decide(context.Background(), inRecall([]string{"搞一下那个脚本"}, recall))
if dec.State != StateAwake {
t.Fatalf("state = %s, want awake (habit memory leans 办事)", dec.State)
}
}
func TestDecide_L4_BoostsConfidenceOnAwake(t *testing.T) {
d := New(activeConfig())
// L2 already wakes this follow-up at 0.66; a strong learned habit corroborating
// the same task phrasing must raise the confidence (设计: 越用越准).
without := d.Decide(context.Background(), in("帮我查一下资料", "还有吗"))
if without.State != StateAwake || without.Confidence < 0.6 {
t.Fatalf("baseline = state %s conf %v, want awake >= 0.6", without.State, without.Confidence)
}
recall := []RecallItem{{
Content: taskRecord("还有吗", 0.9),
Scope: "user",
}}
with := d.Decide(context.Background(), inRecall([]string{"帮我查一下资料", "还有吗"}, recall))
if with.State != StateAwake {
t.Fatalf("with-recall state = %s, want awake", with.State)
}
if with.Confidence <= without.Confidence {
t.Fatalf("with-recall confidence = %v, want > baseline %v", with.Confidence, without.Confidence)
}
}
func TestDecide_L4_MissingRecall_FallsBackToL1L2(t *testing.T) {
d := New(activeConfig())
// No recall (nil or empty) keeps L1/L2 behavior identical.
base := d.Decide(context.Background(), in("帮我查一下资料"))
nilRecall := d.Decide(context.Background(), inRecall([]string{"帮我查一下资料"}, nil))
emptyRecall := d.Decide(context.Background(), inRecall([]string{"帮我查一下资料"}, []RecallItem{}))
for _, got := range []Decision{nilRecall, emptyRecall} {
if got.State != base.State || got.Confidence != base.Confidence {
t.Fatalf("no-recall = state %s conf %v, want baseline %s %v",
got.State, got.Confidence, base.State, base.Confidence)
}
}
}
func TestDecide_L4_IgnoresNonAllowedScope(t *testing.T) {
d := New(activeConfig())
// A recalled item outside the allowed scopes (stranger / secret) must not feed L4
// even when it leans task and shares the phrase — 只读用户授权范围记忆.
for _, scope := range []string{"stranger", "secret", "group"} {
recall := []RecallItem{{
Content: "用户习惯:他说搞一下就是要我办事",
Scope: scope,
Score: 0.9,
}}
dec := d.Decide(context.Background(), inRecall([]string{"搞一下那个部署"}, recall))
if dec.State != StateQuiet {
t.Fatalf("scope %q leaked into L4: state = %s, want quiet", scope, dec.State)
}
}
}
func TestDecide_L4_NonTaskHabit_StaysConservative(t *testing.T) {
d := New(activeConfig())
// A recalled preference that is not a task habit (coffee) must not wake a neutral
// turn — 默认保守 still holds.
recall := []RecallItem{{
Content: "用户习惯:他喜欢喝冰美式",
Scope: "user",
Score: 0.9,
}}
dec := d.Decide(context.Background(), inRecall([]string{"今天喝冰美式了吗"}, recall))
if dec.State != StateQuiet {
t.Fatalf("non-task habit woke to %s, want quiet", dec.State)
}
}
func TestDecide_L4_UndecidedNoHabit_ConservativeQuiet(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), inRecall([]string{"嗯", "这个", "然后"}, nil))
if dec.State != StateQuiet {
t.Fatalf("undecided state = %s, want quiet (conservative)", dec.State)
}
}
func TestRecordIntent_TaskFormatsParses(t *testing.T) {
d := New(activeConfig())
j := Judgment{IsTask: true, TaskKind: taskKindQuery, Confidence: 0.9, Text: "帮我查一下今天的天气;再跑个测试"}
rec := d.RecordIntent(j)
if rec == "" {
t.Fatal("RecordIntent of a task judgment returned empty")
}
got, ok := parseIntentRecord(rec)
if !ok {
t.Fatalf("parseIntentRecord failed on %q", rec)
}
if !got.isTask || got.taskKind != taskKindQuery || got.text != j.Text {
t.Fatalf("round-trip mismatch: %+v, want isTask=true kind=query text=%q", got, j.Text)
}
if got.confidence != j.Confidence {
t.Fatalf("confidence = %v, want %v", got.confidence, j.Confidence)
}
}
func TestRecordIntent_NonTaskReturnsEmpty(t *testing.T) {
d := New(activeConfig())
if rec := d.RecordIntent(Judgment{IsTask: false, Text: "你好"}); rec != "" {
t.Fatalf("non-task judgment persisted %q, want empty (nothing worth learning)", rec)
}
}
func TestDecide_JudgmentPopulated(t *testing.T) {
d := New(activeConfig())
task := d.Decide(context.Background(), in("帮我查一下资料"))
if !task.Judgment.IsTask || task.Judgment.TaskKind != taskKindQuery {
t.Fatalf("task judgment = %+v, want IsTask=true kind=query", task.Judgment)
}
if task.Judgment.Text != "帮我查一下资料" {
t.Fatalf("judgment text = %q, want the current turn", task.Judgment.Text)
}
quiet := d.Decide(context.Background(), in("你好"))
if quiet.Judgment.IsTask || quiet.Judgment.TaskKind != "" {
t.Fatalf("quiet judgment = %+v, want IsTask=false kind empty", quiet.Judgment)
}
}
+300
View File
@@ -0,0 +1,300 @@
package capacity
import "strings"
// defaultQuietTools is the companion-safe face (design §1): terminal + file edit
// + memory/emotion companion. It is a function building the slice fresh so no
// package-level global slice is introduced (project rule §10).
func defaultQuietTools() []string {
return []string{"terminal", "file", "knowledge", "text"}
}
// defaultAwakeTools extends the quiet face with web/network retrieval (design
// §1 唤醒态): a real task wakes the retrieval tools, file and terminal.
func defaultAwakeTools() []string {
return []string{"terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http"}
}
// intent classifies the round. It runs L1 on the current turn, falls to L2 on
// the recent context trend, and folds in the L4 learned-habit verdict (design §2.2)
// at each layer: L4 may correct a decided result (boost / quiet→awake) or fill in
// when L1/L2 are undecided. With no matching habit L4 is silent, so a turn with no
// recall behaves exactly as before; otherwise the result stays conservative (低置信
// still rests via applyAwakening).
func (d *Decider) intent(in Input) (State, float64, string) {
if len(in.Messages) == 0 {
return "", 0, ""
}
current := in.Messages[len(in.Messages)-1]
l4s, l4c, l4t, l4ok := d.l4(current, in.Recall)
if s, c, t, ok := l1(current); ok {
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
}
if s, c, t, ok := l2(in.Messages); ok {
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
}
if l4ok {
return l4s, l4c, l4t
}
return "", 0.3, "" // 拿不准 → conservative fallback
}
// l1 is the fast heuristic on the current turn (design §2.2 L1): a strong
// action signal wakes (awake, or full for complex work); a clear companion
// signal (weak + no task object) stays quiet; otherwise it is undecided and the
// caller defers to L2.
func l1(current string) (State, float64, string, bool) {
if hasStrongSignal(current) {
if isComplex(current) {
return StateFull, 0.9, inferTool(current), true
}
return StateAwake, 0.9, inferTool(current), true
}
if isClearCompanion(current) {
return StateQuiet, 0.8, "", true
}
return "", 0, "", false
}
// l2 looks at the recent context trend (design §2.2 L2) when L1 is undecided:
// a session firmly leaning task (or a follow-up on the previous task) wakes;
// a session firmly leaning emotional keeps quiet; a balanced window defers.
func l2(messages []string) (State, float64, string, bool) {
window := lastN(messages, 6)
task, comp := 0, 0
lastTask := ""
for _, m := range window {
switch {
case hasStrongSignal(m):
task++
lastTask = m
case isClearCompanion(m):
comp++
case hasFollowup(m):
task++
}
}
if task > comp {
if isComplex(lastTask) {
return StateFull, 0.66, inferTool(lastTask), true
}
return StateAwake, 0.66, inferTool(lastTask), true
}
if comp > task {
return StateQuiet, 0.7, "", true
}
return "", 0, "", false
}
// lastN returns the last n elements of messages (or all when shorter).
func lastN(messages []string, n int) []string {
if len(messages) <= n {
return messages
}
return messages[len(messages)-n:]
}
// hasStrongSignal reports a task-verb present together with either an
// imperative marker (帮我/请/去/把/将) or a concrete task object (file path,
// URL, code, a task noun) — 明确的动作指令 + 任务对象 (design §2.2 L1).
func hasStrongSignal(text string) bool {
if !hasStrongVerb(text) {
return false
}
return hasImperativeMarker(text) || hasTaskObject(text)
}
// hasStrongVerb reports an action verb that implies work (查/改/写/搜/跑/部署…).
func hasStrongVerb(text string) bool {
return containsAny(text, strongVerbs())
}
// strongVerbs is the L1 action-verb vocabulary.
func strongVerbs() []string {
return []string{
"帮我", "查", "查询", "查一下", "搜索", "搜", "搜一下", "查找", "找一下", "找",
"看", "看看", "检查", "核对", "改", "修改", "编辑", "写", "编写", "创建",
"生成", "实现", "配置", "修复", "删除", "移除", "升级", "更新", "搭建",
"部署", "迁移", "重构", "运行", "执行", "跑", "编译", "构建", "安装",
"启动", "重启", "停止", "下载", "上传", "翻译", "计算", "对比", "分析",
"总结", "汇报", "审批", "编排",
// ASCII
"help me", "search", "query", "look up", "find", "edit", "modify",
"write", "create", "generate", "fix", "refactor", "migrate", "deploy",
"build", "run", "compile", "install", "start", "restart", "stop", "test",
"analyze", "calculate", "compare", "please",
}
}
// hasImperativeMarker reports a direct instruction cue (帮我/请/去/把/将/麻烦…).
func hasImperativeMarker(text string) bool {
return containsAny(text, imperativeMarkers())
}
func imperativeMarkers() []string {
return []string{
"帮我", "请", "给我", "麻烦你", "你能", "你可不可以", "去", "把", "将",
"帮忙", "拜托", "帮我一下",
// ASCII
"please", "help me", "can you", "could you", "would you",
}
}
// hasTaskObject reports a concrete task object: a file path, URL, code snippet,
// or an object noun (报告/文档/代码/测试/设备…).
func hasTaskObject(text string) bool {
return hasFileObject(text) || hasURLObject(text) || hasCodeObject(text) || hasTaskNoun(text)
}
// hasFileObject reports a path-like target (a known source extension, a drive
// path, a "./" or "../" relative path, a bare filename such as cx.go).
func hasFileObject(text string) bool {
for _, ext := range fileExtensions() {
if strings.Contains(text, ext) {
return true
}
}
if strings.Contains(text, "./") || strings.Contains(text, "../") {
return true
}
return false
}
// fileExtensions is the source/config extension set used for object detection.
func fileExtensions() []string {
return []string{
".go", ".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".java", ".c", ".cpp",
".h", ".yaml", ".yml", ".json", ".toml", ".ini", ".conf", ".mod", ".sum",
".md", ".txt", ".css", ".html", ".sh", ".sql", ".proto", ".env.example",
}
}
// hasURLObject reports an http(s) URL or a bare web host.
func hasURLObject(text string) bool {
return containsAny(text, []string{"http://", "https://", "www.",
".com", ".cn", ".net", ".org", ".io", ".dev", ".xyz", ".ai"})
}
// hasCodeObject reports a code snippet (backticks, brace pairs, declarations).
func hasCodeObject(text string) bool {
return containsAny(text, []string{"`", "func ", "def ", "return ", ":= ",
"class ", "import ", "package ", "const ", "var ", "=>", "};"})
}
// hasTaskNoun reports a concrete work object noun.
func hasTaskNoun(text string) bool {
return containsAny(text, taskNouns())
}
// taskNouns is the object-noun vocabulary for object detection.
func taskNouns() []string {
return []string{
"报告", "文档", "代码", "脚本", "测试", "文件", "项目", "结果", "数据",
"接口", "配置", "日志", "表格", "图片", "网页", "页面", "服务器", "服务",
"设备", "手机", "电脑", "城市", "天气", "新闻", "价格", "股票", "汇率",
"系统", "数据库", "模型", "论文", "资料", "链接",
// ASCII
"report", "doc", "code", "script", "test", "file", "project", "server",
"device", "config", "log", "url", "api", "database", "page",
}
}
// isClearCompanion reports a pure companionship turn: a weak (emotional/greeting)
// signal with no task verb and no task object (设计 §2.2 L1 弱信号→静息).
func isClearCompanion(text string) bool {
if !hasWeakSignal(text) {
return false
}
if hasStrongSignal(text) {
return false
}
return !hasTaskObject(text)
}
// hasWeakSignal reports a companion-leaning cue (问候/情绪词/日常寒暄).
func hasWeakSignal(text string) bool {
return containsAny(text, weakSignals())
}
// weakSignals is the L1 companion vocabulary.
func weakSignals() []string {
return []string{
"你好", "嗨", "哈喽", "早上好", "早安", "中午好", "下午好", "晚上好",
"晚安", "在吗", "在不在", "抱抱", "么么", "亲亲", "摸摸", "想你",
"爱你", "喜欢你", "想念", "我好", "我累", "累了", "疲惫", "困了",
"无聊", "寂寞", "失眠", "睡不着", "难过", "伤心", "开心", "高兴",
"哈哈", "嘿嘿", "呜呜", "555", "哭", "笑", "心疼", "安慰", "谢谢",
"辛苦", "再见", "明天见", "待会见", "怎么了", "在干嘛", "干嘛呢",
"好呀", "好的", "好哒", "嗯嗯", "嗯", "好", "喔", "哦", "呀",
// ASCII
"hi", "hello", "hey", "good morning", "good night", "good evening",
"hug", "miss you", "love you", "haha", "lol", "aww", "ty", "thanks",
"bye", "thinking", "sleepy", "tired", "happy", "mood", "nice",
}
}
// hasFollowup reports a continuation cue pointing at the previous task
// (设计 §2.2 L2: 上一轮任务的跟进 继续/然后呢/还有吗).
func hasFollowup(text string) bool {
return containsAny(text, followups())
}
func followups() []string {
return []string{
"继续", "然后呢", "还有吗", "还有呢", "接着", "下一步", "接下来",
"之后呢", "那然后", "继续做", "继续写", "继续说", "再来", "再继续",
// ASCII
"continue", "and then", "next", "what else", "also", "go on", "more",
}
}
// isComplex reports whether a task is complex enough for the full state
// (设计 §1 全能态·复杂任务): refactor/migrate/deploy/whole-project/multi-file…
func isComplex(text string) bool {
return containsAny(text, complexMarkers())
}
func complexMarkers() []string {
return []string{
"重构", "搭建", "部署", "迁移", "架构", "实现一个", "写一个", "做一个",
"整个项目", "全部", "所有", "批量", "多文件", "多个文件", "全量",
"综合", "一体化", "测试全部", "修复所有", "上下链路", "系统设计",
// ASCII
"refactor", "migrate", "architect", "build a", "full", "all the",
"multiple files", "entire", "whole", "batch",
}
}
// inferTool names the tool that a task turn would light up, used for the
// capacity_awaken event's Tool field (点亮工具). Order: file path/code → file,
// query/search → web-search, else terminal.
func inferTool(text string) string {
switch {
case hasFileObject(text), hasCodeObject(text):
return "file"
case containsAny(text, []string{
"查", "查询", "搜", "搜索", "查找", "找一下", "search", "query",
"look up", "find", "查一下", "搜索一下",
}):
return "web-search"
case containsAny(text, []string{
"跑", "运行", "执行", "部署", "编译", "构建", "安装", "启动", "重启",
"测试", "run", "deploy", "build", "compile", "install", "start",
"restart", "exec", "test",
}):
return "terminal"
default:
return ""
}
}
// containsAny reports whether text contains any of words (a simple substring
// match; every word is a UTF-8 substring).
func containsAny(text string, words []string) bool {
for _, w := range words {
if strings.Contains(text, w) {
return true
}
}
return false
}
+32
View File
@@ -0,0 +1,32 @@
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
)
// capStateKey is the context key for the per-Run capacity-state accumulator.
type capStateKey struct{}
// capStateAccum holds the mutable capacity state for one Run so the state
// progresses across a multi-step tool loop (quiet→awake→full) without leaking
// between concurrent runs. It lives on the context seeded at the top of Run.
type capStateAccum struct {
state capacity.State
}
// withCapState returns a context carrying acc.
func withCapState(ctx context.Context, acc *capStateAccum) context.Context {
return context.WithValue(ctx, capStateKey{}, acc)
}
// capState returns the accumulator on ctx, or a fresh quiet one when none is
// seeded (a defensive fallback for a caller that invokes toolSchemas outside
// Run, where the state is not meant to persist).
func capState(ctx context.Context) *capStateAccum {
if acc, ok := ctx.Value(capStateKey{}).(*capStateAccum); ok {
return acc
}
return &capStateAccum{state: capacity.StateQuiet}
}
@@ -0,0 +1,97 @@
package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// seedL4Habit writes a canonical intent record memory (the closed-loop write-back
// format produced by capacity.RecordIntent) recording that "搞一下这个部署" is a task
// for persona-a.
func seedL4Habit(t *testing.T, store *memory.Store) {
t.Helper()
ctx := context.Background()
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona-a",
Scope: memory.ScopeUser,
Kind: memory.KindRelationship,
Content: "intent:isTask=true;taskKind=ops;confidence=0.85;text=搞一下这个部署",
}); err != nil {
t.Fatalf("seed L4 habit memory: %v", err)
}
}
// TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv proves the L4 feed: with a
// retrieved user habit recording that the phrase is a task, an otherwise-undecided
// turn wakes in the owner-full env, and the model is advertised the awake tool set.
func TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedL4Habit(t, store)
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
Retriever: memory.NewRetriever(store),
PersonaID: "persona-a",
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "搞一下那个部署")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
// L4 read the learned habit → awake → web-search is advertised (awake set).
if !hasAll(set, "terminal", "file", "web-search") {
t.Fatalf("owner L4 tools = %v, want awake set incl web-search", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead proves the L4 isolation red
// line: in a group/stranger context the loop passes no recall, so the decider falls
// back to L1/L2 and the owner's habit memory is never used to wake the intent.
func TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedL4Habit(t, store)
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
Retriever: memory.NewRetriever(store),
PersonaID: "persona-a",
})
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "搞一下那个部署")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
// Restricted env → L4 silent → undecided turn stays quiet → only quiet tools, no
// web-search, no owner habit read.
if !hasAll(set, "terminal", "file") {
t.Fatalf("restricted tools = %v, want quiet set terminal+file", toolNames(ad.streamReqs[0].Tools))
}
if set["web-search"] {
t.Fatalf("restricted tools = %v, must NOT wake (owner habit not read)", toolNames(ad.streamReqs[0].Tools))
}
}
+158
View File
@@ -0,0 +1,158 @@
package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// capacityToolSet builds a dispatcher over the four representative tools used to
// verify the allowed-set filter: two quiet tools, one awake-only, and one that is
// never allowed in quiet/awake.
func capacityToolSet(t *testing.T) *tool.Dispatcher {
t.Helper()
return dispatcherWith(t,
&mockTool{name: "terminal"},
&mockTool{name: "file"},
&mockTool{name: "web-search"},
&mockTool{name: "json"},
)
}
// toolNames returns the set of tool names advertised in specs. The registry
// List iterates a map, so registration order is not preserved; compare with a
// set (via toolSet) rather than a slice for order-independent assertions.
func toolNames(specs []model.ToolSpec) []string {
out := make([]string, 0, len(specs))
for _, s := range specs {
out = append(out, s.Name)
}
return out
}
func toolSet(specs []model.ToolSpec) map[string]bool {
set := make(map[string]bool, len(specs))
for _, s := range specs {
set[s.Name] = true
}
return set
}
func hasAll(set map[string]bool, names ...string) bool {
for _, n := range names {
if !set[n] {
return false
}
}
return true
}
// TestLoop_Capacity_QuietHidesHeavierTools verifies that with an enabled capacity
// decider a chitchat turn advertises only the quiet allowed set to the model
// (terminal + file, not web-search/json) on the streaming path.
func TestLoop_Capacity_QuietHidesHeavierTools(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "陪你"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file") {
t.Fatalf("quiet tools = %v, want to include terminal+file", toolNames(ad.streamReqs[0].Tools))
}
if set["web-search"] || set["json"] {
t.Fatalf("quiet tools = %v, want to exclude web-search/json", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_WakeExposesWebSearch verifies a task turn exposes the awake
// allowed set (adds web-search) to the model.
func TestLoop_Capacity_WakeExposesWebSearch(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "好的,这就查"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "帮我查一下最近的天气")}); err != nil {
t.Fatalf("Run: %v", err)
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file", "web-search") {
t.Fatalf("awake tools = %v, want to include terminal+file+web-search", toolNames(ad.streamReqs[0].Tools))
}
if set["json"] {
t.Fatalf("awake tools = %v, want to exclude json", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_NilKeepsAllTools verifies the pre-existing behavior: without
// a capacity decider every registered tool is advertised (no filtering).
func TestLoop_Capacity_NilKeepsAllTools(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "你好"}}}}
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好")}); err != nil {
t.Fatalf("Run: %v", err)
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file", "web-search", "json") {
t.Fatalf("nil capacity tools = %v, want all four", toolNames(ad.streamReqs[0].Tools))
}
if len(set) != 4 {
t.Fatalf("nil capacity tools = %v, want exactly four", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_EmitsAwakenEvent verifies a state change surfaces a
// capacity_awaken event through the OnCapacity hook on the streaming path.
func TestLoop_Capacity_EmitsAwakenEvent(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "查到了"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
var got []capacity.Event
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
OnCapacity: func(ev capacity.Event) { got = append(got, ev) },
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "帮我查一下最近的天气")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(got) != 1 {
t.Fatalf("capacity events = %d, want 1 (got %+v)", len(got), got)
}
ev := got[0]
if ev.Type != capacity.EventAwaken || ev.FromState != capacity.StateQuiet || ev.ToState != capacity.StateAwake {
t.Fatalf("event = %+v, want quiet→awake awaken", ev)
}
if ev.Tool != "web-search" {
t.Fatalf("lit tool = %q, want web-search", ev.Tool)
}
}
+5
View File
@@ -19,6 +19,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -81,6 +82,8 @@ type Result struct {
type Loop struct {
agent *agent.Agent
tools *tool.Dispatcher
capacity *capacity.Decider
onCapacity func(capacity.Event)
memory *memory.Store
retriever *memory.Retriever
working *memory.WorkingMemory
@@ -126,6 +129,8 @@ func New(opts Options) *Loop {
return &Loop{
agent: opts.Agent,
tools: opts.Tools,
capacity: opts.Capacity,
onCapacity: opts.OnCapacity,
memory: opts.Memory,
retriever: opts.Retriever,
working: opts.WorkingMemory,
+12
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -26,6 +27,17 @@ type Options struct {
// Tools is the tool dispatcher used to execute model-requested calls. When
// nil the loop falls back to the agent's own dispatcher (if any).
Tools *tool.Dispatcher
// Capacity, when non-nil and enabled, filters the tool schemas advertised to
// the model each round to the allowed set for the current capacity state
// (design/14 唤醒式能力渐进 §4): the loop only exposes the allowed set to the
// model — a view, not a hard boundary (Invoke is left unblocked so an
// emergency call still works). Nil (the default) keeps the pre-existing
// behavior: every registered tool is advertised.
Capacity *capacity.Decider
// OnCapacity, when non-nil, receives capacity_awaken transition events the
// loop observes while deciding a round (design/14 §4), so a caller can
// surface the "能力点亮" progression to the frontend. Nil = no-op.
OnCapacity func(capacity.Event)
// Memory is the long-term memory store used to persist extracted items.
Memory *memory.Store
// Retriever injects relevant memories into the context, re-evaluated every
+102 -5
View File
@@ -1,7 +1,10 @@
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
@@ -9,14 +12,14 @@ import (
// buildRequest assembles a model.Request from the agent's public surface,
// mirroring agent.buildRequest so the streaming path behaves identically to
// RunStep (leading instruction message, tool schemas, model routing).
func (l *Loop) buildRequest(messages []model.Message) model.Request {
func (l *Loop) buildRequest(ctx context.Context, messages []model.Message) model.Request {
req := model.Request{
Model: l.agent.Model(),
Messages: l.assembleMessages(messages),
Temperature: l.temperature(),
MaxTokens: l.agent.MaxTokens(),
Purpose: l.agent.Purpose(),
Tools: l.toolSchemas(),
Tools: l.toolSchemas(ctx, messages),
}
if l.agent.Router() != nil {
if m, _ := l.agent.Router().RouteRequest(req); m != "" {
@@ -50,13 +53,107 @@ func (l *Loop) temperature() *float64 {
}
// toolSchemas advertises the registered tool schemas to the model, or nil when no
// dispatcher is configured.
func (l *Loop) toolSchemas() []model.ToolSpec {
// dispatcher is configured. When a capacity decider is wired and enabled, it
// classifies the current round and returns only the allowed set for that state
// (a view over the dispatcher, per design/14 §4): the model sees/invokes only
// the allowed tools, while Invoke itself stays unblocked. A nil or disabled
// decider keeps the pre-existing behavior (every tool advertised).
func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []model.ToolSpec {
d := l.dispatcher()
if d == nil {
return nil
}
return d.ToolSchemas()
schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() {
return schemas
}
names := make([]string, 0, len(schemas))
for _, s := range schemas {
names = append(names, s.Name)
}
acc := capState(ctx)
dec := l.capacity.Decide(ctx, capacity.Input{
Messages: capacityTexts(messages),
CurrentState: acc.state,
AllToolNames: names,
Recall: l.capacityRecall(ctx, messages),
})
acc.state = dec.State
if l.onCapacity != nil {
for _, ev := range dec.Events {
l.onCapacity(ev)
}
}
allowed := make(map[string]bool, len(dec.AllowedTools))
for _, n := range dec.AllowedTools {
allowed[n] = true
}
out := make([]model.ToolSpec, 0, len(dec.AllowedTools))
for _, s := range schemas {
if allowed[s.Name] {
out = append(out, s)
}
}
return out
}
// capacityTexts extracts the recent conversation text for the capacity decider:
// the last few non-system/non-tool turns, in chronological order (oldest first),
// so the decider's current-turn window sees the latest user message last.
func capacityTexts(messages []model.Message) []string {
texts := make([]string, 0, 6)
for i := len(messages) - 1; i >= 0 && len(texts) < 6; i-- {
m := messages[i]
if m.Role == model.RoleSystem || m.Role == model.RoleTool {
continue
}
if t := messageText(m); t != "" {
texts = append(texts, t)
}
}
for i, j := 0, len(texts)-1; i < j; i, j = i+1, j-1 {
texts[i], texts[j] = texts[j], texts[i]
}
return texts
}
// capacityRecallTopK bounds how many recalled habit memories the capacity L4 layer
// considers per round.
const capacityRecallTopK = 5
// capacityRecall retrieves the owner's relevant long-term memories for the capacity
// L4 habit-alignment layer (design §2.2 L4), the memory feed that lets the decider
// learn the user's task-expression habits. It returns nil (keeping L4 silent) when
// no retriever/persona is configured — backward compatible — or in a restricted
// (group chat / stranger) context, per the isolation red line: L4 learns the owner's
// habits and must never read owner-sensitive memory into a group or stranger intent
// decision (design §2.2 红线: 不读陌生人敏感).
func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []capacity.RecallItem {
if l.retriever == nil || l.personaID == "" {
return nil
}
env := sessionEnvFrom(ctx)
if env.Restricted() {
return nil
}
results, err := l.retriever.Retrieve(ctx, l.personaID, queryFromMessages(messages), capacityRecallTopK)
if err != nil {
l.log.Warn("capacity recall", "error", err, "env", env.ActorEnv)
return nil
}
items := make([]capacity.RecallItem, 0, len(results))
for _, r := range results {
items = append(items, capacity.RecallItem{
Content: r.Item.Content,
Scope: string(r.Item.Scope),
Kind: string(r.Item.Kind),
Tags: r.Item.Tags,
Score: r.Score,
})
}
return items
}
// decodeToolCalls decodes tool calls from a model.Response using the same two
+6
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
@@ -31,6 +32,11 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
return nil, fmt.Errorf("loop: run: %w", ErrNilAgent)
}
// Seed the per-Run capacity accumulator at quiet so the state can progress
// (quiet→awake→full) across a multi-step tool loop; a nil/disabled decider
// leaves it unused. It is scoped to this Run, so concurrent runs never share.
ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet})
msgs := l.resolveImages(ctx, messages)
toolState := &toolLoopState{prevFP: make(map[string]bool)}
res := &Result{}
+1 -1
View File
@@ -19,7 +19,7 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
if adapter == nil {
return nil, fmt.Errorf("loop: stream step: %w", ErrNoAdapter)
}
req := l.buildRequest(messages)
req := l.buildRequest(ctx, messages)
if l.guards != nil {
verdict, err := l.guards.CheckInput(ctx, &req)