两处 review 必修(review-m57 r1): P2-1 跨批覆盖:applyBatchVoice 原先无条件用后一批的语音结论覆盖 前一批,于是「先成功合成 abc.wav、下一批换个参数再试却失败」会把 Result.Voice 退化成 tts_failed —— 音频已落盘,退回文本既无收益也 违背那次表达。改为守 captureVoice 的同一条优先级:已落盘的音频不被 后续批次的失败抹掉;先降级后成功仍然覆盖(后到的成功即最终语音)。 P2-2 nil-dispatcher 安全门:speak 由循环自己承载、无 dispatcher 也 能执行(invokeOne 短路),但 applyToolAdmissions 在 dispatcher 缺席 时整段跳过安全门,使「没配 dispatcher」成为绕过 design/11 §3 的 旁路。新增 applyDispatcherlessVoiceGate:与策略引擎对未登记工具的 口径一致(默认关闭、只有 owner 放行),拒绝走同一条 not_allowed 软结果与 tool.reject.not_allowed 审计通道;并改正该处过时注释。 两条回归测试都做过改前红/改后绿: - TestApplyBatchVoice_KeepsCrossBatchSuccess:注释掉守卫即报 「voice = ... FallbackReason:tts_failed, want abc.wav」 - TestApplyToolAdmissions_DispatcherlessSpeakGate:去掉 else 分支即报 「env group_member run = true, want false」 验收:go build ./... / go vet ./... / go test ./... 全绿。
316 lines
12 KiB
Go
316 lines
12 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
|
)
|
|
|
|
// newPolicyDispatcher 构建全新的 registry + dispatcher,并为每个
|
|
// 名字注册一个 mock 工具,两者都返回,使测试能在演练
|
|
// design/11 §3 安全门之前在 registry 上设置群聊/陌生人标记。
|
|
func newPolicyDispatcher(t *testing.T, names []string) (*registry.Registry, *tool.Dispatcher) {
|
|
t.Helper()
|
|
reg := registry.New()
|
|
d, err := tool.New(reg)
|
|
if err != nil {
|
|
t.Fatalf("tool.New: %v", err)
|
|
}
|
|
for _, name := range names {
|
|
if err := d.Register(&mockTool{name: name}); err != nil {
|
|
t.Fatalf("Register %q: %v", name, err)
|
|
}
|
|
}
|
|
return reg, d
|
|
}
|
|
|
|
// expectToolNames 断言 got 恰好携带 want 中的工具名,顺序不限。
|
|
// registry 的 List 遍历 map,因此注册顺序不保留;
|
|
// 集合比较使这些断言与顺序无关。
|
|
func expectToolNames(t *testing.T, got []model.ToolSpec, want ...string) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("tool schema count = %d, want %d (%v)", len(got), len(want), want)
|
|
}
|
|
wantSet := make(map[string]bool, len(want))
|
|
for _, n := range want {
|
|
wantSet[n] = true
|
|
}
|
|
for _, s := range got {
|
|
if !wantSet[s.Name] {
|
|
t.Fatalf("unexpected tool %q in schemas %v", s.Name, toolNames(got))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestToolSchemas_RestrictedEnv_FiltersByMarkers 证明群聊/陌生人会话
|
|
// 只被展示其可编辑标记允许该环境的工具,而
|
|
// owner 会话保留全集(design/11 §3)。
|
|
func TestToolSchemas_RestrictedEnv_FiltersByMarkers(t *testing.T) {
|
|
t.Parallel()
|
|
reg, d := newPolicyDispatcher(t, []string{"groupOnly", "strangerOnly", "both", "ownerOnly"})
|
|
mustSetPolicy(t, reg, "groupOnly", registry.ToolPolicy{AllowInGroup: true})
|
|
mustSetPolicy(t, reg, "strangerOnly", registry.ToolPolicy{AllowInStranger: true})
|
|
mustSetPolicy(t, reg, "both", registry.ToolPolicy{AllowInGroup: true, AllowInStranger: true})
|
|
// ownerOnly 保持默认关闭(零标记)。
|
|
|
|
l := New(Options{Tools: d})
|
|
|
|
group := l.toolSchemas(WithSessionEnv(context.Background(),
|
|
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}), nil)
|
|
expectToolNames(t, group, "groupOnly", "both")
|
|
|
|
stranger := l.toolSchemas(WithSessionEnv(context.Background(),
|
|
SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}), nil)
|
|
expectToolNames(t, stranger, "strangerOnly", "both")
|
|
|
|
owner := l.toolSchemas(WithSessionEnv(context.Background(), OwnerEnv()), nil)
|
|
expectToolNames(t, owner, "groupOnly", "strangerOnly", "both", "ownerOnly")
|
|
}
|
|
|
|
// TestToolSchemas_DefaultClosed_UnmarkedToolHidden 证明默认关闭
|
|
// 标记:未标记的工具不向群聊/陌生人会话广告,因此
|
|
// 会话连看都看不到(更别说调用)主人专属能力。
|
|
func TestToolSchemas_DefaultClosed_UnmarkedToolHidden(t *testing.T) {
|
|
t.Parallel()
|
|
reg, d := newPolicyDispatcher(t, []string{"secret", "shared"})
|
|
// 只有 shared 对群聊开放;secret 默认关闭。
|
|
mustSetPolicy(t, reg, "shared", registry.ToolPolicy{AllowInGroup: true})
|
|
l := New(Options{Tools: d})
|
|
|
|
ctx := WithSessionEnv(context.Background(),
|
|
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
|
expectToolNames(t, l.toolSchemas(ctx, nil), "shared")
|
|
}
|
|
|
|
// TestRun_Restricted_NotAllowed_BlockedAndAudited 证明群聊会话调用
|
|
// 主人专属工具绝不执行它:该调用作为软 not_allowed
|
|
// 结果回灌(不 panic/崩溃),并为该拒绝记录一个
|
|
// AuditEvent。
|
|
func TestRun_Restricted_NotAllowed_BlockedAndAudited(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"ownerOnly","args":{}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "done"}},
|
|
}}
|
|
reg, d := newPolicyDispatcher(t, []string{"ownerOnly", "shared"})
|
|
mustSetPolicy(t, reg, "shared", registry.ToolPolicy{AllowInGroup: true})
|
|
|
|
a := newTestAgent(t, ad, d)
|
|
var mu sync.Mutex
|
|
var audited []AuditEvent
|
|
l := New(Options{Agent: a, MaxRounds: 8, OnAudit: func(ev AuditEvent) {
|
|
mu.Lock()
|
|
audited = append(audited, ev)
|
|
mu.Unlock()
|
|
}})
|
|
|
|
ctx := WithSessionEnv(context.Background(),
|
|
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
|
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Text != "done" {
|
|
t.Fatalf("Text = %q, want done", res.Text)
|
|
}
|
|
if res.ToolCallsExecuted != 0 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 0 (ownerOnly blocked in group)", res.ToolCallsExecuted)
|
|
}
|
|
|
|
last := ad.reqs[len(ad.reqs)-1].Messages
|
|
lastMsg := last[len(last)-1]
|
|
if !strings.Contains(lastMsg.Content[0].Text, "not_allowed") ||
|
|
!strings.Contains(lastMsg.Content[0].Text, "ownerOnly") {
|
|
t.Fatalf("expected not_allowed soft result, got %q", lastMsg.Content[0].Text)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(audited) != 1 {
|
|
t.Fatalf("audit events = %d, want 1", len(audited))
|
|
}
|
|
if audited[0].What != auditToolRejectNotAllowed {
|
|
t.Fatalf("audit what = %q, want %q", audited[0].What, auditToolRejectNotAllowed)
|
|
}
|
|
if audited[0].Who != string(restrict.ActorGroupMember) {
|
|
t.Fatalf("audit who = %q, want %q", audited[0].Who, restrict.ActorGroupMember)
|
|
}
|
|
if !strings.Contains(audited[0].Result, "ownerOnly") {
|
|
t.Fatalf("audit result = %q, want to name ownerOnly", audited[0].Result)
|
|
}
|
|
}
|
|
|
|
// TestRun_Restricted_AllowedTool_Executes 证明群聊会话可以调用
|
|
// 设置了 AllowInGroup 标记的工具,且它正常运行。
|
|
func TestRun_Restricted_AllowedTool_Executes(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"shared","args":{"q":"hi"}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "done"}},
|
|
}}
|
|
reg, d := newPolicyDispatcher(t, []string{"shared", "ownerOnly"})
|
|
mustSetPolicy(t, reg, "shared", registry.ToolPolicy{AllowInGroup: true})
|
|
|
|
a := newTestAgent(t, ad, d)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
ctx := WithSessionEnv(context.Background(),
|
|
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
|
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.ToolCallsExecuted != 1 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 1 (shared allowed in group)", res.ToolCallsExecuted)
|
|
}
|
|
if res.Text != "done" {
|
|
t.Fatalf("Text = %q, want done", res.Text)
|
|
}
|
|
}
|
|
|
|
// TestRun_Owner_AllToolsExecute 证明 owner 环境不受
|
|
// 默认关闭标记影响:主人可以调用主人专属(未标记)工具。
|
|
func TestRun_Owner_AllToolsExecute(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"ownerOnly","args":{}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "done"}},
|
|
}}
|
|
_, d := newPolicyDispatcher(t, []string{"ownerOnly"})
|
|
// 默认关闭标记(从未开放):主人仍必须能调用它。
|
|
a := newTestAgent(t, ad, d)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
res, err := l.Run(WithSessionEnv(context.Background(), OwnerEnv()), "sess",
|
|
[]model.Message{model.TextMessage(model.RoleUser, "go")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.ToolCallsExecuted != 1 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 1 (owner unaffected by default-closed)", res.ToolCallsExecuted)
|
|
}
|
|
if res.Text != "done" {
|
|
t.Fatalf("Text = %q, want done", res.Text)
|
|
}
|
|
}
|
|
|
|
// TestRun_Stranger_NotAllowed_Blocked 证明该门同样适用于
|
|
// 陌生人私聊环境,且仅群聊的工具在那里被拒。
|
|
func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"groupOnly","args":{}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "done"}},
|
|
}}
|
|
reg, d := newPolicyDispatcher(t, []string{"groupOnly", "strangerOk"})
|
|
mustSetPolicy(t, reg, "groupOnly", registry.ToolPolicy{AllowInGroup: true})
|
|
mustSetPolicy(t, reg, "strangerOk", registry.ToolPolicy{AllowInStranger: true})
|
|
|
|
a := newTestAgent(t, ad, d)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
ctx := WithSessionEnv(context.Background(),
|
|
SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false})
|
|
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.ToolCallsExecuted != 0 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 0 (groupOnly denied to stranger)", res.ToolCallsExecuted)
|
|
}
|
|
last := ad.reqs[len(ad.reqs)-1].Messages
|
|
if !strings.Contains(last[len(last)-1].Content[0].Text, "not_allowed") {
|
|
t.Fatalf("expected not_allowed soft result, got %q", last[len(last)-1].Content[0].Text)
|
|
}
|
|
}
|
|
|
|
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
|
|
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
|
|
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
|
|
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
|
|
// 主人环境照常放行。
|
|
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
|
|
t.Parallel()
|
|
var mu sync.Mutex
|
|
var audited []AuditEvent
|
|
l := New(Options{
|
|
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
|
|
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
|
|
OnAudit: func(ev AuditEvent) {
|
|
mu.Lock()
|
|
audited = append(audited, ev)
|
|
mu.Unlock()
|
|
},
|
|
})
|
|
|
|
cases := []struct {
|
|
env restrict.ActorEnv
|
|
allow bool
|
|
}{
|
|
{restrict.ActorGroupMember, false},
|
|
{restrict.ActorStranger, false},
|
|
{restrict.ActorOwner, true},
|
|
}
|
|
for _, tc := range cases {
|
|
mu.Lock()
|
|
audited = nil
|
|
mu.Unlock()
|
|
|
|
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
|
|
actions := []toolCallAction{{run: true}}
|
|
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
|
|
|
|
if actions[0].run != tc.allow {
|
|
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
|
|
}
|
|
mu.Lock()
|
|
got := append([]AuditEvent(nil), audited...)
|
|
mu.Unlock()
|
|
if tc.allow {
|
|
if len(got) != 0 {
|
|
t.Fatalf("env %s audited %+v, want none", tc.env, got)
|
|
}
|
|
continue
|
|
}
|
|
if !strings.Contains(actions[0].reason, "not_allowed") {
|
|
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
|
|
}
|
|
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
|
|
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
|
|
}
|
|
}
|
|
|
|
// speak 未接线时没有可执行的旁路,门不该动任何调用。
|
|
actions := []toolCallAction{{run: true}}
|
|
New(Options{}).applyToolAdmissions(
|
|
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
|
|
nil,
|
|
[]agent.ToolCall{{Name: speakToolName}},
|
|
actions,
|
|
)
|
|
if !actions[0].run {
|
|
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
|
|
}
|
|
}
|
|
|
|
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
|
|
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
|
|
t.Helper()
|
|
if err := reg.SetPolicy(name, pol); err != nil {
|
|
t.Fatalf("SetPolicy %q: %v", name, err)
|
|
}
|
|
}
|