【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。
159 lines
5.5 KiB
Go
159 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|