Files
Cyrene_v2/internal/runtime/loop/assemble_skill_surface_test.go
AskaEth 6475eb875d feat(skills): 渐进披露接线(L2)—— 清单投影 + 命中才展开 + 工具面只收不放
- 清单只给 name+description(+when-to-use),单条 ≤1536 字符 + 全局条数上限,超限如实标 truncated
- 命中才把正文作为一条消息注入:跨回合保留、按内容指纹缓存不重复读盘、注入 ≤16 KiB 标 .truncated
- invocation 三态与清单逐格对齐(model-only 清单不含 description;user-only 清单有但模型不可调)
- 激活轮的工具面 = allowed-tools ∩ 安全闸 ∩ 注册表 再减 disallowed-tools(owner 也看不见未允许的工具)
- 激活状态进程内按会话(不落库);注入量已界化(压缩器估值未含,超我文件面)

裁定(DECISIONS #3/#4/#5):删掉 paths 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
2026-10-06 14:12:24 +08:00

260 lines
11 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 (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// messageTexts 把一份消息列表的全部文本拼起来,供「请求里有没有某块」断言。
func messageTexts(messages []model.Message) string {
var b strings.Builder
for _, m := range messages {
b.WriteString(messageText(m))
b.WriteString("\n")
}
return b.String()
}
// surfaceLoop 构造一个接了技能面的循环:dispatcher 持有 tools 里的全部工具,
// 技能面以 list 为来源。
func surfaceLoop(t *testing.T, face *SkillFace, tools ...sdk.Tool) (*Loop, *tool.Dispatcher) {
t.Helper()
d := dispatcherWith(t, tools...)
l := New(Options{Tools: d})
l.SetSkillFace(face)
return l, d
}
// TestNarrowToolSchemas_技能上限只收不放 是本片的核心安全不变式
// (design/09 §6.4-3 / §6.6-3):allowed-tools=[A] ⇒ 该技能轮次里 B **不可见**,
// 即便 owner 本来可以调用 B。
func TestNarrowToolSchemas_技能上限只收不放(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "one", desc: "只允许 A", allowed: []string{"toolA"}, triggers: []string{"简报"}}
face := faceFor(sk)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
ctx := faceOwnerCtx("s1")
msgs := []model.Message{model.TextMessage(model.RoleUser, "做个简报")}
before := l.toolSchemas(ctx, msgs)
expectToolNames(t, before, "toolA", "toolB")
face.Assemble(ctx, msgs, 1)
expectToolNames(t, l.toolSchemas(ctx, msgs), "toolA")
}
// TestNarrowToolSchemas_不声明上限则不动 断言「空 = 不额外收窄」:技能没写
// allowed-tools 时工具面原样保留(仍受安全闸约束)。
func TestNarrowToolSchemas_不声明上限则不动(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "open", triggers: []string{"简报"}}
face := faceFor(sk)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
ctx := faceOwnerCtx("s1")
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA", "toolB")
}
// TestNarrowToolSchemas_技能不得放宽 断言技能声明的名字若不在本轮工具面里
// (未注册 / 未授权),它拿不到也变不出来:收缩只会删,不会加。
func TestNarrowToolSchemas_技能不得放宽(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "greedy", allowed: []string{"ghost", "toolA"}, triggers: []string{"简报"}}
face := faceFor(sk)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
ctx := faceOwnerCtx("s1")
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
got := l.toolSchemas(ctx, nil)
expectToolNames(t, got, "toolA")
for _, s := range got {
if s.Name == "ghost" {
t.Fatalf("技能凭空放行了未注册的工具:%v", toolNames(got))
}
}
}
// TestNarrowToolSchemas_disallowed最后减 断言 disallowed-tools 从上限里再删一层。
func TestNarrowToolSchemas_disallowed最后减(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "cut", allowed: []string{"toolA", "toolB"}, disallowed: []string{"toolB"},
triggers: []string{"简报"}}
face := faceFor(sk)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"}, &mockTool{name: "toolC"})
ctx := faceOwnerCtx("s1")
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA")
}
// TestNarrowToolSchemas_多技能取并集 断言两个技能同时激活时工具面是两者的并集
// (取交集会让两个技能互相封掉对方的工具,不是任何设计意图)。
func TestNarrowToolSchemas_多技能取并集(t *testing.T) {
t.Parallel()
one := &faceSkill{name: "one", allowed: []string{"toolA"}, triggers: []string{"简报"}}
two := &faceSkill{name: "two", allowed: []string{"toolB"}, triggers: []string{"简报"}}
face := faceFor(one, two)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"}, &mockTool{name: "toolC"})
ctx := faceOwnerCtx("s1")
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA", "toolB")
}
// TestNarrowToolSchemas_权限不跨回合 断言收缩的生效范围是**技能轮次**:正文
// 跨回合保留(design/09 §6.3-3),但工具面在新回合恢复原状(对齐 Agent Skills
// 的「权限不保留」)—— 否则一次命中会让该会话此后只剩技能声明的那几个工具。
func TestNarrowToolSchemas_权限不跨回合(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "one", prompt: "正文标记", allowed: []string{"toolA"}, triggers: []string{"简报"}}
face := faceFor(sk)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
ctx := faceOwnerCtx("s1")
faces := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "做个简报")}, 1)
if !strings.Contains(joinedSystem(faces), "正文标记") {
t.Fatalf("命中时应当展开正文:%q", joinedSystem(faces))
}
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA")
// 新回合(round 回到 1)且没有触发词:工具面恢复,正文仍在。
next := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "换个话题")}, 1)
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA", "toolB")
if !strings.Contains(joinedSystem(next), "正文标记") {
t.Fatalf("正文应当跨回合保留:%q", joinedSystem(next))
}
}
// TestNarrowToolSchemas_发布本轮允许集 断言收缩后的面被重新发布到本 Run 的
// 允许集(design/14 §4):模型凭记忆调用被收窄掉的工具时,会拿到 not_allowed
// 软提示(调用仍执行 —— 状态不是能力边界,绝不硬阻断)。
func TestNarrowToolSchemas_发布本轮允许集(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "one", allowed: []string{"toolA"}, triggers: []string{"简报"}}
face := faceFor(sk)
reg, d := newPolicyDispatcher(t, []string{"toolA", "toolB"})
l := New(Options{Tools: d})
l.SetSkillFace(face)
ctx := tool.WithRoundAllowSet(faceOwnerCtx("s1"))
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
l.toolSchemas(ctx, nil)
out, err := d.Invoke(ctx, "toolB", nil)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
soft, ok := out.(tool.SoftNotAllowed)
if !ok {
t.Fatalf("越界调用应当带 not_allowed 软结果,got %#v(registry=%v)", out, reg != nil)
}
if !strings.Contains(soft.NotAllowed, "toolB") {
t.Fatalf("软提示没有点名该工具:%q", soft.NotAllowed)
}
// 面内的工具不受软判影响。
if out, err := d.Invoke(ctx, "toolA", nil); err != nil {
t.Fatalf("Invoke toolA: %v", err)
} else if _, soft := out.(tool.SoftNotAllowed); soft {
t.Fatalf("面内工具不该被判越界:%#v", out)
}
}
// TestNarrowToolSchemas_同回合内持续生效 断言技能轮次的**后续轮次**(同一回合
// 的工具循环第 2、3 步)仍受收缩约束 —— 技能的工作流要跨多轮工具调用跑。
func TestNarrowToolSchemas_同回合内持续生效(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "one", allowed: []string{"toolA"}, triggers: []string{"简报"}}
face := faceFor(sk)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
ctx := faceOwnerCtx("s1")
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "做个简报")}, 1)
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA")
// 同一回合的第 3 步(工具结果回灌后再次装配):收缩仍在。
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "做个简报")}, 3)
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA")
}
// TestNarrowToolSchemas_新回合再次命中则再次收缩 断言「本回合命中」判定是每次
// 新回合重算的:正文早就在上下文里(不重复上报激活),触发词再次出现时工具面
// 收缩重新生效。
func TestNarrowToolSchemas_新回合再次命中则再次收缩(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "one", allowed: []string{"toolA"}, triggers: []string{"简报"}}
face := faceFor(sk)
l, _ := surfaceLoop(t, face, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
ctx := faceOwnerCtx("s1")
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "做个简报")}, 1)
hit := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "再来个简报")}, 1)
expectToolNames(t, l.toolSchemas(ctx, nil), "toolA")
if got := face.Stats().Activations; got != 1 {
t.Fatalf("Activations = %d, want 1(已激活的技能不再重复计数)", got)
}
if !strings.Contains(joinedSystem(hit), "技能正文:one") {
t.Fatalf("正文应当在上下文里:%q", joinedSystem(hit))
}
}
// TestRun_技能工具面进入模型请求 是端到端证据:真跑一次 Run,模型收到的工具
// 面就是收缩后的那一份(而非只有 toolSchemas 单测)。
func TestRun_技能工具面进入模型请求(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "done"}}}}
d := dispatcherWith(t, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
a := newTestAgent(t, ad, d)
l := New(Options{Agent: a, MaxRounds: 4})
l.SetSkillFace(faceFor(&faceSkill{
name: "one", desc: "只允许 A", allowed: []string{"toolA"}, triggers: []string{"简报"},
}))
if _, err := l.Run(faceOwnerCtx("s1"), "s1",
[]model.Message{model.TextMessage(model.RoleUser, "做个简报")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.reqs) == 0 {
t.Fatal("模型没有被调用")
}
sent := ad.reqs[0]
names := make([]string, 0, len(sent.Tools))
for _, s := range sent.Tools {
names = append(names, s.Name)
}
if len(names) != 1 || names[0] != "toolA" {
t.Fatalf("模型看到的工具面 = %v, want [toolA]", names)
}
if !strings.Contains(messageTexts(sent.Messages), "技能清单") {
t.Fatalf("清单没进模型请求:%q", messageTexts(sent.Messages))
}
}
// TestRun_无技能面时工具面不变 断言未接技能面(SetSkillFace 未调用)时,
// 工具面与接线前逐字节一致 —— 这是兼容性硬要求。
func TestRun_无技能面时工具面不变(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "done"}}}}
d := dispatcherWith(t, &mockTool{name: "toolA"}, &mockTool{name: "toolB"})
a := newTestAgent(t, ad, d)
l := New(Options{Agent: a, MaxRounds: 4})
if _, err := l.Run(faceOwnerCtx("s1"), "s1",
[]model.Message{model.TextMessage(model.RoleUser, "做个简报")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.reqs) != 1 {
t.Fatalf("模型调用次数 = %d, want 1", len(ad.reqs))
}
if len(ad.reqs[0].Tools) != 2 {
t.Fatalf("未接技能面时工具面应当完整:%d", len(ad.reqs[0].Tools))
}
if strings.Contains(messageTexts(ad.reqs[0].Messages), "技能清单") {
t.Fatalf("未接技能面时不该有清单块:%q", messageTexts(ad.reqs[0].Messages))
}
}