278 lines
7.8 KiB
Go
278 lines
7.8 KiB
Go
package capability
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/plugin"
|
|
"reasonix/internal/skill"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// Profile filters which skills are eligible in a given runtime profile.
|
|
type Profile string
|
|
|
|
const (
|
|
ProfileEconomy Profile = "economy"
|
|
ProfileBalanced Profile = "balanced"
|
|
ProfileDelivery Profile = "delivery"
|
|
)
|
|
|
|
// Catalog is the unified capability inventory for one routing turn.
|
|
type Catalog struct {
|
|
Entries []Entry
|
|
Fingerprint string
|
|
}
|
|
|
|
// CatalogOptions builds a catalog from live tools, skills, configured MCP
|
|
// servers (including auto_start=false), schema cache, and host failure state.
|
|
type CatalogOptions struct {
|
|
Tools []tool.ContractEntry
|
|
Skills []skill.Skill
|
|
Plugins []config.PluginEntry
|
|
Profile Profile
|
|
Connected map[string]bool // server name → connected
|
|
Failed map[string]string
|
|
Disabled map[string]bool
|
|
CachedTools map[string][]plugin.CachedTool // server → tools
|
|
CacheHashOK map[string]bool // server → fingerprint match
|
|
// ProxyTools carries host-observed live tools of servers connected through
|
|
// the Delivery proxy: they are absent from Tools (never registered) yet
|
|
// must stay routable after the server turns ready.
|
|
ProxyTools map[string][]plugin.CachedTool
|
|
}
|
|
|
|
// LoadCachedToolsForSpecs loads the persisted MCP schema caches for the given
|
|
// boot-converted specs, keyed by server name, plus the per-server fingerprint
|
|
// match state. Fingerprint-mismatched caches are still returned (with
|
|
// CacheHashOK=false) so MCPServerEntries can mark them stale instead of
|
|
// hiding them; servers without a usable cache are simply absent. Call once at
|
|
// session start and reuse — the cache lives on disk.
|
|
func LoadCachedToolsForSpecs(specs []plugin.Spec) (map[string][]plugin.CachedTool, map[string]bool) {
|
|
cached := map[string][]plugin.CachedTool{}
|
|
hashOK := map[string]bool{}
|
|
for _, s := range specs {
|
|
name := strings.TrimSpace(s.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
cs, ok, match := plugin.LoadCachedSchemaAny(name, plugin.SpecFingerprint(s))
|
|
if !ok || len(cs.Tools) == 0 {
|
|
continue
|
|
}
|
|
cached[name] = cs.Tools
|
|
hashOK[name] = match
|
|
}
|
|
return cached, hashOK
|
|
}
|
|
|
|
// BuildCatalog assembles the unified capability directory.
|
|
func BuildCatalog(opts CatalogOptions) Catalog {
|
|
profile := opts.Profile
|
|
if profile == "" {
|
|
profile = ProfileBalanced
|
|
}
|
|
var entries []Entry
|
|
entries = append(entries, ToolEntries(opts.Tools)...)
|
|
entries = append(entries, SkillEntriesFiltered(opts.Skills, opts.Tools, profile)...)
|
|
entries = append(entries, MCPServerEntries(opts)...)
|
|
|
|
// Deduplicate by ID, preferring ready over configured.
|
|
byID := map[string]Entry{}
|
|
order := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if prev, ok := byID[e.ID]; ok {
|
|
if rankStatus(e.Status) > rankStatus(prev.Status) {
|
|
byID[e.ID] = e
|
|
}
|
|
continue
|
|
}
|
|
byID[e.ID] = e
|
|
order = append(order, e.ID)
|
|
}
|
|
out := make([]Entry, 0, len(order))
|
|
for _, id := range order {
|
|
out = append(out, byID[id])
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].Kind != out[j].Kind {
|
|
return out[i].Kind < out[j].Kind
|
|
}
|
|
return out[i].ID < out[j].ID
|
|
})
|
|
return Catalog{Entries: out, Fingerprint: catalogFingerprint(out)}
|
|
}
|
|
|
|
// SkillEntriesFiltered applies profile eligibility and requires metadata.
|
|
func SkillEntriesFiltered(skills []skill.Skill, tools []tool.ContractEntry, profile Profile) []Entry {
|
|
out := SkillEntries(skills, tools)
|
|
filtered := make([]Entry, 0, len(out))
|
|
for i, e := range out {
|
|
sk := skills[i]
|
|
if !skill.AllowedInProfile(sk, string(profile)) {
|
|
continue
|
|
}
|
|
e.Requires = cleanList(sk.Requires)
|
|
e.Profiles = normalizeProfiles(sk.Profiles)
|
|
// Status stays ready when listed; callers re-check requires against the
|
|
// live catalog at invoke time so routing can still recommend the skill.
|
|
filtered = append(filtered, e)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// MCPServerEntries includes every configured MCP, even when not auto-started.
|
|
func MCPServerEntries(opts CatalogOptions) []Entry {
|
|
var out []Entry
|
|
seen := map[string]bool{}
|
|
for _, p := range opts.Plugins {
|
|
name := strings.TrimSpace(p.Name)
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
status := StatusConfigured
|
|
if opts.Disabled != nil && opts.Disabled[name] {
|
|
status = StatusDisabled
|
|
} else if opts.Failed != nil && opts.Failed[name] != "" {
|
|
status = StatusFailed
|
|
} else if opts.Connected != nil && opts.Connected[name] {
|
|
status = StatusReady
|
|
} else if opts.CacheHashOK != nil && !opts.CacheHashOK[name] && opts.CachedTools != nil && len(opts.CachedTools[name]) > 0 {
|
|
status = StatusStale
|
|
}
|
|
e := Entry{
|
|
ID: "mcp-server:" + name,
|
|
Kind: KindMCPServer,
|
|
Name: name,
|
|
Description: "MCP server " + name,
|
|
Source: name,
|
|
Status: status,
|
|
ConnectSource: "mcp",
|
|
ConnectName: name,
|
|
AutoStart: p.ShouldAutoStart(),
|
|
}
|
|
if reason, ok := opts.Failed[name]; ok && reason != "" {
|
|
e.FailureReason = reason
|
|
}
|
|
out = append(out, e)
|
|
|
|
// Surface concrete tools that are not on the provider-visible registry:
|
|
// live proxy-observed tools once the server is connected (proxied
|
|
// servers never register), cached schema before any connection exists.
|
|
registryHasTools := false
|
|
prefix := plugin.ToolPrefix(name)
|
|
for _, te := range opts.Tools {
|
|
if strings.HasPrefix(te.Name, prefix) {
|
|
registryHasTools = true
|
|
break
|
|
}
|
|
}
|
|
var toolSrc []plugin.CachedTool
|
|
toolStatus := StatusConfigured
|
|
switch {
|
|
case len(opts.ProxyTools[name]) > 0 && !registryHasTools:
|
|
toolSrc = opts.ProxyTools[name]
|
|
toolStatus = StatusReady
|
|
case status != StatusReady:
|
|
toolSrc = opts.CachedTools[name]
|
|
// A fingerprint-mismatched cache marked the server stale; its
|
|
// tools carry the same staleness so routing prompts expose it.
|
|
if status == StatusStale {
|
|
toolStatus = StatusStale
|
|
}
|
|
}
|
|
for _, ct := range toolSrc {
|
|
raw := strings.TrimSpace(ct.Name)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
out = append(out, Entry{
|
|
ID: "mcp-tool:" + name + "/" + raw,
|
|
Kind: KindMCPTool,
|
|
Name: name + "/" + raw,
|
|
Description: strings.TrimSpace(ct.Description),
|
|
Source: name,
|
|
Status: toolStatus,
|
|
ReadOnly: ct.ReadOnly,
|
|
ToolName: plugin.ModelToolName(name, raw),
|
|
ConnectSource: "mcp",
|
|
ConnectName: name,
|
|
AutoStart: p.ShouldAutoStart(),
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeProfiles(in []string) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, p := range in {
|
|
p = strings.ToLower(strings.TrimSpace(p))
|
|
switch p {
|
|
case string(ProfileEconomy), string(ProfileBalanced), string(ProfileDelivery):
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func rankStatus(s Status) int {
|
|
switch s {
|
|
case StatusReady:
|
|
return 4
|
|
case StatusConfigured:
|
|
return 3
|
|
case StatusStale:
|
|
return 2
|
|
case StatusFailed:
|
|
return 1
|
|
case StatusDisabled:
|
|
return 0
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func catalogFingerprint(entries []Entry) string {
|
|
h := sha256.New()
|
|
for _, e := range entries {
|
|
fmt.Fprintf(h, "%s|%s|%s|%v\n", e.ID, e.Kind, e.Status, e.AutoUse)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))[:16]
|
|
}
|
|
|
|
// Lookup returns the entry with the given capability ID.
|
|
func (c Catalog) Lookup(id string) (Entry, bool) {
|
|
id = strings.TrimSpace(id)
|
|
for _, e := range c.Entries {
|
|
if e.ID == id {
|
|
return e, true
|
|
}
|
|
}
|
|
return Entry{}, false
|
|
}
|
|
|
|
// RequiresReady reports whether every required dependency is ready.
|
|
func (c Catalog) RequiresReady(requires []string) (ready bool, missing []string) {
|
|
for _, dep := range requires {
|
|
dep = strings.TrimSpace(dep)
|
|
if dep == "" {
|
|
continue
|
|
}
|
|
e, ok := c.Lookup(dep)
|
|
if !ok || e.Status != StatusReady {
|
|
missing = append(missing, dep)
|
|
}
|
|
}
|
|
return len(missing) == 0, missing
|
|
}
|