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

+26
View File
@@ -72,6 +72,26 @@ func (k *APIKey) Revoke(key string) {
delete(k.keys, key)
}
// RevokeByMask removes every key whose masked identifier (the same derived id
// List returns) matches mask. It returns true when at least one key was revoked.
// The mask is lossy, so it is matched by recomputing it for each key; the table
// is small so a linear scan is fine. An empty or nil-returning call is a no-op.
func (k *APIKey) RevokeByMask(mask string) bool {
if mask == "" {
return false
}
k.mu.Lock()
defer k.mu.Unlock()
revoked := false
for key := range k.keys {
if maskKey(key) == mask {
delete(k.keys, key)
revoked = true
}
}
return revoked
}
// List returns a summary of every currently registered API key for the admin
// panel. Key material is never returned; each identifier is masked so the panel
// can tell keys apart without exposing the secret. The in-memory store keeps no
@@ -86,6 +106,12 @@ func (k *APIKey) List() []UserSummary {
return out
}
// MaskKey renders a key's leading and trailing groups so the panel can identify
// an entry without revealing the secret. Short keys are fully hidden. It is the
// exported form of maskKey so callers (e.g. the admin issue handler) can return
// the same masked id the List page shows.
func MaskKey(key string) string { return maskKey(key) }
// maskKey renders a key's leading and trailing groups so the panel can identify
// an entry without revealing the secret. Short keys are fully hidden.
func maskKey(key string) string {
+21
View File
@@ -32,6 +32,7 @@ type Config struct {
WSHub WSHubConfig
ExecSandbox ExecSandbox
MCP MCPConfig
Capacity CapacityConfig
}
// ServerConfig holds the single gateway listener settings.
@@ -123,6 +124,26 @@ type MCPConfig struct {
ServersJSON string `env:"MCP_SERVERS_JSON"`
}
// CapacityConfig configures the awakened-capacity decision layer
// (DevDocs/design/14-唤醒式能力渐进.md §4). It is opt-in: the loop exposes every
// tool until Enabled is true.
type CapacityConfig struct {
// Enabled turns on the capacity decider. When false the loop advertises every
// registered tool (pre-existing behavior). Note: it is wired at the loop
// periphery, not back-end (Invoke stays unblocked).
Enabled bool `env:"CAPACITY_ENABLED" default:"false"`
// MinConfidence is the wake threshold below which the state is forced toward
// the persona default (设计 §2.3 默认保守). Zero selects the code default 0.6.
MinConfidence float64 `env:"CAPACITY_MIN_CONFIDENCE" default:"0.6"`
// ToolSetsJSON, when non-empty, is a JSON map of state→[]tool name, e.g.
// {"quiet":["terminal","file"],"awake":["terminal","file","web-search"],
// "full":null}. A missing state selects the built-in default set; null ("full")
// means unrestricted. Parsed by the capacity wiring at startup (the
// reflection-based loader only supports scalars and []string), matching the
// MCP config pattern.
ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"`
}
// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or
// an SSE endpoint (URL). Enabled gates activation at startup.
type MCPServer struct {
+32
View File
@@ -130,6 +130,38 @@ func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
}
}
func TestLoad_CapacityDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = true, want false (default, opt-in)")
}
if cfg.Capacity.MinConfidence != 0.6 {
t.Errorf("Capacity.MinConfidence = %v, want 0.6", cfg.Capacity.MinConfidence)
}
}
func TestLoad_CapacityEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("CAPACITY_ENABLED", "true")
t.Setenv("CAPACITY_MIN_CONFIDENCE", "0.75")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = false, want true")
}
if cfg.Capacity.MinConfidence != 0.75 {
t.Errorf("Capacity.MinConfidence = %v, want 0.75", cfg.Capacity.MinConfidence)
}
}
func TestParseDotEnv_QuotedAndExport(t *testing.T) {
path := writeEnvFile(t, `export FOO=bar
BAZ='quoted value'
+15
View File
@@ -74,6 +74,8 @@ func setField(fv reflect.Value, raw string) error {
return setBool(fv, raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return setInt(fv, raw)
case reflect.Float32, reflect.Float64:
return setFloat(fv, raw)
case reflect.Slice:
return setStringSlice(fv, raw)
default:
@@ -107,6 +109,19 @@ func setInt(fv reflect.Value, raw string) error {
return nil
}
func setFloat(fv reflect.Value, raw string) error {
if raw == "" {
fv.SetFloat(0)
return nil
}
n, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("invalid float %q", raw)
}
fv.SetFloat(n)
return nil
}
func setStringSlice(fv reflect.Value, raw string) error {
if fv.Type().Elem().Kind() != reflect.String {
return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind())
+62
View File
@@ -89,6 +89,68 @@ func (r *Registry) Persona(id string) (*loader.Persona, bool) {
return p, ok
}
// UpdatePersona replaces the persona registered under p.ID with p, rebuilding
// its emotion machine and affection tracker. It fails when p is nil, has no id,
// or is not currently registered. In-flight loops bound to the previous
// *loader.Persona keep their pointer; only future lookups observe the new value.
func (r *Registry) UpdatePersona(p *loader.Persona) error {
if p == nil {
return fmt.Errorf("registry: nil persona")
}
if p.ID == "" {
return fmt.Errorf("registry: persona id required")
}
machine, err := p.NewMachine()
if err != nil {
return fmt.Errorf("registry: persona %q machine: %w", p.ID, err)
}
affection, err := p.NewAffection()
if err != nil {
return fmt.Errorf("registry: persona %q affection: %w", p.ID, err)
}
r.mu.Lock()
if _, ok := r.personas[p.ID]; !ok {
r.mu.Unlock()
return fmt.Errorf("registry: persona %q not registered", p.ID)
}
r.personas[p.ID] = p
r.mu.Unlock()
r.emotions.Set(p.ID, machine, affection)
return nil
}
// UpdateCharacter replaces the fields of the character registered under c.ID,
// leaving its Default flag intact unless c re-declares it. It fails when c is
// nil, has no id, is not registered, or its persona does not exist.
func (r *Registry) UpdateCharacter(c *Character) error {
if c == nil {
return fmt.Errorf("registry: nil character")
}
if c.ID == "" {
return fmt.Errorf("registry: character id required")
}
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.characters[c.ID]
if !ok {
return fmt.Errorf("registry: character %q not registered", c.ID)
}
if _, ok := r.personas[c.PersonaID]; !ok {
return fmt.Errorf("registry: character %q references unknown persona %q", c.ID, c.PersonaID)
}
existing.Name = c.Name
existing.PersonaID = c.PersonaID
existing.Modes = append(existing.Modes[:0], c.Modes...)
if c.Default {
existing.Default = true
r.defaultID = c.ID
}
return nil
}
// RegisterCharacter registers c. It requires c's persona to exist, so persona
// registration must happen first. A character marked Default becomes the
// library's default role.
+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)
+54
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"sync"
"time"
)
// Service composes the Scheduler (cron), the Engine (event rules) and the
@@ -170,6 +171,59 @@ func (s *Service) CancelReminder(ctx context.Context, id string) error {
return s.remind.Cancel(ctx, id)
}
// AddRuleByName registers a named cron/event automation whose action records its
// fire on the service logger (a real, observable side effect) and returns the
// generated rule id. kind must be "cron" or "event"; expression is a cron spec
// for cron rules or a bus subject pattern for event rules. A malformed rule or
// expression returns an error wrapping ErrInvalidRule / ErrInvalidCron so the
// admin handler can answer honestly instead of fabricating a success.
func (s *Service) AddRuleByName(ctx context.Context, name, kind, expression string) (string, error) {
act, err := NewFuncAction(name, func(_ context.Context, payload any) (any, error) {
s.log.Info("admin automation fired", "name", name, "payload", payload)
return map[string]any{"fired": name, "payload": payload}, nil
})
if err != nil {
return "", err
}
rule := Rule{
ID: fmt.Sprintf("auto-%d", time.Now().UnixNano()),
Name: name,
Trigger: TriggerKind(kind),
Expression: expression,
Action: act,
}
if err := s.AddRule(ctx, rule); err != nil {
return "", err
}
return rule.ID, nil
}
// RunRule executes one registered rule's action once and returns its output. It
// is the manual "run now" path for the admin panel. It returns an error wrapping
// ErrNotFound when no rule or reminder carries id.
func (s *Service) RunRule(ctx context.Context, id string) (any, error) {
if id == "" {
return nil, fmt.Errorf("automations: run rule: %w", ErrInvalidRule)
}
for _, r := range s.engine.Rules() {
if r.ID == id {
return r.Action.Execute(ctx, nil)
}
}
s.mu.RLock()
r, ok := s.cronMeta[id]
s.mu.RUnlock()
if ok {
return r.Action.Execute(ctx, nil)
}
for _, rem := range s.remind.List() {
if rem.ID == id {
return map[string]any{"reminder": rem.ID, "message": rem.Message, "at": rem.At}, nil
}
}
return nil, fmt.Errorf("automations: run rule %q: %w", id, ErrNotFound)
}
// Start starts the scheduler, the event engine and the reminder loop.
func (s *Service) Start(ctx context.Context) error {
if err := s.sched.Start(ctx); err != nil {
+9 -2
View File
@@ -160,7 +160,7 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
// the handler leaks nothing sensitive.
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.LLM
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: []AdminLlmProvider{
providers := []AdminLlmProvider{
{
ID: "openai",
Name: "OpenAI",
@@ -179,7 +179,14 @@ func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
Enabled: cfg.Provider == "anthropic",
Fallback: cfg.AnthropicFallback,
},
}})
}
// Fold in providers added at runtime via the admin "add provider" write.
// They are process-lifetime and not bound to the model router, so the panel
// reflects them but the response marks nothing else.
s.llmMu.RLock()
providers = append(providers, s.llmExtra...)
s.llmMu.RUnlock()
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
}
// adminListAutomations lists automations and reminders (admin read).
@@ -0,0 +1,175 @@
package gateway
import (
"encoding/json"
"errors"
"net/http"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
)
// IoT device-control and automation-rule admin writes (owner-only, audited).
// Device control performs a real control on the owning provider; the others
// answer honestly when no scenario/rule store is wired.
// iotControlRequest is the POST /api/v1/admin/iot/{id}/control body.
type iotControlRequest struct {
Action string `json:"action"`
Params map[string]any `json:"params"`
}
// adminIoTControl sends a control instruction to a device through the IoT
// manager, which applies it on the owning provider and publishes a device_update
// event. A real action only when the deployment has a device provider.
func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req iotControlRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Action) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "action is required"))
return
}
mgr, ok := s.iot.(*iot.Manager)
if !ok {
s.auditClaims(r, "iot.control", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "iot_unavailable", "IoT 服务未配置"))
return
}
dev, err := mgr.Control(r.Context(), id, req.Action, req.Params)
if err != nil {
s.auditClaims(r, "iot.control", "error")
writeError(w, mapIotError(err))
return
}
if dev == nil {
s.auditClaims(r, "iot.control", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "device %q not found", id))
return
}
s.auditClaims(r, "iot.control", "ok")
writeJSON(w, http.StatusOK, iotDeviceSummary{ID: dev.ID, Name: dev.Name, Type: dev.Type, State: dev.State, Online: dev.Online})
}
// adminIoTScenario answers honestly: the IoT subsystem has no scenario store yet
// (it owns device list/control/status only), so scenario write lands in M10.
func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "iot.scenario", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 场景存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented})
}
// adminIoTRule answers honestly: no IoT automation-rule store exists yet.
func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "iot.rule", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 自动化规则存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented})
}
// mapIotError maps iot sentinel errors to HTTP errors.
func mapIotError(err error) *apperr.AppError {
switch {
case errors.Is(err, iot.ErrDeviceNotFound):
return apperr.Wrap(apperr.ErrNotFound, "not_found", "device not found")
case errors.Is(err, iot.ErrUnsupported):
return apperr.Wrap(apperr.ErrBadRequest, "unsupported_action", "action not supported")
case errors.Is(err, iot.ErrOffline):
return apperr.Wrap(apperr.ErrConflict, "device_offline", "device offline")
case errors.Is(err, iot.ErrInvalidParam):
return apperr.Wrap(apperr.ErrBadRequest, "invalid_param", "invalid parameter")
default:
return apperr.Wrap(apperr.ErrInternal, "iot_control_failed", "device control failed")
}
}
// automationCreateRequest is the POST /api/v1/admin/automations body.
type automationCreateRequest struct {
Name string `json:"name"`
Kind string `json:"kind"` // cron | event
Expression string `json:"expression"`
}
// adminCreateAutomation registers a real cron/event rule whose action records its
// fire. Invalid cron expressions or malformed rules are answered honestly.
func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) {
var req automationCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Kind) == "" || strings.TrimSpace(req.Expression) == "" {
s.auditClaims(r, "automation.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name, kind and expression are required"))
return
}
auto, ok := s.automations.(*automations.Service)
if !ok {
s.auditClaims(r, "automation.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
return
}
id, err := auto.AddRuleByName(r.Context(), req.Name, req.Kind, req.Expression)
if err != nil {
s.auditClaims(r, "automation.create", "error")
if errors.Is(err, automations.ErrInvalidCron) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_cron", "invalid cron expression"))
return
}
if errors.Is(err, automations.ErrInvalidRule) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_rule", "invalid rule"))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to add automation"))
return
}
s.auditClaims(r, "automation.create", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "name": req.Name, "kind": req.Kind})
}
// adminRunAutomation manually triggers a registered rule's action once.
func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
auto, ok := s.automations.(*automations.Service)
if !ok {
s.auditClaims(r, "automation.run", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
return
}
out, err := auto.RunRule(r.Context(), id)
if err != nil {
s.auditClaims(r, "automation.run", "error")
if errors.Is(err, automations.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to run automation"))
return
}
s.auditClaims(r, "automation.run", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "output": out})
}
// adminDeleteAutomation removes a registered rule (cron or event) by id.
func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
auto, ok := s.automations.(*automations.Service)
if !ok {
s.auditClaims(r, "automation.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
return
}
if err := auto.RemoveRule(r.Context(), id); err != nil {
s.auditClaims(r, "automation.delete", "error")
if errors.Is(err, automations.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to delete automation"))
return
}
s.auditClaims(r, "automation.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
}
@@ -0,0 +1,278 @@
package gateway
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// LLM-provider and channel-lifecycle admin writes (owner-only, audited). The LLM
// test performs a real model call; provider "add" records a process-lifetime
// entry. Channel open/close drive the real adapter lifecycle; the identity test
// is the structural actor × whitelist rule, never prompt text.
const (
// ownerProfilePath is the owner whitelist loaded for the structural identity
// test. It mirrors the path wire.go uses for owner recognition.
ownerProfilePath = "configs/users/owner.yaml"
// llmTestTimeout bounds a single LLM probe request.
llmTestTimeout = 30 * time.Second
// llmProbePrompt is the minimal prompt used to verify a model responds.
llmProbePrompt = "Reply with the single word: OK"
)
// llmProviderRequest is the POST /api/v1/admin/llm body for adding a provider.
type llmProviderRequest struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Multimodal bool `json:"multimodal"`
Fallback string `json:"fallback"`
}
// adminAddLlmProvider records a provider for the process lifetime, reflected in
// the GET /admin/llm listing. It is not bound to the model router (config is
// immutable after startup), so it is reported as temporary in the response.
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
var req llmProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Model) == "" {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and model are required"))
return
}
id := req.ID
if id == "" {
id = newIDHex()
}
provider := AdminLlmProvider{ID: id, Name: req.Name, BaseURL: req.BaseURL, Model: req.Model, Multimodal: req.Multimodal, Fallback: req.Fallback}
s.llmMu.Lock()
s.llmExtra = append(s.llmExtra, provider)
s.llmMu.Unlock()
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"provider": provider, "temporary": true})
}
// llmTestRequest is the POST /api/v1/admin/llm/test body.
type llmTestRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Prompt string `json:"prompt"`
}
// adminTestLlm performs a real completion against the supplied provider/model
// endpoint and reports the result — a genuine round-trip, not a placeholder.
func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
var req llmTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
s.auditClaims(r, "llm.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "model and baseUrl are required"))
return
}
provider := req.Provider
if provider == "" {
provider = model.ProviderOpenAI
}
adapter, err := model.NewChatAdapter(config.LLMConfig{Provider: provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model})
if err != nil {
s.auditClaims(r, "llm.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
return
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = llmProbePrompt
}
ctx, cancel := context.WithTimeout(r.Context(), llmTestTimeout)
defer cancel()
resp, err := adapter.Chat(ctx, model.Request{
Messages: []model.Message{model.TextMessage(model.RoleUser, prompt)},
}, model.Options{Timeout: llmTestTimeout})
if err != nil {
s.auditClaims(r, "llm.test", "error")
s.log.Error("llm test", "provider", provider, "model", req.Model, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_test_failed", "model call failed: %s", err.Error()))
return
}
s.auditClaims(r, "llm.test", "ok")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens})
}
// channelActionRequest is the POST /api/v1/admin/channels/open|close body.
type channelActionRequest struct {
Name string `json:"name"`
}
// adminChannelOpen connects a registered channel adapter (real lifecycle call).
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.open")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.open", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Connect(r.Context()); err != nil {
s.auditClaims(r, "channel.open", "error")
s.log.Error("channel open", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
return
}
s.auditClaims(r, "channel.open", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
}
// adminChannelClose disconnects a registered channel adapter (real lifecycle call).
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.close")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.close", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Disconnect(r.Context()); err != nil {
s.auditClaims(r, "channel.close", "error")
s.log.Error("channel close", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error()))
return
}
s.auditClaims(r, "channel.close", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
}
// channelTestRequest is the POST /api/v1/admin/channels/test body: a channel
// account id plus the conversation shape, resolved by the structural actor rule.
type channelTestRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
IsGroup bool `json:"isGroup"`
}
// adminChannelTest runs the structural identity rule (actor metadata × owner
// whitelist), never prompt text. It loads the owner whitelist from disk so a
// mismatch degrades to the conservative default (member/stranger).
func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
var req channelTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.AccountID) == "" {
s.auditClaims(r, "channel.identity-test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "accountId is required"))
return
}
msg := adapter.UnifiedMessage{
Platform: req.Platform,
ChannelID: req.AccountID,
SenderID: req.AccountID,
Sender: adapter.Actor{UserID: req.AccountID, IsGroup: req.IsGroup},
}
if req.IsGroup {
msg.ChannelType = adapter.ChannelGroup
} else {
msg.ChannelType = adapter.ChannelDirect
}
owner := loadOwnerProfile()
actor := session.ResolveActor(msg, owner)
env := session.EnvFor(actor)
s.auditClaims(r, "channel.identity-test", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"role": string(actor.Role),
"owner": actor.IsOwner(),
"isGroup": actor.IsGroupChat(),
"env": string(env),
"verdict": describeIdentity(actor, owner),
})
}
// channelName decodes the {name} body for open/close routes, writing a 400 on
// failure. It reports uniqueness of the decode via ok.
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) {
var req channelActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return "", false
}
if strings.TrimSpace(req.Name) == "" {
s.auditClaims(r, op, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return "", false
}
return req.Name, true
}
// findChannel returns the registered channel adapter named name, if any.
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
if caps == nil {
return nil, false
}
for _, cap := range caps.List(capreg.KindChannel) {
if ch, ok := cap.Value.(sdk.Channel); ok && ch.Name() == name {
return ch, true
}
}
return nil, false
}
// loadOwnerProfile loads the owner whitelist from disk, returning nil on any
// failure so the identity test degrades to the conservative deny.
func loadOwnerProfile() *user.OwnerProfile {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
return nil
}
return p
}
// describeIdentity renders the structural verdict for the identity test.
func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
switch {
case a.IsGroupChat():
return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
case a.IsOwner():
return "匹配主人白名单 → owner(全量访问,记忆可读写)"
default:
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
}
}
// newIDHex returns a 16-byte hex id.
func newIDHex() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("id-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
@@ -0,0 +1,275 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// persona-to-identity and API-key admin writes (owner-only, audited). Persona
// writes register into the persona registry (real runtime effect) and persist a
// YAML file so they survive a restart; API keys are minted/revoked in the
// auth.APIKey store.
const personaDir = "configs/personas"
// personaUpsertRequest is the POST/PUT /api/v1/admin/personas body.
type personaUpsertRequest struct {
ID string `json:"id"`
Name string `json:"name"`
CharacterRef string `json:"characterRef"`
Role string `json:"role"`
SystemPrompt string `json:"systemPrompt"`
Personality string `json:"personality"`
SpeechStyle string `json:"speechStyle"`
Catchphrase string `json:"catchphrase"`
Background string `json:"background"`
Values []string `json:"values"`
Modes []string `json:"modes"`
Mode string `json:"mode"`
MemoryNS string `json:"memoryNamespace"`
}
// adminCreatePersona registers a new persona in the registry (real runtime
// effect) and persists it to the persona YAML library so it survives a restart.
func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
var req personaUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.ID) == "" || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required"))
return
}
reg, ok := s.personas.(*personareg.Registry)
if !ok {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
return
}
if _, exists := reg.Persona(req.ID); exists {
s.auditClaims(r, "persona.create", "error")
writeError(w, &apperr.AppError{Code: "duplicate", Message: "人格已存在,请使用编辑", HTTPCode: http.StatusConflict})
return
}
p := personaFromRequest(req)
if err := savePersonaYAML(p); err != nil {
s.auditClaims(r, "persona.create", "error")
s.log.Error("persona persist", "id", req.ID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
return
}
if err := reg.RegisterPersona(p); err != nil {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register persona"))
return
}
if err := reg.RegisterCharacter(characterFromRequest(req)); err != nil {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register character"))
return
}
s.auditClaims(r, "persona.create", "ok")
writeJSON(w, http.StatusCreated, personaSummaryOf(p, characterFromRequest(req)))
}
// adminUpdatePersona edits an existing persona, persisting the YAML and
// replacing the in-memory persona/character.
func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req personaUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required"))
return
}
req.ID = id
reg, ok := s.personas.(*personareg.Registry)
if !ok {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
return
}
if _, exists := reg.Persona(id); !exists {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "persona %q not found", id))
return
}
p := personaFromRequest(req)
if err := savePersonaYAML(p); err != nil {
s.auditClaims(r, "persona.update", "error")
s.log.Error("persona persist", "id", id, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
return
}
if err := reg.UpdatePersona(p); err != nil {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update persona"))
return
}
if err := reg.UpdateCharacter(characterFromRequest(req)); err != nil {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update character"))
return
}
s.auditClaims(r, "persona.update", "ok")
writeJSON(w, http.StatusOK, personaSummaryOf(p, characterFromRequest(req)))
}
// adminCreateUser answers honestly: there is no persistent user table yet (the
// deployment is a single admin account + API keys), so user creation is deferred
// to M10 rather than fabricated.
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "user.create", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "新增用户/角色存储为 M10 提供(当前为单 admin + APIKey 现状)", HTTPCode: http.StatusNotImplemented})
}
// apiKeyIssueRequest is the POST /api/v1/admin/users/apikey body.
type apiKeyIssueRequest struct {
Role string `json:"role"`
}
// adminIssueApiKey mints a real API key bound to a role and returns the plaintext
// once (the only time it is visible). The id in the response is the masked form
// the admin list shows, so it can be used to revoke the key later.
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
var req apiKeyIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
role, err := auth.ParseRole(req.Role)
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role"))
return
}
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
key, err := keys.Generate(role)
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key"))
return
}
s.auditClaims(r, "apikey.issue", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)})
}
// adminRevokeApiKey revokes a key by its masked id (the identifier the admin list
// returns). It reports not-found when no key carries that mask.
func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "apikey.revoke", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
if !keys.RevokeByMask(id) {
s.auditClaims(r, "apikey.revoke", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "api key not found"))
return
}
s.auditClaims(r, "apikey.revoke", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
}
// personaFromRequest builds a loader.Persona from the upsert request, filling a
// derived system prompt/characterRef when omitted so a minimal create validates.
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
systemPrompt := strings.TrimSpace(req.SystemPrompt)
if systemPrompt == "" {
systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role)
}
characterRef := strings.TrimSpace(req.CharacterRef)
if characterRef == "" {
characterRef = req.ID
}
return &loader.Persona{
ID: req.ID,
Name: req.Name,
CharacterRef: characterRef,
Identity: loader.Identity{Role: req.Role, SystemPrompt: systemPrompt},
Personality: req.Personality,
SpeechStyle: req.SpeechStyle,
Catchphrase: req.Catchphrase,
Background: req.Background,
Values: req.Values,
Emotion: loader.EmotionParams{MemoryNamespace: strings.TrimSpace(req.MemoryNS)},
}
}
// characterFromRequest builds the registry.Character bound to the persona.
func characterFromRequest(req personaUpsertRequest) *personareg.Character {
modes := append([]string(nil), req.Modes...)
if len(modes) == 0 && req.Mode != "" {
modes = []string{req.Mode}
}
return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes}
}
// personaSummaryOf maps a persona+character to its wire summary.
func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
return PersonaSummary{
ID: p.ID,
Name: c.Name,
Character: c.Name,
IsDefault: c.Default,
Mode: firstMode(c.Modes),
MemoryNS: p.MemoryNamespace(),
}
}
// savePersonaYAML persists p to the persona library as <id>.yaml so it survives
// a restart (wire.go loads this directory). The id is sanitized so user input
// cannot traverse out of the library directory.
func savePersonaYAML(p *loader.Persona) error {
data, err := yaml.Marshal(p)
if err != nil {
return fmt.Errorf("persona: marshal %q: %w", p.ID, err)
}
if err := os.MkdirAll(personaDir, 0o755); err != nil {
return fmt.Errorf("persona: mkdir: %w", err)
}
path := filepath.Join(personaDir, sanitizeID(p.ID)+".yaml")
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("persona: write %s: %w", path, err)
}
return nil
}
// sanitizeID keeps only URL/path-safe characters so a persona id cannot escape
// the persona directory.
func sanitizeID(id string) string {
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
@@ -0,0 +1,156 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// Plugin uninstall, memory retrieval and session export admin writes
// (owner-only, audited). Uninstall removes a capability from the registry; memory
// search runs a real retrieval; session export returns the session metadata.
// pluginActionRequest is the POST /api/v1/admin/plugins/install|uninstall body.
type pluginActionRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
}
// adminPluginInstall answers honestly: installing a plugin needs a real plugin
// source (compiled binary or skill YAML), which the admin API cannot supply. It
// returns a clear 501 rather than fabricating a registered plugin.
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "plugin.install", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "插件安装需插件源码清单(编译二进制/YAML)为 M10 提供;卸载可直接调用 registry", HTTPCode: http.StatusNotImplemented})
}
// adminPluginUninstall removes a registered capability from the registry — a real
// effect that stops the plugin being enumerated or dispatched.
func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
var req pluginActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" {
s.auditClaims(r, "plugin.uninstall", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return
}
reg, ok := s.caps.(*capreg.Registry)
if !ok {
s.auditClaims(r, "plugin.uninstall", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
return
}
if err := reg.Remove(req.Name); err != nil {
s.auditClaims(r, "plugin.uninstall", "error")
if errors.Is(err, capreg.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "plugin %q not found", req.Name))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
return
}
s.auditClaims(r, "plugin.uninstall", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "uninstalled": true})
}
// memorySearchRequest is the POST /api/v1/admin/memories/search body.
type memorySearchRequest struct {
Query string `json:"query"`
PersonaID string `json:"personaId"`
TopK int `json:"topK"`
}
// memorySearchHit is one admin memory retrieval hit.
type memorySearchHit struct {
ID string `json:"id"`
Scope string `json:"scope"`
Text string `json:"text"`
Score float64 `json:"score"`
}
// adminMemorySearch runs a real retrieval over the memory store. The retrieval
// ranks by keyword relevance × decay; the semantic/vector path requires the
// composition root to inject the vector index, so this answers on the keyword
// path and reports semantic=false honestly.
func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) {
var req memorySearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Query) == "" {
s.auditClaims(r, "memory.search", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "query is required"))
return
}
store, ok := s.memories.(*memory.Store)
if !ok {
s.auditClaims(r, "memory.search", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_unavailable", "memory store is not configured"))
return
}
personaID := strings.TrimSpace(req.PersonaID)
if personaID == "" {
personaID = "cyrene"
}
topK := req.TopK
if topK <= 0 {
topK = 10
}
results, err := memory.NewRetriever(store).Retrieve(r.Context(), personaID, req.Query, topK)
if err != nil {
s.auditClaims(r, "memory.search", "error")
s.log.Error("memory search", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_search_failed", "failed to search memory"))
return
}
hits := make([]memorySearchHit, 0, len(results))
for _, res := range results {
hits = append(hits, memorySearchHit{ID: res.Item.ID, Scope: string(res.Item.Scope), Text: res.Item.Content, Score: res.Score})
}
s.auditClaims(r, "memory.search", "ok")
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "personaId": personaID, "semantic": false, "hits": hits})
}
// adminExportSession exports a session as JSON. Without a conversation-message
// store the export carries the session identity metadata that exists today; a
// full transcript export is deferred to M10.
func (s *Server) adminExportSession(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
sess := s.findSession(r.Context(), id)
if sess == nil {
s.auditClaims(r, "session.export", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.auditClaims(r, "session.export", "ok")
writeJSON(w, http.StatusOK, map[string]any{"session": summaryOfSession(*sess)})
}
// findSession returns the session whose SessionID equals id, if known.
func (s *Server) findSession(ctx context.Context, id string) *session.Session {
if s.sessions == nil {
return nil
}
list, err := s.sessions.List(ctx)
if err != nil {
return nil
}
for i := range list {
if list[i].SessionID == id {
return &list[i]
}
}
return nil
}
+28 -1
View File
@@ -130,7 +130,34 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
msgs := userChatMessages(req.Text, req.Image)
if s.hub.orchestrator != nil {
sr, res := s.hub.orchestrator.SubmitSync(r.Context(), req.SessionID, msgs, loop.OwnerEnv())
if !sr.Accepted {
if sr.PendingFull {
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
} else {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required"))
}
return
}
// A busy session returns a non-blocking ack (design/15 §2.1) instead of
// blocking the request on a turn that may run for a while.
if sr.Pending {
writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth})
return
}
if sr.Err != nil {
s.log.Error("chat run", "err", sr.Err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
return
}
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
return
}
// Legacy synchronous path when no orchestrator is wired (a hub built without
// one keeps the exact pre-design/15 reply contract).
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, msgs)
if err != nil {
s.log.Error("chat run", "err", err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
+55 -3
View File
@@ -25,6 +25,7 @@ import (
"net"
"net/http"
"strconv"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
@@ -234,9 +235,15 @@ type Server struct {
voice VoiceService
streaming bool
frontend fs.FS
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
// llmMu guards llmExtra, the process-lifetime providers added at runtime via
// the admin "add provider" write. They are reflected in GET /admin/llm but are
// not bound to the model router (config is immutable after startup), so they
// are reported as temporary.
llmMu sync.RWMutex
llmExtra []AdminLlmProvider
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
}
// New validates cfg and deps and returns a ready Server. Auth is required; the
@@ -272,6 +279,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
log: sourcedLog(deps.Log),
}
s.hub = newHub(s)
s.hub.orchestrator = newOrchestrator(s)
s.hub.start()
return s, nil
}
@@ -315,6 +323,32 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/admin/observability/audit", adminAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
// Admin writes: owner-only, audited. Reads above accept owner/admin; writes
// are reserved for the owner (RBAC red line). Each handler persists to the
// real backing store or answers honestly with a clear "M10 / not-wired" error
// rather than fabricating a success.
ownerAuth := s.auth.Authorize(auth.RoleOwner)
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))
mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona)))
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey)))
mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey)))
mux.Handle("POST /api/v1/admin/iot/{id}/control", ownerAuth(http.HandlerFunc(s.adminIoTControl)))
mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario)))
mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule)))
mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation)))
mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation)))
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))
mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm)))
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession)))
// External webhook callbacks (e.g. the webhook channel) are mounted without
// gateway auth; each handler enforces its own signature/authorization.
for path, hdl := range s.webhooks {
@@ -374,6 +408,24 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Close frees the hub's bus subscription and closes live connections.
func (s *Server) Close() { s.hub.close() }
// newOrchestrator builds the session-message orchestrator (design/15) bound to
// this server's hub. The TurnRunner shares the hub's runTurn (so a turn always
// emits the same stream contract), and the StateSink fans per-session state
// transitions out through the hub broadcast for the frontend. It is wrapped in a
// function so the composition root can later override the injection (an explicit
// wiring point rather than a hard-coded construct).
func newOrchestrator(s *Server) *Orchestrator {
return NewOrchestrator(OrchestratorOptions{
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
return s.hub.runTurn(ctx, sessionID, messages, env)
},
Emit: func(sessionID, typ string, data map[string]any) {
s.hub.broadcast(wsEvent{Type: typ, Data: data})
},
Log: s.log,
})
}
// sourcedLog returns a logger tagged with source "gateway", substituting a
// discard logger for nil so Server can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger {
@@ -0,0 +1,443 @@
package gateway
import (
"context"
"io"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file implements the session-message orchestrator (DevDocs/design/15).
//
// It is deliberately NOT a task engine. design/04 §2 (task/subagent) decides
// whether a heavy job gets pushed to the background; this orchestrator decides
// how NEW user messages queue and resume while a session is busy. The two layers
// are orthogonal and compose: a session runs a task (04), a new message arrives
// mid-run (15), and the orchestrator holds it in a bounded pending queue and
// resumes it with the previous turn's context once the current turn finishes.
//
// Non-blocking is a hard constraint: Submit/Interrupt never wait on a running
// turn. The queue is bounded per session and isolated across sessions, and no
// identity decision is made here — the orchestrator only orders intent/messages,
// identity stays in the channel actor metadata (project red line).
// Mode is the per-session busy-state lane of the orchestrator. It is exposed so
// a loop/subagent hook can raise it to executing/subagent (an injection point
// the composition root may wire later), but the orchestrator itself tracks an
// abstract "busy" via mode != ModeIdle.
type Mode string
const (
// ModeIdle means the session has no turn in flight and no resume pending.
ModeIdle Mode = "idle"
// ModeThinking means a turn is in flight (the initial start state).
ModeThinking Mode = "thinking"
// ModeExecuting means a turn is executing tools/substeps.
ModeExecuting Mode = "executing"
// ModeSubagent means a turn is delegated to a subagent (design/04 §2).
ModeSubagent Mode = "subagent"
)
// State event types pushed over the WS channel to the frontend. The data map of
// each carries sessionId so a client can correlate per-session state.
const (
EventSessionBusy = "session_busy"
EventPendingAck = "pending_ack"
EventPendingDrain = "pending_drain"
EventSessionIdle = "session_idle"
EventSessionInterrupt = "session_interrupted"
EventPendingFull = "pending_full"
)
const (
// defaultMaxPending bounds the per-session pending queue (design/15 §4).
defaultMaxPending = 5
// defaultMaxTail bounds the conversation tail kept for continuation, so the
// resumed context is incremental, not a full replay (design/15 §2.2, §4).
defaultMaxTail = 20
// defaultSessionID is used when a caller passes an empty session id.
defaultSessionID = "web"
)
// SubmitResult is the outcome of handing one user turn to the orchestrator. It
// is always returned without blocking: Accepted is false only when the turn was
// rejected (pending queue full, or an empty turn), never because a turn is busy.
type SubmitResult struct {
// Accepted reports whether the turn was taken. A false value with
// PendingFull or Empty carries the reject reason; it never means "try later".
Accepted bool
// Immediate reports that a turn started now because the session was idle.
Immediate bool
// Pending reports that the turn was queued behind a busy turn.
Pending bool
// QueueDepth is the pending depth after this insertion (0 when Immediate).
QueueDepth int
// PendingFull reports the turn was rejected because the queue is at capacity
// (design/15 §4: bounded pending, over-capacity is refused with a hint).
PendingFull bool
// Empty reports the turn was rejected because it carried no text or image.
Empty bool
// Err is set on the synchronous path when the run errored; the REST caller
// uses it to return a 5xx rather than a fabricated reply.
Err error
}
// TurnRunner executes one agent turn and returns its result. The gateway wires
// it to the loop plus the streaming broadcast, so the orchestrator stays agnostic
// of how a turn is physically driven (it never re-implements a loop engine).
type TurnRunner func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error)
// StateSink reports a per-session state transition (busy/pending/idle/interrupt)
// to the transport. The gateway wires it to the WS hub broadcast.
type StateSink func(sessionID, typ string, data map[string]any)
// OrchestratorOptions configures an Orchestrator. Run and Log are required
// (Log may be a discard logger); Emit is optional (state events are dropped when
// nil). MaxPending/MaxTail fall back to the defaults when non-positive.
type OrchestratorOptions struct {
Run TurnRunner
Emit StateSink
Log *slog.Logger
MaxPending int
MaxTail int
}
// sessionState is the per-session message state (design/15 §2). Every mutation
// happens under the Orchestrator mutex; the actual loop run is done outside the
// lock so a slow model call never blocks Submit.
type sessionState struct {
mode Mode
gen uint64 // generation: bumped on interrupt and on a fresh top-level turn
cancel context.CancelFunc
pending []pendingMsg
tail []model.Message // bounded conversation tail for context continuation
}
// pendingMsg is one queued user turn. It carries its own env so a resume runs
// under the same memory-isolation boundary the message arrived with.
type pendingMsg struct {
messages []model.Message
env loop.SessionEnv
at time.Time
}
// Orchestrator owns the per-session message state machine. It is concurrency-safe
// and non-blocking: Submit/Interrupt lock only to mutate state, then hand the
// actual run off to a goroutine. It is the single owner of "is this session busy"
// for the chat path; the loop/task/subagent engines are reused, not re-created.
type Orchestrator struct {
mu sync.Mutex
states map[string]*sessionState
run TurnRunner
emit StateSink
log *slog.Logger
maxPending int
maxTail int
}
// NewOrchestrator builds an orchestrator from opts. Run is required: it is the
// turn-driver the orchestrator reuses (the loop, never a re-implemented engine).
// A nil Run makes every Submit reject rather than run, so a misconfiguration
// cannot silently leave a session busy. Tests inject a fake runner here.
func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
maxPending := opts.MaxPending
if maxPending <= 0 {
maxPending = defaultMaxPending
}
maxTail := opts.MaxTail
if maxTail <= 0 {
maxTail = defaultMaxTail
}
if opts.Log == nil {
opts.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &Orchestrator{
states: make(map[string]*sessionState),
run: opts.Run,
emit: opts.Emit,
log: opts.Log,
maxPending: maxPending,
maxTail: maxTail,
}
}
// Submit hands one user turn to the session. It never blocks: an idle session
// starts a turn on a background goroutine and returns Immediate; a busy session
// enqueues the turn into the bounded pending queue and returns Pending; a full
// queue refuses with PendingFull. Interrupt words are NOT auto-detected here —
// the caller (frontend/intent classifier) calls Interrupt when the user asks to
// stop, per design/15 §3 (interrupt is an intent, not an identity decision).
func (o *Orchestrator) Submit(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) SubmitResult {
sr, _ := o.submitInternal(ctx, o.normalize(sessionID), messages, env, false)
return sr
}
// SubmitSync is the synchronous variant used by the REST non-streaming path. An
// idle session runs the turn to completion and returns the Result; a busy session
// returns a Pending result without blocking so the caller can reply with a
// non-blocking ack (design/15 §2.1). It never waits on an in-flight turn.
func (o *Orchestrator) SubmitSync(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (SubmitResult, *loop.Result) {
return o.submitInternal(ctx, o.normalize(sessionID), messages, env, true)
}
// submitInternal is the shared Submit/SubmitSync body. sync selects the
// synchronous turn path (REST) vs the background goroutine path (WS).
func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv, sync bool) (SubmitResult, *loop.Result) {
if o.run == nil {
o.log.Error("orchestrator submit", "err", "no runner configured", "session", sessionID)
return SubmitResult{Accepted: false}, nil
}
if o.messagesEmpty(messages) {
return SubmitResult{Empty: true}, nil
}
o.mu.Lock()
st := o.states[sessionID]
if st == nil {
st = &sessionState{mode: ModeIdle}
o.states[sessionID] = st
}
if st.mode != ModeIdle {
if len(st.pending) >= o.maxPending {
mode := st.mode
o.mu.Unlock()
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": mode, "max": o.maxPending})
return SubmitResult{PendingFull: true}, nil
}
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, at: time.Now()})
depth := len(st.pending)
mode := st.mode
o.mu.Unlock()
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth}, nil
}
st.gen++
gen := st.gen
st.mode = ModeThinking
st.tail = o.boundedTail(messages)
runCtx, cancel := context.WithCancel(ctx)
st.cancel = cancel
o.mu.Unlock()
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
if sync {
res, err := o.run(runCtx, sessionID, messages, env)
if err != nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err)
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
}
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel)
return SubmitResult{Accepted: true, Immediate: true}, nil
}
// goTurn runs a single turn on a background goroutine and, when it finishes,
// hands the outcome to completeTurn so the orchestrator can resume the queue.
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc) {
defer cancel()
res, err := o.run(ctx, sessionID, messages, env)
if err != nil && ctx.Err() == nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err)
}
// completeTurn runs after a turn settles (either it finished, errored, or was
// canceled). It folds the assistant reply into the session's continuation tail,
// then pops the next pending turn and resumes it carrying that tail — so the next
// message "remembers" the prior context instead of starting from a blank slate
// (design/15 §2.2: incremental, not full replay). A stale generation (an
// interrupt or a newer top-level turn superseded this one) is a no-op.
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error) error {
o.mu.Lock()
st := o.states[sessionID]
if st == nil || st.gen != gen {
o.mu.Unlock()
return nil
}
if res != nil && res.Text != "" {
st.tail = o.boundedTail(concatMessages(st.tail, model.TextMessage(model.RoleAssistant, res.Text)))
}
if len(st.pending) > 0 {
next := st.pending[0]
st.pending = st.pending[1:]
st.tail = o.boundedTail(concatMessages(st.tail, next.messages...))
st.mode = ModeThinking
st.gen++
gen = st.gen
runCtx, cancel := context.WithCancel(context.Background())
st.cancel = cancel
depth := len(st.pending)
resume := append([]model.Message(nil), st.tail...)
env := next.env
o.mu.Unlock()
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth})
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel)
return nil
}
st.mode = ModeIdle
st.cancel = nil
o.mu.Unlock()
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": ModeIdle})
return nil
}
// Interrupt cancels the in-flight turn, clears the pending queue and returns the
// session to idle (design/15 §3, the "停下/算了/换个话题" path). It is the only
// way a busy session resets without draining the queue; the running goroutine's
// completion becomes a stale generation and does not resume anything. It returns
// the number of pending turns that were dropped.
func (o *Orchestrator) Interrupt(sessionID string) int {
return o.interrupt(o.normalize(sessionID))
}
// InterruptAll cancels every in-flight session and clears all pending queues. It
// is used on gateway shutdown so no background turn outlives the hub. It returns
// the total number of dropped pending turns.
func (o *Orchestrator) InterruptAll() int {
o.mu.Lock()
total := 0
for id, st := range o.states {
total += o.interruptLocked(id, st)
}
o.mu.Unlock()
return total
}
func (o *Orchestrator) interrupt(sessionID string) int {
o.mu.Lock()
defer o.mu.Unlock()
return o.interruptLocked(sessionID, o.states[sessionID])
}
func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState) int {
if st == nil || st.mode == ModeIdle {
return 0
}
if st.cancel != nil {
st.cancel()
}
dropped := len(st.pending)
st.pending = nil
st.gen++
st.mode = ModeIdle
st.cancel = nil
o.emitState(sessionID, EventSessionInterrupt, map[string]any{"sessionId": sessionID, "mode": ModeIdle, "dropped": dropped})
return dropped
}
// SetMode raises the reported busy-state lane of a session (thinking →
// executing → subagent). It is an injection point a loop/subagent hook can call;
// any non-idle mode keeps the session busy, and idle un-busies it. A nil session
// is created lazily so a hook may set a mode before the first message.
func (o *Orchestrator) SetMode(sessionID string, mode Mode) {
sessionID = o.normalize(sessionID)
o.mu.Lock()
st := o.states[sessionID]
if st == nil {
st = &sessionState{mode: mode}
o.states[sessionID] = st
} else {
st.mode = mode
}
o.mu.Unlock()
if mode == ModeIdle {
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": mode})
return
}
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": mode})
}
// Busy reports whether the session currently has a turn in flight.
func (o *Orchestrator) Busy(sessionID string) bool {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
return st != nil && st.mode != ModeIdle
}
// QueueDepth reports the number of pending turns for a session.
func (o *Orchestrator) QueueDepth(sessionID string) int {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
if st == nil {
return 0
}
return len(st.pending)
}
// ModeOf reports the current busy-state lane of a session (idle when unknown).
func (o *Orchestrator) ModeOf(sessionID string) Mode {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
if st == nil {
return ModeIdle
}
return st.mode
}
// normalize maps an empty session id to the default so the state map is stable
// for callers that omit it (the Web main session).
func (o *Orchestrator) normalize(sessionID string) string {
if sessionID == "" {
return defaultSessionID
}
return sessionID
}
// emitState forwards a state transition to the transport when one is wired.
func (o *Orchestrator) emitState(sessionID, typ string, data map[string]any) {
if o.emit != nil {
o.emit(sessionID, typ, data)
}
}
// boundedTail returns a fresh copy of the last maxTail messages, so continuation
// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input
// slice or another state's tail.
func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message {
if len(msgs) <= o.maxTail {
return append([]model.Message(nil), msgs...)
}
out := make([]model.Message, o.maxTail)
copy(out, msgs[len(msgs)-o.maxTail:])
return out
}
// messagesEmpty reports whether a turn carries no usable text or image, so the
// orchestrator does not mark a session busy on a vacuous message.
func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool {
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text != "" {
return false
}
if c.Type == model.ContentTypeImage && c.ImageURL != "" {
return false
}
}
}
return true
}
// concatMessages returns a new slice combining a and b (never reusing a or b
// backing arrays) so the continuation tail is safe to hand to a concurrent run.
func concatMessages(a []model.Message, b ...model.Message) []model.Message {
out := make([]model.Message, 0, len(a)+len(b))
out = append(out, a...)
out = append(out, b...)
return out
}
@@ -0,0 +1,480 @@
package gateway
import (
"context"
"fmt"
"log/slog"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// callRecord is one invocation captured by the fake runner.
type callRecord struct {
sessionID string
messages []model.Message
env loop.SessionEnv
ctx context.Context
}
// gatedRunner is a controllable TurnRunner. Each Run blocks until it consumes one
// ticket from tickets, or until ctx is canceled (the interrupt path). When it
// proceeds it records the call and returns a deterministic reply. A nil err
// produces a reply holding the *incoming* messages as text, so a test can assert
// exactly what context the resumed turn carried.
type gatedRunner struct {
mu sync.Mutex
calls []callRecord
tickets chan struct{}
err error
}
func newGatedRunner(cap int) *gatedRunner {
return &gatedRunner{tickets: make(chan struct{}, cap)}
}
func (g *gatedRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
select {
case <-g.tickets:
case <-ctx.Done():
return nil, ctx.Err()
}
g.mu.Lock()
n := len(g.calls) + 1
g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx})
g.mu.Unlock()
if g.err != nil {
return nil, g.err
}
reply := n + len(messages)
return &loop.Result{Text: fmt.Sprintf("reply-%d", reply), Steps: 1, StopReason: "stop"}, nil
}
// waitCalls blocks until at least n calls are recorded and returns a copy of the
// first n, so a test can advance the gated chain deterministically.
func (g *gatedRunner) waitCalls(n int) []callRecord {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
g.mu.Lock()
if len(g.calls) >= n {
out := append([]callRecord(nil), g.calls[:n]...)
g.mu.Unlock()
return out
}
g.mu.Unlock()
time.Sleep(time.Millisecond)
}
panic("timed out waiting for runner calls")
}
// count returns the total recorded calls.
func (g *gatedRunner) count() int {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.calls)
}
// eventRec is one state transition captured by the recording sink.
type eventRec struct {
sessionID, typ string
data map[string]any
}
// recordingSink captures every emitted state transition.
type recordingSink struct {
mu sync.Mutex
events []eventRec
}
func (s *recordingSink) Emit(sessionID, typ string, data map[string]any) {
s.mu.Lock()
s.events = append(s.events, eventRec{sessionID: sessionID, typ: typ, data: data})
s.mu.Unlock()
}
func (s *recordingSink) types() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.events))
for _, e := range s.events {
out = append(out, e.typ)
}
return out
}
func (s *recordingSink) last() (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.events) == 0 {
return "", false
}
return s.events[len(s.events)-1].typ, true
}
// userMsg builds a text-only user message.
func userMsg(text string) model.Message {
return model.TextMessage(model.RoleUser, text)
}
// messageText returns the concatenated text of a message's text parts.
func messageText(m model.Message) string {
var out string
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out += c.Text
}
}
return out
}
// seedOrchestrator builds an orchestrator with a gated runner and a recording
// sink, both returned so the test can drive the chain and assert state events.
func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordingSink) {
sink := &recordingSink{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
Log: slog.Default(),
MaxPending: maxPending,
MaxTail: 20,
})
return o, sink
}
// readOnlyEnv returns the group-chat / stranger read-only memory lane.
func readOnlyEnv() loop.SessionEnv {
return loop.SessionEnv{MemWritable: false}
}
func TestSubmitIdleStartsImmediate(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, _ := seedOrchestrator(run, 5)
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
run.tickets <- struct{}{}
got := run.waitCalls(1)
if len(got) != 1 {
t.Fatalf("calls = %d, want 1", len(got))
}
if o.Busy("s1") {
t.Fatal("session should be idle after a completed turn")
}
}
func TestSubmitBusyPendingNotDropped(t *testing.T) {
t.Parallel()
run := newGatedRunner(4)
o, sink := seedOrchestrator(run, 5)
// Turn A starts and blocks (no ticket yet), so the session is busy.
a := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
if !a.Immediate {
t.Fatalf("A = %+v, want Immediate", a)
}
// B and C arrive while A is in flight: they must be queued, not dropped.
b := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv())
c := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv())
if !b.Accepted || !b.Pending || b.QueueDepth != 1 {
t.Fatalf("B = %+v, want Pending depth=1", b)
}
if !c.Accepted || !c.Pending || c.QueueDepth != 2 {
t.Fatalf("C = %+v, want Pending depth=2", c)
}
if o.QueueDepth("s1") != 2 {
t.Fatalf("queue depth = %d, want 2", o.QueueDepth("s1"))
}
// Release the chain: A, then B, then C. The emitted ack should include the
// pending depth, and the sink should have seen pending_ack events.
types := sink.types()
foundAck := false
for _, ty := range types {
if ty == EventPendingAck {
foundAck = true
}
}
if !foundAck {
t.Fatalf("no pending_ack in events: %v", types)
}
run.tickets <- struct{}{}
run.tickets <- struct{}{}
run.tickets <- struct{}{}
if n := len(run.waitCalls(3)); n != 3 {
t.Fatalf("total calls = %d, want 3 (A+B+C)", n)
}
if o.QueueDepth("s1") != 0 || o.Busy("s1") {
t.Fatalf("session should be idle and empty after drain: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
}
func TestSubmitBusyResumeCarriesPreviousContext(t *testing.T) {
t.Parallel()
run := newGatedRunner(3)
o, _ := seedOrchestrator(run, 5)
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv()); !sr.Pending {
t.Fatalf("B = %+v, want Pending", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("C = %+v, want Pending", sr)
}
run.tickets <- struct{}{} // A
run.tickets <- struct{}{} // B
run.tickets <- struct{}{} // C
got := run.waitCalls(3)
// A starts from a blank turn.
if len(got[0].messages) != 1 || messageText(got[0].messages[0]) != "A" {
t.Fatalf("A messages = %v, want [A]", messageTextList(got[0].messages))
}
// B must resume carrying A's assistant reply folded into the tail.
if !matchesText(got[1].messages, "A", "B") {
t.Fatalf("B messages = %v, want B to resume carrying the prior tail", messageTextList(got[1].messages))
}
// C must resume carrying A's and B's assistant replies.
if !matchesText(got[2].messages, "A", "C") {
t.Fatalf("C messages = %v, want C to resume carrying the prior tail", messageTextList(got[2].messages))
}
// B's env must be the one the message arrived with (read-only lane).
if got[1].env.MemWritable {
t.Fatal("B resumed with MemWritable=true, want the caller's read-only env")
}
}
// matchesText reports whether a resumed turn begins with the captured first user
// turn (so the prior context was carried over), ends with the expected new user
// turn, and contains at least one assistant reply in between.
func matchesText(msgs []model.Message, first, last string) bool {
if len(msgs) < 3 {
return false
}
if messageText(msgs[0]) != first {
return false
}
if messageText(msgs[len(msgs)-1]) != last {
return false
}
for i := 1; i < len(msgs)-1; i++ {
if msgs[i].Role == model.RoleAssistant {
return true
}
}
return false
}
func messageTextList(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, messageText(m))
}
return out
}
func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, _ := seedOrchestrator(run, 3) // tight queue
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
for i := 0; i < 3; i++ {
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("p%d", i))}, loop.OwnerEnv())
if !sr.Pending || sr.QueueDepth != i+1 {
t.Fatalf("pending %d = %+v, want Pending depth=%d", i, sr, i+1)
}
}
// Queue is at capacity (3), so the next message is refused, not dropped.
over := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv())
if over.Accepted || !over.PendingFull {
t.Fatalf("overflow = %+v, want PendingFull reject", over)
}
if o.QueueDepth("s1") != 3 {
t.Fatalf("queue depth = %d, want 3 (unchanged)", o.QueueDepth("s1"))
}
}
func TestInterruptCancelsTurnAndClearsPending(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("B = %+v, want Pending", sr)
}
// Release no ticket: A is blocked in flight. Interrupt cancels it and clears B.
dropped := o.Interrupt("s1")
if dropped != 1 {
t.Fatalf("dropped = %d, want 1", dropped)
}
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
t.Fatalf("session not idle after interrupt: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
// A new message after the interrupt must start fresh (Immediate), and the
// canceled chain must not have resumed B.
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("C = %+v, want Immediate after interrupt", sr)
}
run.tickets <- struct{}{}
got := run.waitCalls(1)
if len(got) != 1 {
t.Fatalf("calls = %d, want 1 (only C, not the canceled A or B)", len(got))
}
if messageText(got[0].messages[0]) != "C" {
t.Fatalf("only surviving call = %v, want the C turn", messageTextList(got[0].messages))
}
}
func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, _ := seedOrchestrator(run, 3)
const senders = 8
var wg sync.WaitGroup
accepted := make(chan SubmitResult, senders)
for i := 0; i < senders; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
accepted <- o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("m%d", i))}, loop.OwnerEnv())
}(i)
}
wg.Wait()
close(accepted)
immediate, pending, full := 0, 0, 0
for sr := range accepted {
switch {
case sr.Immediate:
immediate++
case sr.Pending:
pending++
case sr.PendingFull:
full++
default:
t.Fatalf("unexpected submit result: %+v", sr)
}
}
// With maxPending=3: exactly 1 turn runs immediately and up to 3 queue behind
// it; the remaining senders are refused at capacity rather than dropped.
if immediate != 1 {
t.Fatalf("immediate = %d, want 1", immediate)
}
if pending != 3 {
t.Fatalf("pending = %d, want 3", pending)
}
if full != senders-4 {
t.Fatalf("pendingFull = %d, want %d", full, senders-4)
}
if o.QueueDepth("s1") != 3 {
t.Fatalf("queue depth = %d, want 3", o.QueueDepth("s1"))
}
// Drain the chain: 1 running + 3 queued = 4 turns. No panic / no lost message.
for i := 0; i < 4; i++ {
run.tickets <- struct{}{}
}
if n := len(run.waitCalls(4)); n != 4 {
t.Fatalf("total calls = %d, want 4", n)
}
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
t.Fatalf("session should be idle and empty after drain")
}
}
func TestSubmitEmptyRejected(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, _ := seedOrchestrator(run, 5)
sr := o.Submit(context.Background(), "s1", nil, loop.OwnerEnv())
if sr.Accepted || !sr.Empty {
t.Fatalf("empty submit = %+v, want Empty reject", sr)
}
if run.count() != 0 {
t.Fatalf("runner invoked for an empty turn")
}
}
func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
// Idle → synchronous run returns the reply immediately. Push a ticket so the
// gated runner's blocking select resolves during the synchronous wait.
run.tickets <- struct{}{}
sr, res := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("sync idle = %+v, want Accepted+Immediate", sr)
}
if res == nil {
t.Fatal("sync idle should return a result")
}
// Hold a busy turn in flight via an async Submit (blocked, no ticket), then a
// sync submit must return a Pending ack without blocking and without a result.
if sr2 := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr2.Immediate {
t.Fatalf("async A = %+v, want Immediate", sr2)
}
sr3, res3 := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
if !sr3.Accepted || !sr3.Pending {
t.Fatalf("sync busy = %+v, want Pending", sr3)
}
if res3 != nil {
t.Fatal("sync busy should not return a result (must not block)")
}
run.tickets <- struct{}{} // let A finish so the orchestrator is idle again
}
func TestInterruptAllClearsEverySession(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
o.Submit(context.Background(), "s2", []model.Message{userMsg("X")}, loop.OwnerEnv())
if got := o.InterruptAll(); got != 1 {
t.Fatalf("InterruptAll dropped = %d, want 1 (B)", got)
}
if o.Busy("s1") || o.Busy("s2") {
t.Fatal("all sessions should be idle after InterruptAll")
}
}
func TestSetModeBusyAndIdle(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, sink := seedOrchestrator(run, 5)
o.SetMode("s1", ModeExecuting)
if !o.Busy("s1") || o.ModeOf("s1") != ModeExecuting {
t.Fatalf("after executing: busy=%v mode=%q", o.Busy("s1"), o.ModeOf("s1"))
}
o.SetMode("s1", ModeIdle)
if o.Busy("s1") {
t.Fatal("session must be idle after SetMode(idle)")
}
if ty, ok := sink.last(); !ok || ty != EventSessionIdle {
t.Fatalf("last event = %q, want session_idle", ty)
}
}
+63 -25
View File
@@ -74,14 +74,15 @@ type hub struct {
runner Chatter
auditor SubagentAuditor
taskOutputs TaskOutputStore
voice VoiceService
streaming bool
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
voice VoiceService
streaming bool
orchestrator *Orchestrator
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
}
// newHub builds a hub bound to the server's dependencies.
@@ -124,11 +125,15 @@ func (h *hub) start() {
}
}
// close cancels the bus subscription and shuts every live connection down.
// close cancels the bus subscription, interrupts every orchestrator session (so
// no background turn outlives the hub), and shuts every live connection down.
func (h *hub) close() {
if h.cancel != nil {
h.cancel()
}
if h.orchestrator != nil {
h.orchestrator.InterruptAll()
}
h.mu.Lock()
conns := make([]*wsConn, 0, len(h.conns))
for c := range h.conns {
@@ -205,20 +210,19 @@ func (h *hub) relay(ctx context.Context, e bus.Event) error {
return nil
}
// streamChat runs one agent turn for a chat trigger and broadcasts the turn
// terminus (stream_end) with a fresh turnId; on the non-streaming fallback path
// it also emits a single stream_chunk. When the loop is streaming, the per-event
// deltas arrive via the OnStream→bus→relay path as structured message.*/
// thinking.*/tool_call.* events. It runs on its own goroutine so a slow model
// streamChat is the ws.chat trigger entry point. It decodes the request, and
// routes the turn through the session orchestrator when one is wired (the
// non-blocking busy/pending/resume path of design/15); otherwise it falls back to
// the legacy direct run so a hub built without an orchestrator keeps the exact
// stream_chunk/stream_end contract. It runs on its own goroutine so a slow model
// call does not stall the bus subscription worker.
func (h *hub) streamChat(ctx context.Context, payload any) {
var req chatStreamRequest
if b, err := json.Marshal(payload); err == nil {
_ = json.Unmarshal(b, &req)
}
turnID := newTurnID()
if h.runner == nil || (strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "") {
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
return
}
if req.SessionID == "" {
@@ -226,21 +230,55 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
}
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
if err != nil {
h.log.Error("ws chat run", "err", err, "session", req.SessionID)
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})
msgs := userChatMessages(req.Text, req.Image)
if h.orchestrator != nil {
_ = h.orchestrator.Submit(context.Background(), req.SessionID, msgs, loop.OwnerEnv())
return
}
// When the loop is streaming (OnStream wired), the structured deltas already
// fan out as message.*/thinking.*/tool_call.* events through the
// OnStream→bus→relay path; a second whole-text chunk would duplicate the
// tail. The non-streaming path (or a stub runner) still emits one chunk so
// the stream contract holds in tests.
_, _ = h.runTurn(ctx, req.SessionID, msgs, loop.OwnerEnv())
}
// runTurn executes one agent turn and broadcasts its output stream. It is the
// unit both the orchestrator (via its injected TurnRunner) and the legacy
// fallback path share, so a turn always emits the same stream_chunk/stream_end
// events regardless of who drives it. When the loop is streaming (OnStream wired)
// the structured deltas already fan out as message.*/thinking.*/tool_call.*
// events through the OnStream→bus→relay path, so only the non-streaming path
// emits a single whole-text chunk to hold the stream contract.
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
turnID := newTurnID()
if h.runner == nil || len(msgs) == 0 {
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
return &loop.Result{StopReason: "idle"}, nil
}
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs)
if err != nil {
h.log.Error("chat run", "err", err, "session", sessionID)
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})
return nil, err
}
if !h.streaming {
h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"delta": res.Text}})
}
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
return res, nil
}
// handleInterrupt routes a client "interrupt" frame (design/15 §3) to the
// orchestrator, canceling the in-flight turn and clearing the pending queue for
// the requested session. It is a no-op when no orchestrator is wired.
func (h *hub) handleInterrupt(payload json.RawMessage) {
if h.orchestrator == nil {
return
}
var req struct {
SessionID string `json:"sessionId"`
}
_ = json.Unmarshal(payload, &req)
if req.SessionID == "" {
return
}
h.orchestrator.Interrupt(req.SessionID)
}
// broadcastStream maps a streamed model.StreamEvent to its downstream WS event
+4
View File
@@ -14,6 +14,10 @@ func (c *wsConn) route(in wsInbound) {
c.publishPublic("ws.mode_switch", rawData(in.Data))
case "chat":
c.publishPublic(wsChatSubject, rawData(in.Data))
case "interrupt":
// design/15 §3: user says "停下/算了" → cancel the in-flight turn and
// clear the session's pending queue, so the next message starts fresh.
c.h.handleInterrupt(in.Data)
case "voice_input":
// Voice routes through the injected STT→chat→TTS pipeline. Without a
// voice service it replies with a clear error rather than dropping the