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