Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"context"
"log"
"path/filepath"
"strings"
"time"

Expand All @@ -19,6 +20,7 @@ import (
agentruntime "neo-code/internal/runtime"
"neo-code/internal/security"
agentsession "neo-code/internal/session"
"neo-code/internal/skills"
"neo-code/internal/tools"
"neo-code/internal/tools/bash"
"neo-code/internal/tools/filesystem"
Expand Down Expand Up @@ -165,6 +167,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
providerRegistry,
contextBuilder,
)
runtimeSvc.SetSkillsRegistry(buildSkillsRegistry(ctx, loader.BaseDir()))

// 注入记忆提取钩子:当 AutoExtract 启用且 memoSvc 可用时,ReAct 循环完成后异步提取记忆。
if memoSvc != nil && cfg.Memo.AutoExtract {
Expand Down Expand Up @@ -259,6 +262,16 @@ func buildToolRegistry(cfg config.Config) (*tools.Registry, func() error, error)
return toolRegistry, mcpRegistry.Close, nil
}

// buildSkillsRegistry 负责以最小代价初始化本地 skills registry,refresh 失败时仅记录日志并保留 registry 实例。
func buildSkillsRegistry(ctx context.Context, baseDir string) skills.Registry {
root := filepath.Join(baseDir, "skills")
registry := skills.NewRegistry(skills.NewLocalLoader(root))
if err := registry.Refresh(ctx); err != nil {
log.Printf("skills: initialize registry from %s failed: %v", root, err)
}
return registry
}

// buildMCPAgentExposureRules 将配置层的 agent 过滤规则转换为 tools/mcp 层输入。
func buildMCPAgentExposureRules(configs []config.MCPAgentExposureConfig) []mcp.AgentExposureRule {
if len(configs) == 0 {
Expand Down
107 changes: 107 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"neo-code/internal/provider"
providertypes "neo-code/internal/provider/types"
agentruntime "neo-code/internal/runtime"
agentsession "neo-code/internal/session"
"neo-code/internal/skills"
"neo-code/internal/tools"
"neo-code/internal/tools/mcp"
"neo-code/internal/tui"
Expand Down Expand Up @@ -736,6 +738,111 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) {
}
}

func TestBuildRuntimeSucceedsWhenSkillsRootMissing(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

bundle, err := BuildRuntime(context.Background(), BootstrapOptions{})
if err != nil {
t.Fatalf("BuildRuntime() error = %v", err)
}
if bundle.Runtime == nil {
t.Fatalf("expected runtime bundle to be created")
}

runtimeWithSkills, ok := bundle.Runtime.(interface {
ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error
})
if !ok {
t.Fatalf("expected runtime to expose ActivateSessionSkill")
}

store := agentsession.NewStore(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir)
session := agentsession.New("missing root session")
if err := store.Save(context.Background(), &session); err != nil {
t.Fatalf("save session: %v", err)
}

err = runtimeWithSkills.ActivateSessionSkill(context.Background(), session.ID, "missing")
if !errors.Is(err, skills.ErrSkillNotFound) {
t.Fatalf("expected ErrSkillNotFound with empty catalog, got %v", err)
}
}

func TestBuildRuntimeInjectsSkillsRegistryWhenRootExists(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

skillsRoot := filepath.Join(home, ".neocode", "skills", "go-review")
if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
t.Fatalf("mkdir skills root: %v", err)
}
if err := os.WriteFile(filepath.Join(skillsRoot, "SKILL.md"), []byte(strings.Join([]string{
"---",
"id: go-review",
"name: go-review",
"---",
"",
"# Go Review",
"",
"Review code carefully.",
}, "\n")), 0o644); err != nil {
t.Fatalf("write SKILL.md: %v", err)
}

bundle, err := BuildRuntime(context.Background(), BootstrapOptions{})
if err != nil {
t.Fatalf("BuildRuntime() error = %v", err)
}

store := agentsession.NewStore(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir)
session := agentsession.New("skill session")
if err := store.Save(context.Background(), &session); err != nil {
t.Fatalf("save session: %v", err)
}

runtimeWithSkills, ok := bundle.Runtime.(interface {
ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error
})
if !ok {
t.Fatalf("expected runtime to expose ActivateSessionSkill")
}
if err := runtimeWithSkills.ActivateSessionSkill(context.Background(), session.ID, "go-review"); err != nil {
t.Fatalf("ActivateSessionSkill() error = %v", err)
}

loaded, err := store.Load(context.Background(), session.ID)
if err != nil {
t.Fatalf("load session: %v", err)
}
if got := loaded.ActiveSkillIDs(); len(got) != 1 || got[0] != "go-review" {
t.Fatalf("expected activated skill persisted through injected registry, got %+v", got)
}
}

func TestBuildSkillsRegistryKeepsInstanceWhenRefreshFails(t *testing.T) {
t.Parallel()

baseDir := t.TempDir()
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()

registry := buildSkillsRegistry(canceledCtx, baseDir)
if registry == nil {
t.Fatalf("expected non-nil registry even when refresh fails")
}
_, _, err := registry.Get(context.Background(), "missing")
if !errors.Is(err, skills.ErrSkillNotFound) {
t.Fatalf("expected empty catalog behavior, got %v", err)
}
}

func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

Expand Down
2 changes: 2 additions & 0 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder {
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
skillPromptSource{},
systemSource,
},
trimPolicy: spanMessageTrimPolicy{},
Expand All @@ -42,6 +43,7 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
skillPromptSource{},
}
if memoSource != nil {
sources = append(sources, memoSource)
Expand Down
194 changes: 194 additions & 0 deletions internal/context/source_skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package context

import (
"context"
"fmt"
"sort"
"strings"

"neo-code/internal/skills"
)

const (
maxSkillReferences = 3
maxSkillToolHints = 3
maxSkillExamples = 2
)

// skillPromptSource 负责将当前轮次激活的 skills 渲染为统一的 prompt section。
type skillPromptSource struct{}

// Sections 根据 BuildInput.ActiveSkills 生成结构化的 Skills prompt section。
func (skillPromptSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) {
if err := ctx.Err(); err != nil {
return nil, err
}

rendered := renderActiveSkillsSection(input.ActiveSkills)
if strings.TrimSpace(rendered) == "" {
return nil, nil
}
return []promptSection{NewPromptSection("Skills", rendered)}, nil
}

// renderActiveSkillsSection 负责去重、排序并渲染激活 skills 的结构化提示文本。
func renderActiveSkillsSection(activeSkills []skills.Skill) string {
normalized := normalizeActiveSkills(activeSkills)
if len(normalized) == 0 {
return ""
}

parts := make([]string, 0, len(normalized))
for _, skill := range normalized {
rendered := renderOneSkill(skill)
if strings.TrimSpace(rendered) == "" {
continue
}
parts = append(parts, rendered)
}
return strings.Join(parts, "\n\n")
}

// renderOneSkill 将单个 skill 渲染为固定结构,避免 provider 侧看到不稳定格式。
func renderOneSkill(skill skills.Skill) string {
lines := []string{
fmt.Sprintf("- skill: %s (%s)", strings.TrimSpace(skill.Descriptor.Name), strings.TrimSpace(skill.Descriptor.ID)),
}

instruction := strings.TrimSpace(skill.Content.Instruction)
if instruction != "" {
lines = append(lines, " instruction: "+instruction)
}

toolHints := truncateSkillStrings(skill.Content.ToolHints, maxSkillToolHints)
if len(toolHints) > 0 {
lines = append(lines, " tool_hints: "+strings.Join(toolHints, " | "))
}

references := truncateSkillReferences(skill.Content.References, maxSkillReferences)
if len(references) > 0 {
lines = append(lines, " references: "+strings.Join(references, " | "))
}

examples := truncateSkillStrings(skill.Content.Examples, maxSkillExamples)
if len(examples) > 0 {
lines = append(lines, " examples: "+strings.Join(examples, " | "))
}

return strings.Join(lines, "\n")
}

// normalizeActiveSkills 对激活 skills 按规范化 ID 去重并稳定排序。
func normalizeActiveSkills(activeSkills []skills.Skill) []skills.Skill {
if len(activeSkills) == 0 {
return nil
}

byID := make(map[string]skills.Skill, len(activeSkills))
keys := make([]string, 0, len(activeSkills))
for _, skill := range activeSkills {
key := normalizeSkillID(skill.Descriptor.ID)
if key == "" {
continue
}
if _, ok := byID[key]; ok {
continue
}
byID[key] = skill
keys = append(keys, key)
}
if len(keys) == 0 {
return nil
}

sort.Strings(keys)
normalized := make([]skills.Skill, 0, len(keys))
for _, key := range keys {
normalized = append(normalized, byID[key])
}
return normalized
}

// truncateSkillStrings 对 skill 文本列表做去空、去重并按上限裁剪。
func truncateSkillStrings(values []string, limit int) []string {
if len(values) == 0 || limit <= 0 {
return nil
}

result := make([]string, 0, min(limit, len(values)))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
if len(result) >= limit {
break
}
}
return result
}

// truncateSkillReferences 优先保留标题与摘要,并按固定上限裁剪引用条目。
func truncateSkillReferences(references []skills.Reference, limit int) []string {
if len(references) == 0 || limit <= 0 {
return nil
}

result := make([]string, 0, min(limit, len(references)))
seen := make(map[string]struct{}, len(references))
for _, reference := range references {
title := strings.TrimSpace(reference.Title)
summary := strings.TrimSpace(reference.Summary)
path := strings.TrimSpace(reference.Path)

var rendered string
switch {
case title != "" && summary != "":
rendered = title + ": " + summary
case title != "":
rendered = title
case summary != "":
rendered = summary
default:
rendered = path
}
rendered = strings.TrimSpace(rendered)
if rendered == "" {
continue
}
if _, ok := seen[rendered]; ok {
continue
}
seen[rendered] = struct{}{}
result = append(result, rendered)
if len(result) >= limit {
break
}
}
return result
}

// normalizeSkillID 将 skill id 规范化为去重与排序使用的稳定键。
func normalizeSkillID(skillID string) string {
normalized := strings.ToLower(strings.TrimSpace(skillID))
if normalized == "" {
return ""
}
normalized = strings.ReplaceAll(normalized, "_", "-")
normalized = strings.ReplaceAll(normalized, " ", "-")
return strings.Trim(normalized, "-")
}

// min 返回两个整数中的较小值,供固定上限裁剪逻辑复用。
func min(a int, b int) int {
if a < b {
return a
}
return b
}
Loading
Loading