- 清单只给 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 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
260 lines
11 KiB
Go
260 lines
11 KiB
Go
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))
|
||
}
|
||
}
|