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,392 @@
package loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// faceSkill 是技能面的测试技能:一个实现了 sdk.SkillSpec 的最小定义,字段齐到
// 本片用到的全部(三态 / 触发词 / paths / 工具上限 / 步骤 / 约束)。
type faceSkill struct {
name string
desc string
prompt string
whenToUse string
invocation sdk.Invocation
triggers []string
paths []string
allowed []string
disallowed []string
steps []sdk.Step
guardrails []sdk.Guardrail
contextMode sdk.ContextMode
}
func (s *faceSkill) Name() string { return s.name }
func (s *faceSkill) Description() string { return s.desc }
func (s *faceSkill) Prompt() string { return s.prompt }
func (s *faceSkill) Steps() []sdk.Step { return s.steps }
func (s *faceSkill) Refs() []sdk.Ref { return nil }
func (s *faceSkill) Guardrails() []sdk.Guardrail { return s.guardrails }
func (s *faceSkill) Version() int { return 1 }
func (s *faceSkill) WhenToUse() string { return s.whenToUse }
func (s *faceSkill) Invocation() sdk.Invocation { return s.invocation.OrDefault() }
func (s *faceSkill) Triggers() []string { return s.triggers }
func (s *faceSkill) Paths() []string { return s.paths }
func (s *faceSkill) AllowedTools() []string { return s.allowed }
func (s *faceSkill) DisallowedTools() []string { return s.disallowed }
func (s *faceSkill) ContextMode() sdk.ContextMode { return s.contextMode.OrDefault() }
func (s *faceSkill) Agent() string { return "" }
func (s *faceSkill) Model() string { return "" }
func (s *faceSkill) Effort() string { return "" }
func (s *faceSkill) Metadata() map[string]string { return nil }
// faceOwnerCtx 返回带会话标识的主人上下文:技能面按会话分桶保存激活状态,
// 因此测试必须给出会话标识,否则只投影清单、不激活(正是设计的口径)。
func faceOwnerCtx(session string) context.Context {
return withLoopSession(WithSessionEnv(context.Background(), OwnerEnv()), session)
}
// faceFor 构造一个以 list 为唯一来源的技能面。
func faceFor(list ...sdk.Skill) *SkillFace {
return NewSkillFace(SkillFaceOptions{
Source: func() []sdk.Skill { return list },
Log: testLogger(),
})
}
// systemTexts 收集消息列表里全部 system 消息的文本,供断言注入块内容。
func faceSystemTexts(messages []model.Message) []string {
out := make([]string, 0, len(messages))
for _, m := range messages {
if m.Role == model.RoleSystem {
out = append(out, messageText(m))
}
}
return out
}
// joinedSystem 把全部 system 文本拼起来,供「包含/不包含」断言。
func joinedSystem(messages []model.Message) string {
return strings.Join(faceSystemTexts(messages), "\n---\n")
}
// countContains 数 system 消息里含 needle 的条数,用来验证「一个技能一条消息」。
func countContains(messages []model.Message, needle string) int {
n := 0
for _, text := range faceSystemTexts(messages) {
if strings.Contains(text, needle) {
n++
}
}
return n
}
// TestSkillFace_清单只有名字与说明 是本片的核心不变式(design/09 §6.3-1):
// 清单给模型看得见的只有 name + description(+when-to-use),**正文不在其中**。
func TestSkillFace_清单只有名字与说明(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "daily", desc: "晨间简报", prompt: "正文-机密提示词", whenToUse: "整理昨日事项"}
face := faceFor(sk)
got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "在吗")}, 1)
joined := joinedSystem(got)
if !strings.Contains(joined, "daily") || !strings.Contains(joined, "晨间简报") {
t.Fatalf("清单缺少 name/description:%q", joined)
}
if !strings.Contains(joined, "整理昨日事项") {
t.Fatalf("清单缺少 when-to-use:%q", joined)
}
if strings.Contains(joined, "正文-机密提示词") {
t.Fatalf("未命中的技能正文不该进上下文:%q", joined)
}
}
// TestSkillFace_三态逐格 断言 invocation 三态与清单逐格对齐(design/09 §6.3-4):
// model-only ⇒ 清单**不含** description;user-only ⇒ 清单有 description 但模型不可调。
func TestSkillFace_三态逐格(t *testing.T) {
t.Parallel()
both := &faceSkill{name: "both", desc: "两边都能调"}
modelOnly := &faceSkill{name: "modelOnly", desc: "不该进清单的说明", invocation: sdk.InvocationModelOnly}
userOnly := &faceSkill{name: "userOnly", desc: "只给人调", invocation: sdk.InvocationUserOnly}
face := faceFor(both, modelOnly, userOnly)
joined := joinedSystem(face.Assemble(faceOwnerCtx("s1"), nil, 1))
for _, want := range []string{"both", "modelOnly", "userOnly", "两边都能调", "只给人调"} {
if !strings.Contains(joined, want) {
t.Fatalf("清单缺少 %q:%q", want, joined)
}
}
if strings.Contains(joined, "不该进清单的说明") {
t.Fatalf("model-only 的清单不该带 description:%q", joined)
}
}
// TestSkillFace_命中才展开且一条消息 断言触发词命中时正文作为**一条**消息注入
// (design/09 §6.3-3),未命中时完全不出现。
func TestSkillFace_命中才展开且一条消息(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "brief", desc: "简报", prompt: "按以下步骤做简报", triggers: []string{"简报"}}
face := faceFor(sk)
ctx := faceOwnerCtx("s1")
before := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "今天天气不错")}, 1)
if strings.Contains(joinedSystem(before), "按以下步骤做简报") {
t.Fatalf("未命中就展开了正文:%q", joinedSystem(before))
}
after := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "帮我做个简报")}, 2)
if got := countContains(after, "技能正文:brief"); got != 1 {
t.Fatalf("正文消息条数 = %d, want 1(一个技能一条消息)", got)
}
if !strings.Contains(joinedSystem(after), "按以下步骤做简报") {
t.Fatalf("命中的技能正文没进上下文:%q", joinedSystem(after))
}
}
// TestSkillFace_跨回合保留且不重复渲染 是本片的第二条核心不变式:正文激活后
// 跨回合保留,且**不重复渲染/读盘**(Stats().BodyRenders 恒为 1)。
func TestSkillFace_跨回合保留且不重复渲染(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "brief", prompt: "步骤正文", triggers: []string{"简报"}}
face := faceFor(sk)
ctx := faceOwnerCtx("s1")
face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "做个简报")}, 1)
// 后续回合:触发词不再出现,正文仍必须在(跨回合保留)。
later := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "继续")}, 2)
again := face.Assemble(ctx, []model.Message{model.TextMessage(model.RoleUser, "再继续")}, 3)
if !strings.Contains(joinedSystem(later), "步骤正文") || !strings.Contains(joinedSystem(again), "步骤正文") {
t.Fatalf("正文没有跨回合保留:%q / %q", joinedSystem(later), joinedSystem(again))
}
if got := face.Stats().BodyRenders; got != 1 {
t.Fatalf("BodyRenders = %d, want 1(同一技能只得渲染一次 = 不重复读盘)", got)
}
if got := face.Stats().Activations; got != 1 {
t.Fatalf("Activations = %d, want 1(同一技能只激活一次)", got)
}
}
// TestSkillFace_激活状态按会话隔离 断言两个会话不共享激活集合:A 会话命中不会
// 让 B 会话凭空拿到正文。
func TestSkillFace_激活状态按会话隔离(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "brief", prompt: "正文标记", triggers: []string{"简报"}}
face := faceFor(sk)
face.Assemble(faceOwnerCtx("session-a"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
other := face.Assemble(faceOwnerCtx("session-b"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
if !strings.Contains(joinedSystem(other), "正文标记") {
t.Fatalf("session-b 自己也命中了,应当展开:%q", joinedSystem(other))
}
// session-c 从未命中:不得出现任何正文。
plain := face.Assemble(faceOwnerCtx("session-c"), []model.Message{model.TextMessage(model.RoleUser, "你好")}, 1)
if strings.Contains(joinedSystem(plain), "正文标记") {
t.Fatalf("session-c 未命中却拿到了正文(跨会话泄漏):%q", joinedSystem(plain))
}
}
// TestSkillFace_userOnly_不自动激活 断言 user-only 的技能清单照常列出、
// 但模型不可调 ⇒ 不参与自动激活(design/09 §6.3-4)。
func TestSkillFace_userOnly_不自动激活(t *testing.T) {
t.Parallel()
sk := &faceSkill{
name: "humanOnly", desc: "只给人调", prompt: "不该被自动展开",
invocation: sdk.InvocationUserOnly, triggers: []string{"简报"},
}
face := faceFor(sk)
got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "做简报")}, 1)
joined := joinedSystem(got)
if !strings.Contains(joined, "humanOnly") {
t.Fatalf("user-only 也应在清单里可见:%q", joined)
}
if strings.Contains(joined, "不该被自动展开") {
t.Fatalf("user-only 不该被自动激活:%q", joined)
}
}
// TestSkillFace_群聊陌生人整块不注入 断言技能面是 owner 能力(design/09 §6.5):
// 群聊 / 陌生人会话既不注入清单,也不注入正文。
func TestSkillFace_群聊陌生人整块不注入(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "brief", desc: "简报", prompt: "正文标记", triggers: []string{"简报"}}
face := faceFor(sk)
env := SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
ctx := withLoopSession(WithSessionEnv(context.Background(), env), "s-group")
msgs := []model.Message{model.TextMessage(model.RoleUser, "做个简报")}
if got := face.Assemble(ctx, msgs, 1); len(got) != len(msgs) {
t.Fatalf("群聊会话不该注入任何技能块:got %d 条消息", len(got))
}
}
// TestSkillFace_正文超预算截断并标 断言注入有字节上限(design/09 §6.3-5):
// 超限截断、结果不超上限、且如实带 .truncated 标记。
func TestSkillFace_正文超预算截断并标(t *testing.T) {
t.Parallel()
const budget = 256
sk := &faceSkill{name: "huge", prompt: strings.Repeat("很长的一步说明", 200), triggers: []string{"简报"}}
face := NewSkillFace(SkillFaceOptions{
Source: func() []sdk.Skill { return []sdk.Skill{sk} },
Log: testLogger(),
BodyBytes: budget,
})
got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
var body string
for _, text := range faceSystemTexts(got) {
if strings.HasPrefix(text, "技能正文:huge") {
body = text
}
}
if body == "" {
t.Fatalf("没有找到正文消息:%q", joinedSystem(got))
}
if len(body) > budget {
t.Fatalf("正文 %d 字节 > 上限 %d(标记须计入预算)", len(body), budget)
}
if !strings.Contains(body, SkillBodyTruncatedMarker) {
t.Fatalf("截断必须如实标记 %q:%q", SkillBodyTruncatedMarker, body)
}
if face.Stats().BodyTruncations != 1 {
t.Fatalf("BodyTruncations = %d, want 1", face.Stats().BodyTruncations)
}
}
// TestSkillFace_清单条数上限如实标 断言清单超出全局条数上限时如实标注,
// 绝不静默丢条目(design/09 §6.3-2)。
func TestSkillFace_清单条数上限如实标(t *testing.T) {
t.Parallel()
list := make([]sdk.Skill, 0, 80)
for i := 0; i < 80; i++ {
list = append(list, &faceSkill{name: "s" + twoDigits(i), desc: "说明"})
}
face := faceFor(list...)
got := face.Assemble(faceOwnerCtx("s1"), nil, 1)
joined := joinedSystem(got)
if !strings.Contains(joined, SkillBodyTruncatedMarker) {
t.Fatalf("清单截断没有如实标注:%q", joined)
}
if face.Stats().Listed != 64 {
t.Fatalf("Listed = %d, want 64(技能包的全局条数上限)", face.Stats().Listed)
}
}
// TestSkillFace_展开上限如实标 断言会话展开上限被触发时如实写明「还有几个未展开」,
// 而不是静默把正文丢掉。
func TestSkillFace_展开上限如实标(t *testing.T) {
t.Parallel()
list := make([]sdk.Skill, 0, SkillActiveLimit+2)
for i := 0; i < SkillActiveLimit+2; i++ {
list = append(list, &faceSkill{
name: "hit" + twoDigits(i), desc: "说明",
prompt: "正文-" + twoDigits(i), triggers: []string{"简报"},
})
}
face := faceFor(list...)
got := face.Assemble(faceOwnerCtx("s1"), []model.Message{model.TextMessage(model.RoleUser, "简报")}, 1)
joined := joinedSystem(got)
if !strings.Contains(joined, "未展开") || !strings.Contains(joined, SkillBodyTruncatedMarker) {
t.Fatalf("超出展开上限必须如实标注:%q", joined)
}
bodies := 0
for _, text := range faceSystemTexts(got) {
if strings.HasPrefix(text, "技能正文:") {
bodies++
}
}
if bodies != SkillActiveLimit {
t.Fatalf("注入的正文条数 = %d, want %d", bodies, SkillActiveLimit)
}
}
// TestSkillFace_paths命中 断言工作文件命中 `paths` 也能激活:显式挂载的路径与
// 用户文本里看得出的路径两条来源都验。
func TestSkillFace_paths命中(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "go", prompt: "改写 Go 文件", paths: []string{"**/*.go"}}
face := faceFor(sk)
// 来源一:ctx 上显式挂载的工作文件。
hinted := WithSkillWorkFiles(faceOwnerCtx("s1"), []string{"internal/runtime/loop/run.go"})
if !strings.Contains(joinedSystem(face.Assemble(hinted, nil, 1)), "改写 Go 文件") {
t.Fatalf("显式工作文件没有命中 paths:%q", joinedSystem(face.Assemble(hinted, nil, 1)))
}
// 来源二:用户文本里看得出路径。
ctx := faceOwnerCtx("s2")
msgs := []model.Message{model.TextMessage(model.RoleUser, "帮我看看 internal/runtime/loop/memory.go 这段")}
if !strings.Contains(joinedSystem(face.Assemble(ctx, msgs, 1)), "改写 Go 文件") {
t.Fatalf("文本里的路径没有命中 paths:%q", joinedSystem(face.Assemble(ctx, msgs, 1)))
}
// 无关文本:不命中。
plain := face.Assemble(faceOwnerCtx("s3"), []model.Message{model.TextMessage(model.RoleUser, "今天吃什么")}, 1)
if strings.Contains(joinedSystem(plain), "改写 Go 文件") {
t.Fatalf("无关文本不该命中:%q", joinedSystem(plain))
}
}
// TestSkillFace_未接来源是空操作 断言关闭态:Source 为 nil、或没有技能、
// 或没有会话标识(只投影清单、不激活)时,消息列表逐字节不变。
func TestSkillFace_未接来源是空操作(t *testing.T) {
t.Parallel()
msgs := []model.Message{model.TextMessage(model.RoleUser, "你好")}
if got := NewSkillFace(SkillFaceOptions{}).Assemble(faceOwnerCtx("s1"), msgs, 1); len(got) != len(msgs) {
t.Fatalf("Source 为 nil 时应当零改动")
}
if got := faceFor().Assemble(faceOwnerCtx("s1"), msgs, 1); len(got) != len(msgs) {
t.Fatalf("没有技能时应当零改动")
}
sk := &faceSkill{name: "brief", prompt: "正文", triggers: []string{"你好"}}
noSession := WithSessionEnv(context.Background(), OwnerEnv())
if got := faceFor(sk).Assemble(noSession, msgs, 1); len(got) != len(msgs)+1 {
t.Fatalf("无会话标识时应只多一条清单块,got %d", len(got))
}
}
// TestSkillFace_注入块插在前导块之后 断言位置口径:注入块落在开头那段 system
// 消息之后、对话之前,前导块次序不被改动。
func TestSkillFace_注入块插在前导块之后(t *testing.T) {
t.Parallel()
sk := &faceSkill{name: "brief", desc: "简报", triggers: []string{"简报"}}
face := faceFor(sk)
msgs := []model.Message{
model.TextMessage(model.RoleSystem, "人格知识块"),
model.TextMessage(model.RoleSystem, "记忆块"),
model.TextMessage(model.RoleUser, "做个简报"),
}
got := face.Assemble(faceOwnerCtx("s1"), msgs, 1)
if len(got) != len(msgs)+2 {
t.Fatalf("消息数 = %d, want %d(清单 + 正文各一条)", len(got), len(msgs)+2)
}
if messageText(got[0]) != "人格知识块" || messageText(got[1]) != "记忆块" {
t.Fatalf("前导块次序被改动:%q / %q", messageText(got[0]), messageText(got[1]))
}
if !strings.HasPrefix(messageText(got[2]), "技能清单") {
t.Fatalf("清单块没有插在前导块之后:%q", messageText(got[2]))
}
if !strings.HasPrefix(messageText(got[3]), "技能正文:brief") {
t.Fatalf("正文块位置不对:%q", messageText(got[3]))
}
if got[4].Role != model.RoleUser {
t.Fatalf("对话被挤走了:%q", got[4].Role)
}
}
// twoDigits 把 0..99 渲染成两位数字,使测试技能名保持字典序 = 生成序。
func twoDigits(i int) string {
if i < 10 {
return "0" + string(rune('0'+i))
}
return string(rune('0'+i/10)) + string(rune('0'+i%10))
}