From 6475eb875d7bdbb17271e909cbaddfc728c66c27 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 14:12:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E6=B8=90=E8=BF=9B=E6=8A=AB?= =?UTF-8?q?=E9=9C=B2=E6=8E=A5=E7=BA=BF=EF=BC=88L2=EF=BC=89=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=B8=85=E5=8D=95=E6=8A=95=E5=BD=B1=20+=20?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E6=89=8D=E5=B1=95=E5=BC=80=20+=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=9D=A2=E5=8F=AA=E6=94=B6=E4=B8=8D=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 清单只给 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 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。 --- cmd/cyrene/wire_services.go | 38 ++ internal/runtime/loop/assemble_skill.go | 370 +++++++++++++++++ .../runtime/loop/assemble_skill_render.go | 179 ++++++++ .../runtime/loop/assemble_skill_surface.go | 106 +++++ .../loop/assemble_skill_surface_test.go | 259 ++++++++++++ internal/runtime/loop/assemble_skill_test.go | 392 ++++++++++++++++++ .../runtime/loop/assemble_skill_workfiles.go | 92 ++++ internal/runtime/loop/loop.go | 7 +- internal/runtime/loop/memory.go | 4 + internal/runtime/loop/request.go | 4 +- 10 files changed, 1448 insertions(+), 3 deletions(-) create mode 100644 internal/runtime/loop/assemble_skill.go create mode 100644 internal/runtime/loop/assemble_skill_render.go create mode 100644 internal/runtime/loop/assemble_skill_surface.go create mode 100644 internal/runtime/loop/assemble_skill_surface_test.go create mode 100644 internal/runtime/loop/assemble_skill_test.go create mode 100644 internal/runtime/loop/assemble_skill_workfiles.go diff --git a/cmd/cyrene/wire_services.go b/cmd/cyrene/wire_services.go index 05dc763..984f11d 100644 --- a/cmd/cyrene/wire_services.go +++ b/cmd/cyrene/wire_services.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" @@ -15,9 +16,11 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // wireAuxiliary 组装执行提供者、任务管理器、IoT 管理器、渠道会话管理器、 @@ -80,6 +83,11 @@ func (a *app) wireAuxiliary() error { a.wireVoice() a.wireMCP() a.wireSkills() + // SK-5(design/09 §6.3):技能注册表填好之后,把技能面接到循环的模型面装配上 + // —— 清单投影 + 命中才展开正文 + 技能轮次的工具面收缩。技能来源就是上面刚 + // 注册进去的能力注册表(每轮实时读,因此面板热装的技能下一轮即出现在清单里)。 + // 必须排在 wireSkills 之后、且在任何 Run 之前(wireAuxiliary 仍在装配期)。 + a.loop.SetSkillFace(a.buildSkillFace()) a.wireTerminalTool() a.wireSubagentTool() a.wireSessionReadTool() @@ -90,6 +98,36 @@ func (a *app) wireAuxiliary() error { return nil } +// buildSkillFace 构造技能面(SK-5,design/09 §6.3):来源是能力注册表里的 +// KindSkill 条目 —— 启动时 wireSkills 载入的与面板热装的技能都在同一处,且 +// **每轮实时读取**,因此热装技能下一轮就出现在模型清单里,无需重启。 +// +// 这里只给来源与日志,不配任何旋钮:单条清单上限(1536 字符)、清单条数上限、 +// 正文注入字节上限都由代码常量给出(技能包的 listing.go 与 loop 的 +// assemble_skill.go 各有带依据的注释),既不新增 `.env` 键,也不引入第二套 +// 配置面(主人 2026-10-06 红线:.env 冻结为最小化引导面)。 +func (a *app) buildSkillFace() *loop.SkillFace { + return loop.NewSkillFace(loop.SkillFaceOptions{ + Source: func() []sdk.Skill { + if a.caps == nil { + return nil + } + caps := a.caps.List(registry.KindSkill) + out := make([]sdk.Skill, 0, len(caps)) + for _, cap := range caps { + // 注册表里不是技能的条目(理论上不会有,KindSkill 条目按契约就是 + // sdk.Skill)直接跳过:宁可少一个技能,也不让类型断言失败的条目 + // 把整轮装配拖垮。 + if s, ok := cap.Value.(sdk.Skill); ok && s != nil { + out = append(out, s) + } + } + return out + }, + Log: logger.WithSource(a.log, "cmd/cyrene/skills"), + }) +} + // pushOutletConfig 把环境变量里的推送出口配置块映射为 backthink 的导出器配置。 // 个推(Getui)凭据只被 config 读取、并不消费:备用 SDK 尚未接入,因此出口在 // 待接入该档位时记日志,而不是伪造一次推送。token 只透传,绝不写日志。 diff --git a/internal/runtime/loop/assemble_skill.go b/internal/runtime/loop/assemble_skill.go new file mode 100644 index 0000000..f877a49 --- /dev/null +++ b/internal/runtime/loop/assemble_skill.go @@ -0,0 +1,370 @@ +package loop + +import ( + "context" + "log/slog" + "sort" + "strings" + "sync" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是 SK-5「渐进披露接线」的**模型面装配**(DevDocs/design/09-插件生态.md +// §6.3,注入口径与 design/14 的唤醒式一次请求视图同源): +// +// ① 清单投影:模型可见的只有 name + description(+when-to-use),正文不进清单; +// ② 命中才展开:激活时把正文**作为一条消息**注入,跨回合保留、不重复读盘; +// ③ invocation 三态与清单逐格对齐(model-only 不含 description;user-only +// 清单有但模型不可调 ⇒ 不参与自动激活); +// ⑤ 注入有字节上限,超限截断并标 .truncated。 +// +// ④(工具面收缩 = allowed-tools ∩ 安全闸 ∩ 注册表 − disallowed-tools)落在 +// 同包的 assemble_skill_surface.go:它是**请求装配**那一侧(Request.Tools)。 +// +// 本文件不读盘:技能对象由 SkillSource 从**能力注册表**的 KindSkill 条目里取 +// (进程内、每轮实时读),因此运行中热装的技能下一轮即出现在清单里;正文渲染 +// 结果按「技能名 + 内容指纹」缓存,同一技能只渲染一次(design/09 §6.3-3 的 +// 「does not re-read the skill file on later turns」在注入侧由此成立)。 + +const ( + // SkillBodyInjectBytes 是**单个技能正文**注入模型可见上下文的字节上限 + // (design/09 §6.3-5:正文注入有上限,超限截断并标 .truncated)。 + // + // 取值依据:清单已经是「每条 ≤1536 字符」的轻投影,正文却是整段工作流 + // (基础提示词 + 步骤渲染文本),不封顶的话一个巨型 skill.yaml 就能吃掉整个 + // 上下文预算,而压缩器只能事后补救、还会顺手把对话一起压掉。16 KiB 足以容纳 + // 一份十几步的常规技能,又对齐 Agent Skills 对正文「约 5k words / 500 行」 + // 的建议量级(中文 1 字 ≈ 1 token 上下,16 KiB ≈ 5.5K 汉字 ≈ 5K token)。 + SkillBodyInjectBytes = 16 << 10 + + // SkillActiveLimit 是**单个会话**同时保持展开的技能正文条数上限。 + // 正文激活后跨回合保留(design/09 §6.3-3),不设上限的话,一次长会话里反复 + // 命中会把正文越堆越多、把对话挤出去。超限的部分**不静默丢**:清单块里如实 + // 写明还有几个已激活技能的正文未展开(与 listing 的 truncated 同一口径)。 + // + // 取值依据(最坏情形可算):4 × 16 KiB ≈ 64 KiB ≈ 2 万 token 量级的注入上界, + // 在 128K 窗口里占比可接受;再高就可能把对话本身挤掉 —— 技能正文是「工作流 + // 说明」,不该比对话还重。(清单最坏情形见 design/09 §6.3-2 的两个上限: + // 64 条 × 1536 字符,实际技能库通常个位数条目、描述一两句。) + SkillActiveLimit = 4 + + // SkillBodyTruncatedMarker 是正文(或清单)超预算时追加的显式标记 + // (design/09 §6.3-5 要求「标 .truncated」)。它字面出现在注入文本里, + // 便于人与日志直接 grep,也让模型知道内容被截过而不是本来就短。 + SkillBodyTruncatedMarker = ".truncated" + + // skillListingHeader 是清单块首行:写明「清单只有名字与说明,正文按需展开」, + // 使模型知道眼前这些不是技能的全部内容。 + skillListingHeader = "技能清单(只有名字与说明;命中相关技能时其正文会被展开)" +) + +// SkillSource 返回当前可用的技能全集。实现必须**进程内**给出(例如读能力注册表 +// 的 KindSkill 条目),装配每轮都会调用它一次:技能是可在运行中热装的能力 +// (design/09 §2.1),清单因此每轮重取,不能是构造期快照。返回 nil/空即 +// 「本进程没有技能」,整条技能面自然不出现。 +type SkillSource func() []sdk.Skill + +// SkillFaceOptions 配置技能面。 +type SkillFaceOptions struct { + // Source 提供技能全集。为 nil 时技能面是关闭态(装配与收缩都是空操作), + // 调用方因此可以先无条件接上、再按配置决定是否给 Source。 + Source SkillSource + // Log 是基础 logger,为 nil 时替换为丢弃 logger;输出一律带 + // source=runtime/loop 的来源标签。 + Log *slog.Logger + // BodyBytes 是单个技能正文的注入字节上限;非正取 SkillBodyInjectBytes。 + BodyBytes int +} + +// SkillFaceStats 是技能面的进程内计数(不参与任何判定,供观测与测试)。 +type SkillFaceStats struct { + // Assembles 是装配调用次数(≈ 装配轮数)。 + Assembles int + // Listed 是最近一次清单的条目数。 + Listed int + // Activations 是累计自动激活次数(同一技能只记第一次)。 + Activations int + // BodyRenders 是正文渲染次数 —— 也就是「读盘/渲染」次数,缓存命中不计入, + // 因此它正是「不重复读盘」这条验收判据的可观测证据。 + BodyRenders int + // BodyCacheHits 是正文命中缓存的次数。 + BodyCacheHits int + // BodyTruncations 是正文被字节预算截断的次数。 + BodyTruncations int + // ListingTruncations 是清单因条数上限被截断的次数(按装配轮次累计,不是 + // 被丢掉的条目数 —— 被丢条数由 renderSkillListing 的文案如实写在上下文里)。 + ListingTruncations int +} + +// SkillFace 承载技能面的模型侧状态:每会话的激活集合 + 正文缓存。 +// +// 并发契约:Loop 会被并发会话复用(每个 channel/群聊/陌生人消息都解析出自己的 +// 边界),因此状态按**会话**分桶并全部在 mu 之下读写;Loop 结构体上只挂一个 +// 指针,绝不把会话状态放进 Loop 的字段(那会造成数据竞争,与 SessionEnv 的 +// 做法同因)。 +type SkillFace struct { + src SkillSource + bodyBytes int + log *slog.Logger + + mu sync.Mutex + sessions map[string]*skillSession + bodies map[string]string + stats SkillFaceStats +} + +// skillSession 是一个会话的技能面状态(全部在 SkillFace.mu 之下访问)。 +type skillSession struct { + // retained 是已展开过的技能 —— 正文跨回合保留的载体(design/09 §6.3-3)。 + retained map[string]sdk.Skill + // turn 是**本回合**激活的技能:工具面收缩只在技能轮次内生效(对齐 Agent + // Skills 的「正文进上下文后跨回合保留,但权限不保留」),故每回合清空。 + turn map[string]sdk.Skill + // order 是激活顺序,使注入块与工具面的次序都确定(map 遍历序随机会让同一 + // 轮的工具面漂移)。 + order []string +} + +// activationResult 是一次装配的激活结论。 +type activationResult struct { + // active 是本轮要注入正文的技能(按激活顺序,已按 SkillActiveLimit 截取)。 + active []sdk.Skill + // dropped 是因会话上限**未展开**的已激活技能数(如实上报,不静默丢)。 + dropped int + // newly 是本轮新激活的技能名(用于带来源的日志;已激活的老技能不重复上报)。 + newly []string +} + +// NewSkillFace 构造技能面。Source 为 nil 时返回的是关闭态:所有方法都是空操作。 +func NewSkillFace(opts SkillFaceOptions) *SkillFace { + f := &SkillFace{ + src: opts.Source, + bodyBytes: opts.BodyBytes, + log: sourcedLog(opts.Log), + sessions: make(map[string]*skillSession), + bodies: make(map[string]string), + } + if f.bodyBytes <= 0 { + f.bodyBytes = SkillBodyInjectBytes + } + return f +} + +// SetSkillFace 把技能面接到循环的模型面装配上(design/09 §6.3)。 +// +// 必须在任何 Run 之前调用一次(组合根的装配期)。留成 setter 而不是 Options +// 字段,是因为组合根里两件事的次序是反的 —— 循环在 wireRuntime 里已建好,技能 +// 注册表要到 wireAuxiliary 才填上(`wireSkills`),因此这里不做构造期强绑定。 +// 传 nil 即关闭技能面,模型面与接线前逐字节一致。 +func (l *Loop) SetSkillFace(f *SkillFace) { + if l == nil { + return + } + l.skillFace = f +} + +// assembleSkillFace 是 nil 安全的装配入口:未接技能面时原样返回入参。 +func (l *Loop) assembleSkillFace(ctx context.Context, messages []model.Message, round int) []model.Message { + if l == nil || l.skillFace == nil { + return messages + } + return l.skillFace.Assemble(ctx, messages, round) +} + +// Assemble 把技能面投影进一份模型可见的消息列表(design/09 §6.3):先给清单块, +// 再给每个已激活/新命中技能的正文块(**一个技能一条消息**)。 +// +// 它由 assembleContext 在 transformContext 之前调用,因此拿到的是本轮完整视图; +// 注入块插在**开头那段 system 消息之后**(人格知识、时间、记忆、角色声明之后), +// 既让前导块次序保持稳定(首部缓存不被后续回合的激活打乱),又让技能正文 +// 紧贴它要影响的对话。 +// +// 群聊 / 陌生人会话整块跳过(design/09 §6.5:技能是 owner 能力,注册走零值 +// Policy ⇒ owner-only),与「人格知识」同一口径:技能正文既不进它们的上下文, +// 也不随请求发给模型供应商。 +func (f *SkillFace) Assemble(ctx context.Context, messages []model.Message, round int) []model.Message { + if f == nil || f.src == nil { + return messages + } + if sessionEnvFrom(ctx).Restricted() { + return messages + } + list := f.src() + if len(list) == 0 { + return messages + } + res := f.activate(loopSession(ctx), list, round, lastUserText(messages), turnWorkFiles(ctx, messages)) + listing := skills.BuildListing(list) + + f.mu.Lock() + f.stats.Assembles++ + f.stats.Listed = len(listing.Entries) + if listing.Truncated { + f.stats.ListingTruncations++ + } + f.mu.Unlock() + + if len(res.newly) > 0 { + f.log.Info("skill activated", + "session", loopSession(ctx), "round", round, "skills", strings.Join(res.newly, ",")) + } + blocks := f.faceBlocks(listing, res) + if len(blocks) == 0 { + return messages + } + at := leadingSystemCount(messages) + out := make([]model.Message, 0, len(messages)+len(blocks)) + out = append(out, messages[:at]...) + out = append(out, blocks...) + return append(out, messages[at:]...) +} + +// activate 更新该会话的技能面状态,并给出本轮要注入正文的技能。 +// +// 命中判定复用技能包已交付的纯函数 skills.AutoActivates(触发词命中**或**工作 +// 文件命中 paths),只对**模型可调**的技能求值:user-only 的技能清单里照常列出 +// (人看得见),但模型不可调 ⇒ 不参与自动激活(design/09 §6.3-4)。 +// +// round<=1 视为**新回合**的开始(Run 内 step 从 1 起算,崩溃恢复从中间续接), +// 于此处清空「本回合激活」而保留跨回合的正文集合。 +func (f *SkillFace) activate(session string, list []sdk.Skill, round int, turnText string, workFiles []string) activationResult { + if session == "" { + // 没有会话标识就没有「跨回合保留」的载体。此时只投影清单,绝不把激活 + // 状态挂到一个空键上 —— 那会让互不相干的运行(子代理、后台思考、 + // 未接会话的调用方)互相看见对方的技能面。 + return activationResult{} + } + f.mu.Lock() + defer f.mu.Unlock() + st := f.sessionLocked(session) + if round <= 1 { + st.turn = make(map[string]sdk.Skill, len(st.turn)) + } + current := make(map[string]sdk.Skill, len(list)) + for _, s := range list { + if s == nil { + continue + } + if name := strings.TrimSpace(s.Name()); name != "" { + current[name] = s + } + } + // 定义可能被热装覆盖(admin 安装同名技能 = 刷新):已激活条目跟着换成当前 + // 定义,正文缓存按内容指纹重算,绝不把旧正文当成新定义继续注入。 + for name := range st.retained { + s, ok := current[name] + if !ok { + continue + } + st.retained[name] = s + if _, active := st.turn[name]; active { + st.turn[name] = s + } + } + var res activationResult + for _, name := range orderedNames(current) { + s := current[name] + if !sdk.SkillView(s).Invocation().ModelInvocable() { + continue + } + hit := skills.AutoActivates(s, turnText, workFiles) + if _, retained := st.retained[name]; retained { + // 已激活的技能:正文早已在上下文里,不重复上报激活。但它在本回合**再次 + // 命中**(触发词/工作文件又出现)时,本回合的工具面收缩要重新生效 —— + // 否则「同一技能第二次用起来」会拿不到它声明的工具上限。 + if hit { + st.turn[name] = s + } + continue + } + if !hit { + continue + } + st.retained[name] = s + st.turn[name] = s + st.order = append(st.order, name) + f.stats.Activations++ + res.newly = append(res.newly, name) + } + all := orderedSkills(st, st.retained) + if len(all) > SkillActiveLimit { + res.dropped = len(all) - SkillActiveLimit + all = all[:SkillActiveLimit] + } + res.active = all + return res +} + +// faceBlocks 生成注入块:清单一条 + 每个已激活技能正文一条(design/09 §6.3-3 +// 「激活时把技能正文作为一条消息注入」)。没有任何技能、也没有已激活正文时 +// 返回空,调用方据此保持消息列表逐字节不变。 +func (f *SkillFace) faceBlocks(listing skills.Listing, res activationResult) []model.Message { + blocks := make([]model.Message, 0, len(res.active)+1) + if text := renderSkillListing(listing, res.dropped); text != "" { + blocks = append(blocks, model.TextMessage(model.RoleSystem, text)) + } + for _, s := range res.active { + blocks = append(blocks, model.TextMessage(model.RoleSystem, f.bodyOf(s))) + } + return blocks +} + +// sessionLocked 取(必要时新建)该会话的技能面状态。调用方必须持有 f.mu。 +func (f *SkillFace) sessionLocked(session string) *skillSession { + st, ok := f.sessions[session] + if !ok { + st = &skillSession{ + retained: make(map[string]sdk.Skill), + turn: make(map[string]sdk.Skill), + } + f.sessions[session] = st + } + return st +} + +// orderedNames 按名字升序返回技能名,使「本回合新激活」的遍历序确定 +// (源列表来自注册表,其 List 遍历的是 map)。 +func orderedNames(current map[string]sdk.Skill) []string { + out := make([]string, 0, len(current)) + for name := range current { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// orderedSkills 按该会话的激活顺序输出集合里的技能。map 遍历序随机,直接遍历会 +// 让注入块与工具面在同一轮内漂移,因此统一按激活顺序取。 +func orderedSkills(st *skillSession, set map[string]sdk.Skill) []sdk.Skill { + out := make([]sdk.Skill, 0, len(set)) + for _, name := range st.order { + if s, ok := set[name]; ok { + out = append(out, s) + } + } + return out +} + +// Stats 返回技能面的计数快照(nil 接收者返回零值)。 +func (f *SkillFace) Stats() SkillFaceStats { + if f == nil { + return SkillFaceStats{} + } + f.mu.Lock() + defer f.mu.Unlock() + return f.stats +} + +// leadingSystemCount 返回消息列表**开头连续**的 system 消息条数:注入块插在 +// 这一段之后,使前导块(人格知识 / 时间 / 工作记忆 / 记忆 / 角色声明)的次序 +// 与接线前逐字一致,而技能块紧贴对话。 +func leadingSystemCount(messages []model.Message) int { + n := 0 + for n < len(messages) && messages[n].Role == model.RoleSystem { + n++ + } + return n +} diff --git a/internal/runtime/loop/assemble_skill_render.go b/internal/runtime/loop/assemble_skill_render.go new file mode 100644 index 0000000..8ea7380 --- /dev/null +++ b/internal/runtime/loop/assemble_skill_render.go @@ -0,0 +1,179 @@ +package loop + +import ( + "hash/fnv" + "strconv" + "strings" + "unicode/utf8" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是技能面的**渲染与预算**(DevDocs/design/09-插件生态.md §6.3-1/§6.3-2/ +// §6.3-5):清单投影怎么渲染成模型可见的一行行说明、正文怎么渲染成一段可注入 +// 文本、以及正文的字节预算怎么施加(技能包的 1536 字符清单预算由 BuildListing +// 自己施加)。渲染都是纯函数,缓存策略见 assemble_skill.go 的 bodyOf。 + +// renderSkillListing 把清单投影渲染成模型可见的一段文本:逐行 `- 名字: 说明`。 +// +// 三态在这里逐格落地(design/09 §6.3-4):`model-only` 的行**没有说明** +// (BuildListing 已按该态省略 Description),`user-only` 的行照常带说明。 +// 条数超上限时如实标注(BuildListing 置的 Truncated)—— 绝不静默丢条目; +// 因会话展开上限未注入正文的技能同样如实标注。 +func renderSkillListing(listing skills.Listing, dropped int) string { + if len(listing.Entries) == 0 && dropped == 0 { + return "" + } + var b strings.Builder + b.WriteString(skillListingHeader) + for _, e := range listing.Entries { + b.WriteString("\n- ") + b.WriteString(e.Name) + if e.Description != "" { + b.WriteString(": ") + b.WriteString(e.Description) + } + } + if listing.Truncated { + b.WriteString("\n[skill-listing ") + b.WriteString(SkillBodyTruncatedMarker) + b.WriteString("] 清单只列到第 ") + b.WriteString(strconv.Itoa(len(listing.Entries))) + b.WriteString(" 条(全局上限),其余条目未列出") + } + if dropped > 0 { + b.WriteString("\n[skill-listing ") + b.WriteString(SkillBodyTruncatedMarker) + b.WriteString("] 另有 ") + b.WriteString(strconv.Itoa(dropped)) + b.WriteString(" 个已激活技能的正文未展开(会话上限 ") + b.WriteString(strconv.Itoa(SkillActiveLimit)) + b.WriteString(")") + } + return b.String() +} + +// bodyOf 返回该技能的注入正文:按「技能名 + 内容指纹」缓存,同一份定义 +// **只渲染一次**,后续回合与后续轮次都直接复用(design/09 §6.3-3:注入后不再 +// 重复读盘)。定义被热装覆盖时指纹变化,自然重新渲染。 +func (f *SkillFace) bodyOf(s sdk.Skill) string { + key := bodyCacheKey(s) + f.mu.Lock() + if body, ok := f.bodies[key]; ok { + f.stats.BodyCacheHits++ + f.mu.Unlock() + return body + } + f.mu.Unlock() + + body, truncated := renderSkillBody(s, f.bodyBytes) + + f.mu.Lock() + f.bodies[key] = body + f.stats.BodyRenders++ + if truncated { + f.stats.BodyTruncations++ + } + f.mu.Unlock() + if truncated { + f.log.Warn("skill body truncated to the inject budget", + "skill", s.Name(), "budget_bytes", f.bodyBytes) + } + return body +} + +// renderSkillBody 渲染一个技能的注入正文:名字 + 说明 + 基础提示词 + 步骤渲染 +// 文本 + 约束(design/09 §6.3-1 把正文定义为「prompt / steps 渲染文本」;约束 +// 一并给出,使模型能按 guardrail 自控,而强制点仍由执行侧把守)。渲染结果按 +// 字节预算截断,截断时追加 SkillBodyTruncatedMarker(标记计入预算,保证结果 +// 不超过上限 —— 与清单的 1536 字符预算同一口径)。 +func renderSkillBody(s sdk.Skill, budget int) (string, bool) { + var b strings.Builder + b.WriteString("技能正文:") + b.WriteString(s.Name()) + if d := strings.TrimSpace(s.Description()); d != "" { + b.WriteString("\n说明:") + b.WriteString(d) + } + if p := strings.TrimSpace(s.Prompt()); p != "" { + b.WriteString("\n\n") + b.WriteString(p) + } + if steps := s.Steps(); len(steps) > 0 { + b.WriteString("\n\n步骤:") + for i, st := range steps { + b.WriteString("\n") + b.WriteString(strconv.Itoa(i + 1)) + b.WriteString(". ") + b.WriteString(strings.TrimSpace(st.Name)) + if p := strings.TrimSpace(st.Prompt); p != "" { + b.WriteString(" — ") + b.WriteString(p) + } + if len(st.Depends) > 0 { + b.WriteString("(依赖:") + b.WriteString(strings.Join(st.Depends, ", ")) + b.WriteString(")") + } + } + } + if gs := s.Guardrails(); len(gs) > 0 { + b.WriteString("\n\n约束:") + for _, g := range gs { + b.WriteString("\n- ") + b.WriteString(g.ID) + if g.Enforced { + b.WriteString("(强制)") + } + b.WriteString(":") + b.WriteString(strings.TrimSpace(g.Message)) + } + } + return truncateUTF8Bytes(b.String(), budget) +} + +// truncateUTF8Bytes 把文本截到不超过 n 字节,且不切断 UTF-8 字符;被截断时末尾 +// 追加 SkillBodyTruncatedMarker(标记计入 n,因此结果绝不超过上限)。 +func truncateUTF8Bytes(s string, n int) (string, bool) { + if n <= 0 || len(s) <= n { + return s, false + } + if n <= len(SkillBodyTruncatedMarker) { + return cutUTF8Prefix(s, n), true + } + return cutUTF8Prefix(s, n-len(SkillBodyTruncatedMarker)) + SkillBodyTruncatedMarker, true +} + +// cutUTF8Prefix 取不超过 n 字节的前缀,且不落在一个 UTF-8 字符中间 +// (最多回退 3 字节)。 +func cutUTF8Prefix(s string, n int) string { + if n >= len(s) { + return s + } + cut := n + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} + +// bodyCacheKey 拼出正文缓存的键:技能名 + 定义内容指纹。指纹覆盖正文渲染真正 +// 读到的字段,使「同名但内容变了」(热装刷新)不会被旧缓存顶掉。 +func bodyCacheKey(s sdk.Skill) string { + h := fnv.New64a() + writeHash := func(parts ...string) { + for _, p := range parts { + _, _ = h.Write([]byte(p)) + _, _ = h.Write([]byte{0}) + } + } + writeHash(s.Name(), s.Description(), s.Prompt()) + for _, st := range s.Steps() { + writeHash(st.Name, st.Prompt, strings.Join(st.Depends, ",")) + } + for _, g := range s.Guardrails() { + writeHash(g.ID, g.Message, strconv.FormatBool(g.Enforced)) + } + return s.Name() + "\x00" + strconv.FormatUint(h.Sum64(), 16) +} diff --git a/internal/runtime/loop/assemble_skill_surface.go b/internal/runtime/loop/assemble_skill_surface.go new file mode 100644 index 0000000..3abad6a --- /dev/null +++ b/internal/runtime/loop/assemble_skill_surface.go @@ -0,0 +1,106 @@ +package loop + +import ( + "context" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills" + "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" +) + +// 本文件把技能包已交付的能力收缩求值(skills.ResolveToolSurface)接进**请求 +// 装配**(DevDocs/design/09-插件生态.md §6.4-3): +// +// 技能轮次可用工具 = allowed-tools(若声明)∩ 当前安全闸 ∩ 全局注册表 +// − disallowed-tools +// +// 🔴 铁律:**任何情况下都不得因为技能而放宽**。因此这里只从「本轮已经过 +// capacity 允许集 + design/11 §3 安全门 + 注册表」的工具面里**删**名字: +// 技能声明的 allowed-tools 里若含未注册/未授权的工具,它照样拿不到 —— 闸就是 +// 入参名字集合本身(fail-closed),绝不是「全放行」的闸。 +// +// 生效范围:**技能轮次**。正文跨回合保留,但这个工具面收缩只在技能被激活的 +// 那个回合内生效(对齐 Agent Skills 的「正文进上下文后跨回合保留,但权限不 +// 保留」),否则一次命中会让该会话此后所有回合都只剩技能声明的那几个工具。 + +// narrowToolSchemas 在已过滤的本轮工具面之上再做技能收缩,并把收缩后的面重新 +// 发布到本 Run 的允许集(design/14 §4 的软判通道),使 Invoke 的越界判定与模型 +// 真正看到的工具面同源:模型凭记忆调用被技能收窄掉的工具时,会拿到 not_allowed +// 软提示(调用照常执行 —— 状态不是能力边界)。 +// +// 未接技能面、本轮没有激活技能、或收缩后名单没变时都是空操作。 +func (l *Loop) narrowToolSchemas(ctx context.Context, schemas []model.ToolSpec) []model.ToolSpec { + if l == nil || l.skillFace == nil || len(schemas) == 0 { + return schemas + } + out, active, changed := l.skillFace.narrow(ctx, schemas) + if !changed { + return schemas + } + names := make([]string, 0, len(out)) + for _, s := range out { + names = append(names, s.Name) + } + tool.PublishRoundAllowSet(ctx, names, "skill:"+strings.Join(active, "+")) + l.log.Info("skill tool surface narrowed", + "session", loopSession(ctx), "skills", strings.Join(active, ","), + "tools_before", len(schemas), "tools_after", len(out)) + return out +} + +// narrow 返回本回合激活技能收缩后的工具面、参与收缩的技能名,以及「是否真的 +// 收敛了」(收缩前后条数不同)。 +// +// 入参 schemas 是 dispatcher 给出的、已经过 capacity 与安全门的本轮工具面, +// 也就是说「安全闸 ∩ 注册表」已经在入参里;技能只能在这个交集里再删。 +// +// 多个技能同时激活时取**并集**:每个技能的 allowed-tools 是它自己的上限,并集 +// 仍然落在入参之内(安全性不受影响);取交集则会让两个技能互相封掉对方的工具, +// 且「技能 A 声明 [x]、技能 B 不声明」时会直接变成空集 —— 那不是任何设计意图。 +func (f *SkillFace) narrow(ctx context.Context, schemas []model.ToolSpec) ([]model.ToolSpec, []string, bool) { + active := f.turnSkills(ctx) + if len(active) == 0 { + return schemas, nil, false + } + registered := make([]string, 0, len(schemas)) + for _, s := range schemas { + registered = append(registered, s.Name) + } + gate := skills.NewNameSet(registered) + keep := make(map[string]struct{}, len(registered)) + for _, sk := range active { + spec := sdk.SkillView(sk) + for _, name := range skills.ResolveToolSurface(registered, spec.AllowedTools(), spec.DisallowedTools(), gate) { + keep[name] = struct{}{} + } + } + out := make([]model.ToolSpec, 0, len(keep)) + for _, s := range schemas { + if _, ok := keep[s.Name]; ok { + out = append(out, s) + } + } + names := make([]string, 0, len(active)) + for _, sk := range active { + names = append(names, sk.Name()) + } + return out, names, len(out) != len(schemas) +} + +// turnSkills 返回该会话**本回合**激活的技能(按激活顺序);无会话标识或本回合 +// 没有激活时为空 —— 于是工具面完全不动。 +func (f *SkillFace) turnSkills(ctx context.Context) []sdk.Skill { + session := loopSession(ctx) + if session == "" { + return nil + } + f.mu.Lock() + defer f.mu.Unlock() + st := f.sessions[session] + if st == nil || len(st.turn) == 0 { + return nil + } + return orderedSkills(st, st.turn) +} diff --git a/internal/runtime/loop/assemble_skill_surface_test.go b/internal/runtime/loop/assemble_skill_surface_test.go new file mode 100644 index 0000000..f84847f --- /dev/null +++ b/internal/runtime/loop/assemble_skill_surface_test.go @@ -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)) + } +} diff --git a/internal/runtime/loop/assemble_skill_test.go b/internal/runtime/loop/assemble_skill_test.go new file mode 100644 index 0000000..122686c --- /dev/null +++ b/internal/runtime/loop/assemble_skill_test.go @@ -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)) +} diff --git a/internal/runtime/loop/assemble_skill_workfiles.go b/internal/runtime/loop/assemble_skill_workfiles.go new file mode 100644 index 0000000..504e149 --- /dev/null +++ b/internal/runtime/loop/assemble_skill_workfiles.go @@ -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 +} diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index aacd651..574dc6c 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -133,7 +133,12 @@ type Loop struct { imageResolver ImageResolver documentResolver DocumentResolver documentInjectChars int - log *slog.Logger + // skillFace 是 SK-5 的技能面(清单投影 + 命中才展开的正文 + 技能轮次的工具面 + // 收缩,见 assemble_skill.go)。为 nil(SetSkillFace 未调用)时模型面与接线前 + // 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部 + // 按会话分桶并自带锁,因此循环的并发 Run 契约不变。 + skillFace *SkillFace + log *slog.Logger } // New 由 opts 构建 Loop 并应用默认值。MaxRounds 非正时 diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index cc1308c..03a7a07 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -175,6 +175,10 @@ func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, ro leading = append(leading, decl) } out := append(leading, view...) + // 技能面(design/09 §6.3):清单投影 + 命中才展开的正文 + 已激活正文的跨回合 + // 保留。它排在 transformContext **之前**,使投影钩子看到的就是模型将看到的 + // 最终形态;未接技能面(SetSkillFace 未调用)时逐字返回入参。 + out = l.assembleSkillFace(ctx, out, round) if l.transformContext != nil { out = l.transformContext(ctx, out, round) } diff --git a/internal/runtime/loop/request.go b/internal/runtime/loop/request.go index 0f6a18e..f0377d1 100644 --- a/internal/runtime/loop/request.go +++ b/internal/runtime/loop/request.go @@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode } schemas := d.ToolSchemas() if l.capacity == nil || !l.capacity.Enabled() { - return l.restrictSchemas(ctx, d, schemas) + return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)) } names := make([]string, 0, len(schemas)) @@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode out = append(out, s) } } - return l.restrictSchemas(ctx, d, out) + return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)) } // capacityTexts 为 capacity 判定器提取近期对话文本: