返回 DeepSeek-Reasonix
skill_health.go
根目录 / internal / doctor / skill_health.go
1 package doctor
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/config"
8 "reasonix/internal/skill"
9 "reasonix/internal/tool"
10 )
11
12 // SkillHealthOptions configures skill/MCP capability diagnostics for doctor.
13 type SkillHealthOptions struct {
14 Skills []skill.Skill
15 Tools []tool.ContractEntry
16 Plugins []config.PluginEntry
17 // FailedServers maps MCP server name → host-proven failure reason.
18 FailedServers map[string]string
19 // CacheMismatch lists MCP servers whose schema cache fingerprint mismatched.
20 CacheMismatch []string
21 }
22
23 // CollectSkillHealthWarnings returns human-readable skill/MCP health warnings.
24 func CollectSkillHealthWarnings(opts SkillHealthOptions) []string {
25 var out []string
26 toolNames := map[string]bool{}
27 for _, t := range opts.Tools {
28 toolNames[t.Name] = true
29 }
30 pluginNames := map[string]bool{}
31 for _, p := range opts.Plugins {
32 pluginNames[strings.TrimSpace(p.Name)] = true
33 }
34
35 // Detect require skills with identical trigger sets (ambiguous conflicts).
36 requireTriggers := map[string][]string{} // key=sorted triggers → skill names
37
38 for _, sk := range opts.Skills {
39 name := sk.Name
40 desc := strings.TrimSpace(sk.Description)
41 if desc == "" || strings.Contains(desc, "no description") || desc == "(no description)" {
42 out = append(out, fmt.Sprintf("skill %q has a missing or placeholder description", name))
43 }
44 // Trigger / negative-trigger conflicts.
45 neg := map[string]bool{}
46 for _, n := range sk.NegativeTriggers {
47 neg[strings.ToLower(strings.TrimSpace(n))] = true
48 }
49 for _, tr := range sk.Triggers {
50 if neg[strings.ToLower(strings.TrimSpace(tr))] {
51 out = append(out, fmt.Sprintf("skill %q trigger %q also appears in negative-triggers", name, tr))
52 }
53 }
54 // auto-use require with missing dependencies.
55 if strings.EqualFold(sk.AutoUse, "require") {
56 for _, dep := range sk.Requires {
57 dep = strings.TrimSpace(dep)
58 if dep == "" {
59 continue
60 }
61 if strings.HasPrefix(dep, "mcp-server:") {
62 srv := strings.TrimPrefix(dep, "mcp-server:")
63 if !pluginNames[srv] {
64 out = append(out, fmt.Sprintf("skill %q requires %s but that MCP server is not configured", name, dep))
65 } else if reason, ok := opts.FailedServers[srv]; ok && reason != "" {
66 out = append(out, fmt.Sprintf("skill %q requires %s which is host-failed: %s", name, dep, reason))
67 }
68 }
69 }
70 key := strings.Join(normalizedTriggers(sk.Triggers), "|")
71 if key != "" {
72 requireTriggers[key] = append(requireTriggers[key], name)
73 }
74 }
75 // allowed-tools references unavailable tools.
76 for _, at := range sk.AllowedTools {
77 at = strings.TrimSpace(at)
78 if at == "" {
79 continue
80 }
81 if !toolNames[at] && !isBuiltinOrMetaTool(at) {
82 // Soft: only warn when the name looks concrete and missing.
83 out = append(out, fmt.Sprintf("skill %q allowed-tools references %q which is not in the current registry", name, at))
84 }
85 }
86 // The parser drops illegal profiles values from Profiles but preserves
87 // them in InvalidProfiles precisely so this check can reach them.
88 for _, p := range sk.InvalidProfiles {
89 out = append(out, fmt.Sprintf("skill %q has illegal profiles value %q (valid: economy, balanced, delivery)", name, p))
90 }
91 }
92
93 for key, names := range requireTriggers {
94 if len(names) > 1 {
95 out = append(out, fmt.Sprintf("multiple require skills share identical triggers [%s]: %s", key, strings.Join(names, ", ")))
96 }
97 }
98
99 for _, srv := range opts.CacheMismatch {
100 out = append(out, fmt.Sprintf("MCP server %q schema cache fingerprint mismatched; tools may be stale until reconnect", srv))
101 }
102 for srv, reason := range opts.FailedServers {
103 out = append(out, fmt.Sprintf("MCP server %q is in a host-failed state: %s", srv, reason))
104 }
105 return out
106 }
107
108 func normalizedTriggers(in []string) []string {
109 out := make([]string, 0, len(in))
110 for _, t := range in {
111 t = strings.ToLower(strings.TrimSpace(t))
112 if t != "" {
113 out = append(out, t)
114 }
115 }
116 // sort-like: simple insertion for small lists
117 for i := 1; i < len(out); i++ {
118 j := i
119 for j > 0 && out[j] < out[j-1] {
120 out[j], out[j-1] = out[j-1], out[j]
121 j--
122 }
123 }
124 return out
125 }
126
127 func isBuiltinOrMetaTool(name string) bool {
128 switch name {
129 case "bash", "read_file", "write_file", "edit_file", "grep", "glob", "ls",
130 "todo_write", "complete_step", "ask", "task", "read_only_task",
131 "parallel_tasks", "fleet",
132 "run_skill", "read_skill", "read_only_skill", "explore", "research",
133 "review", "security_review", "web_fetch", "multi_edit", "move_file",
134 "code_index", "wait", "bash_output", "kill_shell":
135 return true
136 default:
137 return strings.HasPrefix(name, "mcp__") || strings.HasPrefix(name, "lsp_")
138 }
139 }
140
140 lines GO