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,92 @@
package loop
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件汇出技能 `paths` 判定(DevDocs/design/09-插件生态.md §6.2/§6.3)要用的
// 「本轮工作文件」:ctx 上显式挂载的权威路径,加上用户本轮文本里看得出的路径
// token(保守启发式,代价只落在上下文,不落在权限上)。
// skillWorkFilesKey 是「本轮工作文件」的 context key(design/09 §6.2 的 `paths`)。
type skillWorkFilesKey struct{}
// WithSkillWorkFiles 把本轮确实在动的文件路径挂到 ctx 上,供技能 `paths` 判定
// 自动激活(design/09 §6.3)。知道真实路径的调用方(网关的附件/工作区、编码类
// 工具的宿主)挂它;未挂载时该来源为空,判定退回用户文本里的路径候选。
//
// 空列表是空操作:不挂键即「本轮没有工作文件」,与不接该能力时逐字一致。
func WithSkillWorkFiles(ctx context.Context, paths []string) context.Context {
if ctx == nil || len(paths) == 0 {
return ctx
}
return context.WithValue(ctx, skillWorkFilesKey{}, append([]string(nil), paths...))
}
// SkillWorkFilesFrom 返回 ctx 上挂载的工作文件;未挂载返回 nil。它是
// WithSkillWorkFiles 的读侧,供只拿到 context 的调用方与测试观察。
func SkillWorkFilesFrom(ctx context.Context) []string {
if ctx == nil {
return nil
}
paths, _ := ctx.Value(skillWorkFilesKey{}).([]string)
return paths
}
// turnWorkFiles 汇出本轮 `paths` 判定的工作文件候选:
//
// - ctx 上显式挂载的(WithSkillWorkFiles,权威来源);
// - 用户本轮文本里**看得出是路径**的 token(保守启发式,见 pathLikeTokens)。
//
// 第二条是为了让「帮我改 internal/runtime/loop/run.go」这类消息真的能命中
// `paths`,而不是让这枚设计字段在生产上永远不生效。它只影响「是否自动展开某个
// 技能的正文」,不参与任何身份/权限判定(身份只走 channel 层 actor 元数据)。
func turnWorkFiles(ctx context.Context, messages []model.Message) []string {
hinted := SkillWorkFilesFrom(ctx)
derived := pathLikeTokens(lastUserText(messages))
if len(hinted) == 0 {
return derived
}
return append(append(make([]string, 0, len(hinted)+len(derived)), hinted...), derived...)
}
// pathLikeTokens 从一段文本里挑出「像路径」的 token:含路径分隔符(`/` 或 `\`)、
// 不含空白/引号等边界字符、单 token 长度有界。刻意保守 —— 宁可漏报,也不把普通
// 词当成路径(误报只会多展开一份技能正文,代价是上下文,不是权限)。
func pathLikeTokens(text string) []string {
const (
maxTokenLen = 200
maxTokens = 16
)
if text == "" {
return nil
}
fields := strings.FieldsFunc(text, func(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', '"', '\'', '`', ',', ';', '(', ')', '[', ']', '{', '}',
'<', '>', '|', ',', '。', '、', '(', ')', ':', ';', '「', '」':
return true
default:
return false
}
})
out := make([]string, 0, 4)
seen := make(map[string]struct{}, 4)
for _, tok := range fields {
if len(tok) > maxTokenLen || !strings.ContainsAny(tok, `/\`) {
continue
}
if _, dup := seen[tok]; dup {
continue
}
seen[tok] = struct{}{}
out = append(out, tok)
if len(out) >= maxTokens {
break
}
}
return out
}