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 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
This commit is contained in:
AskaEth committed 2026-10-06 14:12:24 +08:00
1 parent 2e1c3ed3fd
commit 6475eb875d
10 files changed
+1448 -3

No files matched your search

@@ -0,0 +1,259 @@
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))
}
}