feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。 【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。 【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。 【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。 【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。 【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。 实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
This commit is contained in:
1 parent
e0a543cc37
commit
6dbb4a2e18
51 files changed
+5395
-287
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user