| 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 |