返回 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 concrete tools that are not on the provider-visible registry:
183 // live proxy-observed tools once the server is connected (proxied
184 // servers never register), cached schema before any connection exists.
185 registryHasTools := false
186 prefix := plugin.ToolPrefix(name)
187 for _, te := range opts.Tools {
188 if strings.HasPrefix(te.Name, prefix) {
189 registryHasTools = true
190 break
191 }
192 }
193 var toolSrc []plugin.CachedTool
194 toolStatus := StatusConfigured
195 switch {
196 case status == StatusReady && len(opts.ProxyTools[name]) > 0 && !registryHasTools:
197 toolSrc = opts.ProxyTools[name]
198 toolStatus = StatusReady
199 case status != StatusReady:
200 toolSrc = opts.CachedTools[name]
201 // Cached tools share the server lifecycle. A failed or disabled
202 // server cannot make a stale schema actionable, and a cache-key
203 // mismatch keeps the same staleness on every cached tool.
204 toolStatus = status
205 }
206 for _, ct := range toolSrc {
207 raw := strings.TrimSpace(ct.Name)
208 if raw == "" || !ct.ToolIsModelVisible() {
209 // App-only tools stay in the server-private App catalog.
210 continue
211 }
212 out = append(out, Entry{
213 ID: "mcp-tool:" + name + "/" + raw,
214 Kind: KindMCPTool,
215 Name: name + "/" + raw,
216 Description: strings.TrimSpace(ct.Description),
217 Source: name,
218 Status: toolStatus,
219 ReadOnly: ct.ReadOnly,
220 Destructive: ct.Destructive,
221 ToolName: plugin.ModelToolName(name, raw),
222 ConnectSource: "mcp",
223 ConnectName: name,
224 AutoStart: p.ShouldAutoStart(),
225 })
226 }
227 }
228 return out
229 }
230
231 // normalizeProfiles keeps legacy frontmatter profile labels for diagnostics.
232 // The values are deprecated execution-mode names; they never gate visibility.
233 func normalizeProfiles(in []string) []string {
234 var out []string
235 seen := map[string]bool{}
236 for _, p := range in {
237 p = strings.ToLower(strings.TrimSpace(p))
238 switch p {
239 case "economy", "balanced", "delivery":
240 if !seen[p] {
241 seen[p] = true
242 out = append(out, p)
243 }
244 }
245 }
246 return out
247 }
248
249 func rankStatus(s Status) int {
250 switch s {
251 case StatusReady:
252 return 4
253 case StatusConfigured:
254 return 3
255 case StatusStale:
256 return 2
257 case StatusFailed:
258 return 1
259 case StatusDisabled:
260 return 0
261 default:
262 return 0
263 }
264 }
265
266 func catalogFingerprint(entries []Entry) string {
267 h := sha256.New()
268 for _, e := range entries {
269 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)
270 }
271 return hex.EncodeToString(h.Sum(nil))[:16]
272 }
273
274 // Lookup returns the entry with the given capability ID.
275 func (c Catalog) Lookup(id string) (Entry, bool) {
276 id = strings.TrimSpace(id)
277 for _, e := range c.Entries {
278 if e.ID == id {
279 return e, true
280 }
281 }
282 return Entry{}, false
283 }
284
285 // RequiresReady reports whether every required dependency is ready.
286 func (c Catalog) RequiresReady(requires []string) (ready bool, missing []string) {
287 for _, dep := range requires {
288 dep = strings.TrimSpace(dep)
289 if dep == "" {
290 continue
291 }
292 e, ok := c.Lookup(dep)
293 if !ok || e.Status != StatusReady {
294 missing = append(missing, dep)
295 }
296 }
297 return len(missing) == 0, missing
298 }
299
299 lines GO