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