- 原缺陷:流式路径经 loop.toolSchemas(capacity 允许集 + restrict)过滤,而 agent 静音/内部路径
(RunStep,被 services/automations 等使用)广告 dispatcher **全量** ⇒ 两条路径不一致
- 修法(方案 1):**过滤实现只留一份**(loop.toolSchemas);l.agentStep 调用同一个函数,结果经
agent.WithToolSurface(ctx, …) 下传,agent 的 buildRequest 原样广告(取不到才回退全量,保持既有回退语义)
· 按构造相同:同一次函数调用、同一份输入 ⇒ 不存在第二份可漂移的实现
· 未选「抽共享函数两侧各调一次」:loop 的过滤输入含 loop 私有会话环境 ctx key 与 capacity 判定器,
agent 侧凑齐等于再造一套 ctx 管线,漂移面更大
- 复核:agent/loop 包测试绿;新增 parity 用例(owner 与 group 两组,静音 == 流式);
变异(撤掉下传行)→ parity 必红且精确复现「静音=全量 4 个」,已还原
- 附注:全仓仅剩 `services/automations` 的「无 Loop 回退」仍广告全量(没有循环就没有闸可言);
子代理 ToolWhitelist 是执行期否决、本不参与 schema 过滤(未改)
119 lines
4.4 KiB
Go
119 lines
4.4 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"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/capacity"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// TestToolSurface_MutedPathMatchesStreamingPath 钉住审计 P0-12 的修复结论:
|
||
// 同一 persona/actor 条件下,静音/非流式路径(step → agentStep → RunStep)
|
||
// 向模型广告的工具面必须与流式主路径**逐条一致** —— capacity 允许集与会话
|
||
// 环境安全门(design/11 §3)都在两条路径上同样生效,agent 不再自行取
|
||
// dispatcher 全量(否则「模型只见允许集」在这条路径上不成立)。
|
||
//
|
||
// 两条路径由同一个循环、同一份会话环境(含 persona)、同一条用户消息驱动,
|
||
// 唯一区别是 isMutedBroadcast —— 静音即内部自主流程(backthink / 子代理),
|
||
// 它们同样要按同一份允许集广告,只是不做 UI 广播。
|
||
func TestToolSurface_MutedPathMatchesStreamingPath(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
env SessionEnv
|
||
// want 是两个过滤层(capacity 静息允许集 ∩ 会话环境安全门)
|
||
// 叠加后的期望广告面。
|
||
want []string
|
||
}{
|
||
{
|
||
// 主人私聊:安全门全放行,只剩 capacity 静息允许集
|
||
// (terminal + file;web-search/json 被收掉)。
|
||
name: "owner",
|
||
env: OwnerEnv(),
|
||
want: []string{"file", "terminal"},
|
||
},
|
||
{
|
||
// 群聊:capacity 静息允许集上再叠一层安全门 —— 只有带
|
||
// AllowInGroup 标记的 file 留下,terminal 被身份闸收掉。
|
||
name: "group",
|
||
env: SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false, PersonaID: "cyrene"},
|
||
want: []string{"file"},
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
reg, d := newPolicyDispatcher(t, []string{"terminal", "file", "web-search", "json"})
|
||
// 只有 file 对群聊开放;其余保持默认关闭(fail-closed)。
|
||
mustSetPolicy(t, reg, "file", registry.ToolPolicy{AllowInGroup: true})
|
||
|
||
// 闲聊消息:capacity 判为静息,允许集 = terminal + file。
|
||
// 这条输入对两个过滤层都有判别力。
|
||
msgs := []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}
|
||
|
||
ad := &mockAdapter{
|
||
stream: []mgResp{{resp: model.Response{Content: "陪你"}}},
|
||
chat: []mgResp{{resp: model.Response{Content: "陪你"}}},
|
||
}
|
||
a := newTestAgent(t, ad, d)
|
||
l := New(Options{
|
||
Agent: a,
|
||
MaxRounds: 8,
|
||
OnStream: func(model.StreamEvent) {},
|
||
Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}),
|
||
})
|
||
|
||
// 流式主路径(未静音):请求在循环里组装,工具面直接写进
|
||
// model.Request,记录在 streamReqs 上。
|
||
if _, err := l.Run(WithSessionEnv(context.Background(), tc.env), "sess", msgs); err != nil {
|
||
t.Fatalf("streaming Run: %v", err)
|
||
}
|
||
// 静音路径:同一循环、同一会话环境,只是抑制广播;
|
||
// 请求由 agent.RunStep 组装,记录在 reqs 上。
|
||
mutedCtx := WithMutedBroadcast(WithSessionEnv(context.Background(), tc.env))
|
||
if _, err := l.Run(mutedCtx, "sess", msgs); err != nil {
|
||
t.Fatalf("muted Run: %v", err)
|
||
}
|
||
|
||
if len(ad.streamReqs) == 0 || len(ad.reqs) == 0 {
|
||
t.Fatalf("captured requests: stream=%d chat=%d, want both paths exercised",
|
||
len(ad.streamReqs), len(ad.reqs))
|
||
}
|
||
streamFace := sortedToolNames(ad.streamReqs[0].Tools)
|
||
mutedFace := sortedToolNames(ad.reqs[0].Tools)
|
||
if !sameToolFace(streamFace, mutedFace) {
|
||
t.Fatalf("静音路径广告面 %v != 流式路径广告面 %v(audit P0-12 回归)", mutedFace, streamFace)
|
||
}
|
||
if !sameToolFace(streamFace, tc.want) {
|
||
t.Fatalf("广告面 = %v, want %v(capacity 允许集 + 会话环境安全门)", streamFace, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// sortedToolNames 把工具名按字典序排列,使两条路径的广告面可以做
|
||
// 与注册顺序无关的逐条比较(registry.List 遍历 map,顺序不保留)。
|
||
func sortedToolNames(specs []model.ToolSpec) []string {
|
||
names := toolNames(specs)
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
// sameToolFace 报告两份工具名列表逐条相同。
|
||
func sameToolFace(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|