返回 DeepSeek-Reasonix
catalog.go
根目录 / internal / capability / catalog.go
1 package capability
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "sort"
8 "strings"
9
10 "reasonix/internal/config"
11 "reasonix/internal/plugin"
12 "reasonix/internal/skill"
13 "reasonix/internal/tool"
14 )
15
16 // Profile filters which skills are eligible in a given runtime profile.
17 type Profile string
18
19 const (
20 ProfileEconomy Profile = "economy"
21 ProfileBalanced Profile = "balanced"
22 ProfileDelivery Profile = "delivery"
23 )
24
25 // Catalog is the unified capability inventory for one routing turn.
26 type Catalog struct {
27 Entries []Entry
28 Fingerprint string
29 }
30
31 // CatalogOptions builds a catalog from live tools, skills, configured MCP
32 // servers (including auto_start=false), schema cache, and host failure state.
33 type CatalogOptions struct {
34 Tools []tool.ContractEntry
35 Skills []skill.Skill
36 Plugins []config.PluginEntry
37 Profile Profile
38 Connected map[string]bool // server name → connected
39 Failed map[string]string
40 Disabled map[string]bool
41 CachedTools map[string][]plugin.CachedTool // server → tools
42 CacheKeyOK map[string]bool // server → schema-cache key match
43 // ProxyTools carries host-observed live tools of servers connected through
44 // the Delivery proxy: they are absent from Tools (never registered) yet
45 // must stay routable after the server turns ready.
46 ProxyTools map[string][]plugin.CachedTool
47 }
48
49 // LoadCachedToolsForSpecs loads the persisted MCP schema caches for the given
50 // boot-converted specs, keyed by server name, plus the per-server cache-key
51 // match state. Mismatched caches are still returned (with
52 // CacheKeyOK=false) so MCPServerEntries can mark them stale instead of
53 // hiding them; servers without a usable cache are simply absent. Call once at
54 // session start and reuse — the cache lives on disk.
55 func LoadCachedToolsForSpecs(specs []plugin.Spec) (map[string][]plugin.CachedTool, map[string]bool) {
56 cached := map[string][]plugin.CachedTool{}
57 keyOK := map[string]bool{}
58 for _, s := range specs {
59 name := strings.TrimSpace(s.Name)
60 if name == "" {
61 continue
62 }
63 cs, ok, match := plugin.LoadCachedSchemaAny(name, plugin.SchemaCacheKey(s))
64 if !ok || len(cs.Tools) == 0 {
65 continue
66 }
67 cached[name] = cs.Tools
68 keyOK[name] = match
69 }
70 return cached, keyOK
71 }
72
73 // BuildCatalog assembles the unified capability directory.
74 func BuildCatalog(opts CatalogOptions) Catalog {
75 profile := opts.Profile
76 if profile == "" {
77 profile = ProfileBalanced
78 }
79 var entries []Entry
80 entries = append(entries, ToolEntries(opts.Tools)...)
81 entries = append(entries, SkillEntriesFiltered(opts.Skills, opts.Tools, profile)...)
82 entries = append(entries, MCPServerEntries(opts)...)
83
84 // Deduplicate by ID, preferring ready over configured.
85 byID := map[string]Entry{}
86 order := make([]string, 0, len(entries))
87 for _, e := range entries {
88 if prev, ok := byID[e.ID]; ok {
89 if rankStatus(e.Status) > rankStatus(prev.Status) {
90 byID[e.ID] = e
91 }
92 continue
93 }
94 byID[e.ID] = e
95 order = append(order, e.ID)
96 }
97 out := make([]Entry, 0, len(order))
98 for _, id := range order {
99 out = append(out, byID[id])
100 }
101 sort.SliceStable(out, func(i, j int) bool {
102 if out[i].Kind != out[j].Kind {
103 return out[i].Kind < out[j].Kind
104 }
105 return out[i].ID < out[j].ID
106 })
107 return Catalog{Entries: out, Fingerprint: catalogFingerprint(out)}
108 }
109
110 // SkillEntriesFiltered applies profile eligibility and requires metadata.
111 func SkillEntriesFiltered(skills []skill.Skill, tools []tool.ContractEntry, profile Profile) []Entry {
112 out := SkillEntries(skills, tools)
113 filtered := make([]Entry, 0, len(out))
114 for i, e := range out {
115 sk := skills[i]
116 if !skill.AllowedInProfile(sk, string(profile)) {
117 continue
118 }
119 e.Requires = cleanList(sk.Requires)
120 e.Profiles = normalizeProfiles(sk.Profiles)
121 // Status stays ready when listed; callers re-check requires against the
122 // live catalog at invoke time so routing can still recommend the skill.
123 filtered = append(filtered, e)
124 }
125 return filtered
126 }
127
128 // MCPServerEntries includes every configured MCP, even when not auto-started.
129 func MCPServerEntries(opts CatalogOptions) []Entry {
130 var out []Entry
131 seen := map[string]bool{}
132 for _, p := range opts.Plugins {
133 name := strings.TrimSpace(p.Name)
134 if name == "" || seen[name] {
135 continue
136 }
137 seen[name] = true
138 status := StatusConfigured
139 if opts.Disabled != nil && opts.Disabled[name] {
140 status = StatusDisabled
141 } else if opts.Failed != nil && opts.Failed[name] != "" {
142 status = StatusFailed
143 } else if opts.Connected != nil && opts.Connected[name] {
144 status = StatusReady
145 } else if opts.CacheKeyOK != nil && !opts.CacheKeyOK[name] && opts.CachedTools != nil && len(opts.CachedTools[name]) > 0 {
146 status = StatusStale
147 }
148 e := Entry{
149 ID: "mcp-server:" + name,
150 Kind: KindMCPServer,
151 Name: name,
152 Description: "MCP server " + name,
153 Source: name,
154 Status: status,
155 ConnectSource: "mcp",
156 ConnectName: name,
157 AutoStart: p.ShouldAutoStart(),
158 }
159 if reason, ok := opts.Failed[name]; ok && reason != "" {
160 e.FailureReason = reason
161 }
162 out = append(out, e)
163
164 // Surface concrete tools that are not on the provider-visible registry:
165 // live proxy-observed tools once the server is connected (proxied
166 // servers never register), cached schema before any connection exists.
167 registryHasTools := false
168 prefix := plugin.ToolPrefix(name)
169 for _, te := range opts.Tools {
170 if strings.HasPrefix(te.Name, prefix) {
171 registryHasTools = true
172 break
173 }
174 }
175 var toolSrc []plugin.CachedTool
176 toolStatus := StatusConfigured
177 switch {
178 case len(opts.ProxyTools[name]) > 0 && !registryHasTools:
179 toolSrc = opts.ProxyTools[name]
180 toolStatus = StatusReady
181 case status != StatusReady:
182 toolSrc = opts.CachedTools[name]
183 // A schema-cache-key mismatch marked the server stale; its
184 // tools carry the same staleness so routing prompts expose it.
185 if status == StatusStale {
186 toolStatus = StatusStale
187 }
188 }
189 for _, ct := range toolSrc {
190 raw := strings.TrimSpace(ct.Name)
191 if raw == "" {
192 continue
193 }
194 out = append(out, Entry{
195 ID: "mcp-tool:" + name + "/" + raw,
196 Kind: KindMCPTool,
197 Name: name + "/" + raw,
198 Description: strings.TrimSpace(ct.Description),
199 Source: name,
200 Status: toolStatus,
201 ReadOnly: ct.ReadOnly,
202 Destructive: ct.Destructive,
203 ToolName: plugin.ModelToolName(name, raw),
204 ConnectSource: "mcp",
205 ConnectName: name,
206 AutoStart: p.ShouldAutoStart(),
207 })
208 }
209 }
210 return out
211 }
212
213 func normalizeProfiles(in []string) []string {
214 var out []string
215 seen := map[string]bool{}
216 for _, p := range in {
217 p = strings.ToLower(strings.TrimSpace(p))
218 switch p {
219 case string(ProfileEconomy), string(ProfileBalanced), string(ProfileDelivery):
220 if !seen[p] {
221 seen[p] = true
222 out = append(out, p)
223 }
224 }
225 }
226 return out
227 }
228
229 func rankStatus(s Status) int {
230 switch s {
231 case StatusReady:
232 return 4
233 case StatusConfigured:
234 return 3
235 case StatusStale:
236 return 2
237 case StatusFailed:
238 return 1
239 case StatusDisabled:
240 return 0
241 default:
242 return 0
243 }
244 }
245
246 func catalogFingerprint(entries []Entry) string {
247 h := sha256.New()
248 for _, e := range entries {
249 fmt.Fprintf(h, "%s|%s|%s|%v\n", e.ID, e.Kind, e.Status, e.AutoUse)
250 }
251 return hex.EncodeToString(h.Sum(nil))[:16]
252 }
253
254 // Lookup returns the entry with the given capability ID.
255 func (c Catalog) Lookup(id string) (Entry, bool) {
256 id = strings.TrimSpace(id)
257 for _, e := range c.Entries {
258 if e.ID == id {
259 return e, true
260 }
261 }
262 return Entry{}, false
263 }
264
265 // RequiresReady reports whether every required dependency is ready.
266 func (c Catalog) RequiresReady(requires []string) (ready bool, missing []string) {
267 for _, dep := range requires {
268 dep = strings.TrimSpace(dep)
269 if dep == "" {
270 continue
271 }
272 e, ok := c.Lookup(dep)
273 if !ok || e.Status != StatusReady {
274 missing = append(missing, dep)
275 }
276 }
277 return len(missing) == 0, missing
278 }
279
279 lines GO