返回 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 // Catalog is the unified capability inventory for one routing turn.
17 type Catalog struct {
18 Entries []Entry
19 Fingerprint string
20 Incomplete bool
21 Stale bool
22 }
23
24 // CatalogOptions builds a catalog from live tools, skills, configured MCP
25 // servers (including auto_start=false), schema cache, and host failure state.
26 type CatalogOptions struct {
27 Tools []tool.ContractEntry
28 Skills []skill.Skill
29 Plugins []config.PluginEntry
30 Connected map[string]bool // server name → connected
31 Failed map[string]string
32 Disabled map[string]bool
33 CachedTools map[string][]plugin.CachedTool // server → tools
34 CacheKeyOK map[string]bool // server → schema-cache key match
35 // CatalogIncomplete/Stale describe the discovery snapshot used for Skills.
36 CatalogIncomplete bool
37 CatalogStale bool
38 // ProxyTools carries host-observed live tools of servers connected through
39 // the use_capability proxy: they are absent from Tools (never registered)
40 // yet must stay routable after the server turns ready.
41 ProxyTools map[string][]plugin.CachedTool
42 }
43
44 // LoadCachedToolsForSpecs loads the persisted MCP schema caches for the given
45 // boot-converted specs, keyed by server name, plus the per-server cache-key
46 // match state. Mismatched caches are still returned (with
47 // CacheKeyOK=false) so MCPServerEntries can mark them stale instead of
48 // hiding them; servers without a usable cache are simply absent. Call once at
49 // session start and reuse — the cache lives on disk. The profile selects the
50 // cache identity: capability-declaring profiles never read the legacy shared
51 // file, whose catalog was negotiated under different client capabilities.
52 func LoadCachedToolsForSpecs(specs []plugin.Spec, profile plugin.HostProfile) (map[string][]plugin.CachedTool, map[string]bool) {
53 cached := map[string][]plugin.CachedTool{}
54 keyOK := map[string]bool{}
55 if profile.UsesEnhancedCache() {
56 for _, s := range specs {
57 name := strings.TrimSpace(s.Name)
58 if name == "" {
59 continue
60 }
61 if cs, ok := plugin.LoadCachedSchemaForSpecProfile(s, profile); ok && len(cs.Tools) > 0 {
62 cached[name] = cs.Tools
63 keyOK[name] = true
64 }
65 }
66 return cached, keyOK
67 }
68 for _, s := range specs {
69 name := strings.TrimSpace(s.Name)
70 if name == "" {
71 continue
72 }
73 cs, ok, match := plugin.LoadCachedSchemaAny(name, plugin.SchemaCacheKey(s))
74 if !ok || len(cs.Tools) == 0 {
75 continue
76 }
77 cached[name] = cs.Tools
78 keyOK[name] = match
79 }
80 return cached, keyOK
81 }
82
83 // BuildCatalog assembles the unified capability directory. Every execution
84 // shares one catalog; task risk never changes skill visibility or tool sets.
85 func BuildCatalog(opts CatalogOptions) Catalog {
86 var entries []Entry
87 toolEntries := ToolEntries(opts.Tools)
88 for i := range toolEntries {
89 if toolEntries[i].Kind != KindMCPTool {
90 continue
91 }
92 name := toolEntries[i].Source
93 switch {
94 case opts.Disabled != nil && opts.Disabled[name]:
95 toolEntries[i].Status = StatusDisabled
96 case opts.Failed != nil && opts.Failed[name] != "":
97 toolEntries[i].Status = StatusFailed
98 toolEntries[i].FailureReason = opts.Failed[name]
99 }
100 }
101 entries = append(entries, toolEntries...)
102 entries = append(entries, SkillEntriesForCatalog(opts.Skills, opts.Tools)...)
103 entries = append(entries, MCPServerEntries(opts)...)
104
105 // Deduplicate by ID, preferring ready over configured.
106 byID := map[string]Entry{}
107 order := make([]string, 0, len(entries))
108 for _, e := range entries {
109 if prev, ok := byID[e.ID]; ok {
110 if rankStatus(e.Status) > rankStatus(prev.Status) {
111 byID[e.ID] = e
112 }
113 continue
114 }
115 byID[e.ID] = e
116 order = append(order, e.ID)
117 }
118 out := make([]Entry, 0, len(order))
119 for _, id := range order {
120 out = append(out, byID[id])
121 }
122 sort.SliceStable(out, func(i, j int) bool {
123 if out[i].Kind != out[j].Kind {
124 return out[i].Kind < out[j].Kind
125 }
126 return out[i].ID < out[j].ID
127 })
128 return Catalog{Entries: out, Fingerprint: catalogFingerprint(out), Incomplete: opts.CatalogIncomplete, Stale: opts.CatalogStale}
129 }
130
131 // SkillEntriesForCatalog keeps every skill in the catalog. Legacy frontmatter
132 // profiles: economy|balanced|delivery values are parsed and retained for
133 // diagnostics only; they never filter availability — the capability directory
134 // is shared by every task.
135 func SkillEntriesForCatalog(skills []skill.Skill, tools []tool.ContractEntry) []Entry {
136 out := SkillEntries(skills, tools)
137 for i := range out {
138 if i < len(skills) {
139 out[i].Requires = cleanList(skills[i].Requires)
140 out[i].Profiles = normalizeProfiles(skills[i].Profiles)
141 }
142 }
143 return out
144 }
145
146 // MCPServerEntries includes every configured MCP, even when not auto-started.
147 func MCPServerEntries(opts CatalogOptions) []Entry {
148 var out []Entry
149 seen := map[string]bool{}
150 for _, p := range opts.Plugins {
151 name := strings.TrimSpace(p.Name)
152 if name == "" || seen[name] {
153 continue
154 }
155 seen[name] = true
156 status := StatusConfigured
157 if opts.Disabled != nil && opts.Disabled[name] {
158 status = StatusDisabled
159 } else if opts.Failed != nil && opts.Failed[name] != "" {
160 status = StatusFailed
161 } else if opts.Connected != nil && opts.Connected[name] {
162 status = StatusReady
163 } else if opts.CacheKeyOK != nil && !opts.CacheKeyOK[name] && opts.CachedTools != nil && len(opts.CachedTools[name]) > 0 {
164 status = StatusStale
165 }
166 e := Entry{
167 ID: "mcp-server:" + name,
168 Kind: KindMCPServer,
169 Name: name,
170 Description: "MCP server " + name,
171 Source: name,
172 Status: status,
173 ConnectSource: "mcp",
174 ConnectName: name,
175 AutoStart: p.ShouldAutoStart(),
176 }
177 if reason, ok := opts.Failed[name]; ok && reason != "" {
178 e.FailureReason = reason
179 }
180 out = append(out, e)
181
182 // Surface tools missing from the provider-visible registry: live proxy
183 // tools once connected, cached schema before. Pinned registrations can lag
184 // the live server, so a registered tool hides only itself, not the rest.
185 registered := map[string]bool{}
186 prefix := plugin.ToolPrefix(name)
187 for _, te := range opts.Tools {
188 if strings.HasPrefix(te.Name, prefix) {
189 registered[te.Name] = true
190 }
191 }
192 var toolSrc []plugin.CachedTool
193 toolStatus := StatusConfigured
194 switch {
195 case status == StatusReady && len(opts.ProxyTools[name]) > 0:
196 toolSrc = opts.ProxyTools[name]
197 toolStatus = StatusReady
198 case status != StatusReady:
199 toolSrc = opts.CachedTools[name]
200 // Cached tools share the server lifecycle. A failed or disabled
201 // server cannot make a stale schema actionable, and a cache-key
202 // mismatch keeps the same staleness on every cached tool.
203 toolStatus = status
204 }
205 for _, ct := range toolSrc {
206 raw := strings.TrimSpace(ct.Name)
207 if raw == "" || !ct.ToolIsModelVisible() {
208 // App-only tools stay in the server-private App catalog.
209 continue
210 }
211 if toolStatus == StatusReady && registered[plugin.ModelToolName(name, raw)] {
212 continue
213 }
214 out = append(out, Entry{
215 ID: "mcp-tool:" + name + "/" + raw,
216 Kind: KindMCPTool,
217 Name: name + "/" + raw,
218 Description: strings.TrimSpace(ct.Description),
219 Source: name,
220 Status: toolStatus,
221 ReadOnly: ct.ReadOnly,
222 Destructive: ct.Destructive,
223 ToolName: plugin.ModelToolName(name, raw),
224 ConnectSource: "mcp",
225 ConnectName: name,
226 AutoStart: p.ShouldAutoStart(),
227 })
228 }
229 }
230 return out
231 }
232
233 // normalizeProfiles keeps legacy frontmatter profile labels for diagnostics.
234 // The values are deprecated execution-mode names; they never gate visibility.
235 func normalizeProfiles(in []string) []string {
236 var out []string
237 seen := map[string]bool{}
238 for _, p := range in {
239 p = strings.ToLower(strings.TrimSpace(p))
240 switch p {
241 case "economy", "balanced", "delivery":
242 if !seen[p] {
243 seen[p] = true
244 out = append(out, p)
245 }
246 }
247 }
248 return out
249 }
250
251 func rankStatus(s Status) int {
252 switch s {
253 case StatusReady:
254 return 4
255 case StatusConfigured:
256 return 3
257 case StatusStale:
258 return 2
259 case StatusFailed:
260 return 1
261 case StatusDisabled:
262 return 0
263 default:
264 return 0
265 }
266 }
267
268 func catalogFingerprint(entries []Entry) string {
269 h := sha256.New()
270 for _, e := range entries {
271 fmt.Fprintf(h, "%s|%s|%s|%v|%s|%s|%t|%s\n", e.ID, e.Kind, e.Status, e.AutoUse, e.Name, e.Description, e.ReadOnly, e.SkillRunAs)
272 }
273 return hex.EncodeToString(h.Sum(nil))[:16]
274 }
275
276 // Lookup returns the entry with the given capability ID.
277 func (c Catalog) Lookup(id string) (Entry, bool) {
278 id = strings.TrimSpace(id)
279 for _, e := range c.Entries {
280 if e.ID == id {
281 return e, true
282 }
283 }
284 return Entry{}, false
285 }
286
287 // RequiresReady reports whether every required dependency is ready.
288 func (c Catalog) RequiresReady(requires []string) (ready bool, missing []string) {
289 for _, dep := range requires {
290 dep = strings.TrimSpace(dep)
291 if dep == "" {
292 continue
293 }
294 e, ok := c.Lookup(dep)
295 if !ok || e.Status != StatusReady {
296 missing = append(missing, dep)
297 }
298 }
299 return len(missing) == 0, missing
300 }
301
301 lines GO