Files
Cyrene_v2/internal/runtime/loop/assemble_skill_test.go
T
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

393 lines
17 KiB
Go
Raw 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 (
"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))
}