Files
Cyrene_v2/internal/runtime/loop/tool_surface_parity_test.go
AskaEth 5c28ace70d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(agent,loop): 静音路径工具广告面与流式路径统一(P0-12,审计 §4.3)
- 原缺陷:流式路径经 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 过滤(未改)
2026-10-04 14:14:05 +08:00

119 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}