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)) } }