Skip to content

Commit 1c4a2cb

Browse files
authored
Merge pull request #390 from Cai-Tang-www/feat/skills
feat(skills): 完成Skills边界收口与用户入口落地
2 parents 6a4f63b + c7dc132 commit 1c4a2cb

24 files changed

Lines changed: 1676 additions & 100 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ go run ./cmd/neocode --runtime-mode gateway
123123
- `/memo`:查看记忆索引
124124
- `/remember <text>`:保存记忆
125125
- `/forget <keyword>`:按关键词删除记忆
126+
- `/skills`:查看当前可用 skills(含当前会话激活标记)
127+
- `/skill use <id>`:在当前会话启用 skill
128+
- `/skill off <id>`:在当前会话停用 skill
129+
- `/skill active`:查看当前会话已激活 skills
126130
- `& <command>`:在当前工作区执行本地命令
127131

128132
示例输入:
@@ -159,6 +163,7 @@ go run ./cmd/neocode --runtime-mode gateway
159163
- [Session 持久化设计](docs/session-persistence-design.md)
160164
- [Context Compact 说明](docs/context-compact.md)
161165
- [Tools 与 TUI 集成](docs/tools-and-tui-integration.md)
166+
- [Skills 设计与使用](docs/skills-system-design.md)
162167
- [MCP 配置指南](docs/guides/mcp-configuration.md)
163168
- [更新与升级](docs/guides/update.md)
164169

docs/runtime-provider-event-flow.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
- `permission_requested`
1919
- `permission_resolved`
2020
- `token_usage`
21+
- `skill_activated`
22+
- `skill_deactivated`
23+
- `skill_missing`
2124
- `compact_start`
2225
- `compact_applied`
2326
- `compact_error`

docs/skills-system-design.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Skills 设计与使用说明
2+
3+
## 1. 目标与定位
4+
Skills 是 NeoCode 的“能力提示层”,用于给模型提供任务约束、参考资料和工具偏好,不是新的执行层。
5+
6+
主链路保持不变:
7+
8+
`TUI -> Runtime -> Provider / Tool Manager -> Security -> Executor`
9+
10+
Skills 只影响:
11+
- Context 注入内容
12+
- 工具暴露顺序(提示优先级)
13+
14+
Skills 不影响:
15+
- 工具是否真正可执行
16+
- 权限 ask/deny/allow 决策
17+
- MCP 注册与权限链路
18+
19+
## 2. 发现机制(Discovery)
20+
当前本地发现路径:
21+
- `~/.neocode/skills/`
22+
23+
加载规则:
24+
- 扫描 root 下的子目录(忽略隐藏目录)
25+
- 每个 skill 目录要求存在 `SKILL.md`
26+
- 也支持 root 目录直接放置一个 `SKILL.md`
27+
- 缺失文件、无效 metadata、空内容会记录为 `LoadIssue`,不阻塞其它 skill 加载
28+
29+
## 3. 加载机制(Loader + Registry)
30+
核心模块:
31+
- `internal/skills/loader.go`:本地扫描与解析
32+
- `internal/skills/registry.go`:内存索引、查询与刷新
33+
- `internal/skills/filter.go`:按 source/scope/workspace 过滤
34+
35+
关键约束:
36+
- `SKILL.md` 单文件读取有大小上限(默认 1 MiB)
37+
- 前置 metadata 和正文解析后统一归一化
38+
- skill id 去重冲突时 fail-closed(冲突项不进入可用列表)
39+
40+
## 4. skill 文件结构(建议)
41+
`SKILL.md` 支持 frontmatter + 正文 section:
42+
43+
```md
44+
---
45+
id: go-review
46+
name: Go Review
47+
description: Go 代码审查助手
48+
version: v1
49+
scope: session
50+
source: local
51+
tool_hints:
52+
- filesystem_read_file
53+
- filesystem_grep
54+
---
55+
56+
## Instruction
57+
优先做静态阅读,再给出可执行修改建议。
58+
59+
## References
60+
- [代码规范](./guides/go-style.md)
61+
62+
## Examples
63+
- 先总结问题,再给补丁
64+
65+
## ToolHints
66+
- filesystem_read_file
67+
- filesystem_grep
68+
```
69+
70+
## 5. 激活与会话模型
71+
Runtime 提供会话级接口:
72+
- `ActivateSessionSkill(session_id, skill_id)`
73+
- `DeactivateSessionSkill(session_id, skill_id)`
74+
- `ListSessionSkills(session_id)`
75+
- `ListAvailableSkills(session_id)`
76+
77+
TUI 入口:
78+
- `/skills`
79+
- `/skill use <id>`
80+
- `/skill off <id>`
81+
- `/skill active`
82+
83+
说明:
84+
- `use/off/active` 需要当前有 active session
85+
- session 重载后会恢复 `activated_skills` 状态
86+
- skill 在 registry 中缺失时,会标记为 missing 并发出事件
87+
88+
## 6. 模型如何使用 skill
89+
Runtime 在每轮 context 构建时把激活 skills 注入 `Skills` section,内容包含:
90+
- instruction
91+
- tool_hints(裁剪)
92+
- references(裁剪)
93+
- examples(裁剪)
94+
95+
模型预期行为:
96+
- 把 skill 当成策略与工作流提示
97+
- 只调用当前真实暴露的工具 schema
98+
- 通过正常工具调用链路执行,不跳过权限层
99+
100+
## 7. Tools / Security / MCP 边界
101+
Skills 与安全边界的约束:
102+
- skill 不能注入未注册工具
103+
- skill 不能变成权限 allowlist
104+
- skill 不能绕过 `PermissionEngine` 的 ask/deny/allow
105+
- MCP 工具仍经过统一 registry + exposure filter + permission 检查
106+
107+
当前实现中,`tool_hints` 仅用于对已暴露工具做排序优先级调整,不会新增工具,也不会改变权限决策。
108+
109+
## 8. 可观测事件
110+
Runtime 会发出以下 skills 事件(供 TUI/日志调试):
111+
- `skill_activated`
112+
- `skill_deactivated`
113+
- `skill_missing`
114+
115+
## 9. 兼容与扩展
116+
当前 focus 是本地 skills;后续如需引入 remote source / marketplace,可在 `Loader``Registry` 层扩展,不需要改动 runtime 主执行链路。

docs/tools-and-tui-integration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
- TUI 的 `/memo``/remember``/forget` 等 Slash Command 不再直接依赖 memo service,而是通过 `Runtime.ExecuteSystemTool` 统一入口触发系统工具执行,保证 UI 与 memo 逻辑解耦。
3131
- TUI 不会展示后台自动提取的中间状态。
3232

33+
## Skills 能力集成
34+
- Skills 由 `internal/skills` 统一发现、加载和注册;TUI 不直接读取 `SKILL.md` 文件。
35+
- TUI 通过 runtime 接口管理会话激活状态:`/skills``/skill use <id>``/skill off <id>``/skill active`
36+
- Skills 只影响提示注入与工具排序优先级,不改变工具执行入口;真实调用仍走 `Runtime -> Tool Manager -> Security -> Executor`
37+
- Skills 不提供权限豁免;命中 ask/deny 规则时行为与未启用 skill 保持一致。
38+
3339
## TUI 集成方式
3440
- 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择
3541
- runtime 事件以内联形式渲染到 transcript 中,而不是单独拆出控制台面板

internal/app/bootstrap_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1649,6 +1649,13 @@ func (s *stubRemoteRuntimeForBootstrap) ListSessionSkills(context.Context, strin
16491649
return nil, nil
16501650
}
16511651

1652+
func (s *stubRemoteRuntimeForBootstrap) ListAvailableSkills(
1653+
context.Context,
1654+
string,
1655+
) ([]agentruntime.AvailableSkillState, error) {
1656+
return nil, nil
1657+
}
1658+
16521659
func (s *stubRemoteRuntimeForBootstrap) Close() error {
16531660
s.closed = true
16541661
return nil

internal/cli/gateway_runtime_bridge_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ func (s *runtimeStub) ListSessionSkills(context.Context, string) ([]agentruntime
101101
return nil, nil
102102
}
103103

104+
func (s *runtimeStub) ListAvailableSkills(context.Context, string) ([]agentruntime.AvailableSkillState, error) {
105+
return nil, nil
106+
}
107+
104108
type runtimeWithoutCreator struct {
105109
base *runtimeStub
106110
}
@@ -145,6 +149,13 @@ func (r *runtimeWithoutCreator) ListSessionSkills(ctx context.Context, sessionID
145149
return r.base.ListSessionSkills(ctx, sessionID)
146150
}
147151

152+
func (r *runtimeWithoutCreator) ListAvailableSkills(
153+
ctx context.Context,
154+
sessionID string,
155+
) ([]agentruntime.AvailableSkillState, error) {
156+
return r.base.ListAvailableSkills(ctx, sessionID)
157+
}
158+
148159
func TestNewGatewayRuntimePortBridgeRuntimeUnavailable(t *testing.T) {
149160
bridge, err := newGatewayRuntimePortBridge(context.Background(), nil)
150161
if err == nil {

internal/runtime/run.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur
255255
if err != nil {
256256
return turnSnapshot{}, false, err
257257
}
258+
toolSpecs = prioritizeToolSpecsBySkillHints(toolSpecs, activeSkills)
258259

259260
resolvedProvider, err := config.ResolveSelectedProvider(cfg)
260261
if err != nil {

internal/runtime/runtime.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package runtime
33
import (
44
"context"
55
"errors"
6-
"os"
76
"fmt"
7+
"os"
88
"strings"
99
"sync"
1010
"time"
@@ -46,6 +46,7 @@ type Runtime interface {
4646
ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error
4747
DeactivateSessionSkill(ctx context.Context, sessionID string, skillID string) error
4848
ListSessionSkills(ctx context.Context, sessionID string) ([]SessionSkillState, error)
49+
ListAvailableSkills(ctx context.Context, sessionID string) ([]AvailableSkillState, error)
4950
}
5051

5152
// UserInput 描述一次用户输入请求的最小运行参数。

internal/runtime/skills.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package runtime
33
import (
44
"context"
55
"errors"
6+
"sort"
67
"strings"
78
"time"
89

10+
providertypes "neo-code/internal/provider/types"
911
agentsession "neo-code/internal/session"
1012
"neo-code/internal/skills"
1113
)
@@ -19,6 +21,12 @@ type SessionSkillState struct {
1921
Descriptor *skills.Descriptor
2022
}
2123

24+
// AvailableSkillState 描述当前可见 skill 的元信息及其在会话中的激活状态。
25+
type AvailableSkillState struct {
26+
Descriptor skills.Descriptor
27+
Active bool
28+
}
29+
2230
// ActivateSessionSkill 在 session 级激活一个已注册的 skill。
2331
func (s *Service) ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error {
2432
if err := ctx.Err(); err != nil {
@@ -113,6 +121,56 @@ func (s *Service) ListSessionSkills(ctx context.Context, sessionID string) ([]Se
113121
return states, nil
114122
}
115123

124+
// ListAvailableSkills 返回当前 registry 中对会话可见的技能列表,并标记激活状态。
125+
func (s *Service) ListAvailableSkills(ctx context.Context, sessionID string) ([]AvailableSkillState, error) {
126+
if err := ctx.Err(); err != nil {
127+
return nil, err
128+
}
129+
if s.skillsRegistry == nil {
130+
return nil, errSkillsRegistryUnavailable
131+
}
132+
133+
normalizedSessionID := strings.TrimSpace(sessionID)
134+
workspace := ""
135+
activeSet := map[string]struct{}{}
136+
if normalizedSessionID != "" {
137+
session, err := s.sessionStore.LoadSession(ctx, normalizedSessionID)
138+
if err != nil {
139+
return nil, err
140+
}
141+
activeSet = skillSetFromIDs(session.ActiveSkillIDs())
142+
if s.configManager != nil {
143+
workspace = agentsession.EffectiveWorkdir(session.Workdir, s.configManager.Get().Workdir)
144+
} else {
145+
workspace = strings.TrimSpace(session.Workdir)
146+
}
147+
} else if s.configManager != nil {
148+
workspace = strings.TrimSpace(s.configManager.Get().Workdir)
149+
}
150+
151+
descriptors, err := s.skillsRegistry.List(ctx, skills.ListInput{Workspace: workspace})
152+
if err != nil {
153+
return nil, err
154+
}
155+
if len(descriptors) == 0 {
156+
return nil, nil
157+
}
158+
159+
states := make([]AvailableSkillState, 0, len(descriptors))
160+
for _, descriptor := range descriptors {
161+
key := normalizeRuntimeSkillID(descriptor.ID)
162+
_, active := activeSet[key]
163+
states = append(states, AvailableSkillState{
164+
Descriptor: descriptor,
165+
Active: active,
166+
})
167+
}
168+
sort.Slice(states, func(i, j int) bool {
169+
return normalizeRuntimeSkillID(states[i].Descriptor.ID) < normalizeRuntimeSkillID(states[j].Descriptor.ID)
170+
})
171+
return states, nil
172+
}
173+
116174
// resolveActiveSkills 解析当前 session 激活的 skills,并对缺失项做事件降级。
117175
func (s *Service) resolveActiveSkills(ctx context.Context, state *runState) ([]skills.Skill, error) {
118176
if err := ctx.Err(); err != nil {
@@ -151,6 +209,42 @@ func (s *Service) resolveActiveSkills(ctx context.Context, state *runState) ([]s
151209
return resolved, nil
152210
}
153211

212+
// prioritizeToolSpecsBySkillHints 按激活 skill 的 tool_hints 调整工具顺序,仅影响提示优先级。
213+
func prioritizeToolSpecsBySkillHints(
214+
specs []providertypes.ToolSpec,
215+
activeSkills []skills.Skill,
216+
) []providertypes.ToolSpec {
217+
if len(specs) == 0 {
218+
return nil
219+
}
220+
hints := collectSkillToolHints(activeSkills)
221+
if len(hints) == 0 {
222+
return append([]providertypes.ToolSpec(nil), specs...)
223+
}
224+
225+
rank := make(map[string]int, len(hints))
226+
for idx, hint := range hints {
227+
rank[hint] = idx
228+
}
229+
prioritized := append([]providertypes.ToolSpec(nil), specs...)
230+
sort.SliceStable(prioritized, func(i, j int) bool {
231+
leftRank, leftHit := rank[normalizeRuntimeSkillID(prioritized[i].Name)]
232+
rightRank, rightHit := rank[normalizeRuntimeSkillID(prioritized[j].Name)]
233+
switch {
234+
case leftHit && rightHit:
235+
return leftRank < rightRank
236+
case leftHit:
237+
return true
238+
case rightHit:
239+
return false
240+
default:
241+
// 未命中的工具保持原有相对顺序,避免 hint 影响无关工具排序。
242+
return false
243+
}
244+
})
245+
return prioritized
246+
}
247+
154248
// emitSkillMissingOnce 在同一次 run 内只上报一次指定 skill 的缺失事件,避免重复噪音。
155249
func (s *Service) emitSkillMissingOnce(ctx context.Context, state *runState, skillID string) {
156250
if state == nil {
@@ -163,6 +257,45 @@ func (s *Service) emitSkillMissingOnce(ctx context.Context, state *runState, ski
163257
_ = s.emitRunScoped(ctx, EventSkillMissing, state, SessionSkillEventPayload{SkillID: skillID})
164258
}
165259

260+
// collectSkillToolHints 收集并规范化激活 skills 中的 tool_hints,用于工具排序提示。
261+
func collectSkillToolHints(activeSkills []skills.Skill) []string {
262+
if len(activeSkills) == 0 {
263+
return nil
264+
}
265+
out := make([]string, 0, len(activeSkills))
266+
seen := make(map[string]struct{}, len(activeSkills))
267+
for _, skill := range activeSkills {
268+
for _, hint := range skill.Content.ToolHints {
269+
normalized := normalizeRuntimeSkillID(hint)
270+
if normalized == "" {
271+
continue
272+
}
273+
if _, ok := seen[normalized]; ok {
274+
continue
275+
}
276+
seen[normalized] = struct{}{}
277+
out = append(out, normalized)
278+
}
279+
}
280+
return out
281+
}
282+
283+
// skillSetFromIDs 将技能 ID 列表转换为规范化集合,便于快速判断激活状态。
284+
func skillSetFromIDs(ids []string) map[string]struct{} {
285+
if len(ids) == 0 {
286+
return map[string]struct{}{}
287+
}
288+
set := make(map[string]struct{}, len(ids))
289+
for _, id := range ids {
290+
normalized := normalizeRuntimeSkillID(id)
291+
if normalized == "" {
292+
continue
293+
}
294+
set[normalized] = struct{}{}
295+
}
296+
return set
297+
}
298+
166299
// mutateSessionSkills 串行修改 session 的激活 skills,并在发生变化时立即持久化。
167300
func (s *Service) mutateSessionSkills(
168301
ctx context.Context,

0 commit comments

Comments
 (0)