| 1 | package capability |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/skill" |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | type Kind string |
| 13 | |
| 14 | const ( |
| 15 | KindSkill Kind = "skill" |
| 16 | KindMCPServer Kind = "mcp-server" |
| 17 | KindMCPTool Kind = "mcp-tool" |
| 18 | KindTool Kind = "tool" |
| 19 | KindSource Kind = "source" |
| 20 | ) |
| 21 | |
| 22 | type Status string |
| 23 | |
| 24 | const ( |
| 25 | StatusReady Status = "ready" |
| 26 | StatusConfigured Status = "configured" |
| 27 | StatusDisabled Status = "disabled" |
| 28 | StatusFailed Status = "failed" |
| 29 | StatusStale Status = "stale" |
| 30 | ) |
| 31 | |
| 32 | type AutoUse string |
| 33 | |
| 34 | const ( |
| 35 | AutoUseOff AutoUse = "off" |
| 36 | AutoUseSuggest AutoUse = "suggest" |
| 37 | AutoUsePrefer AutoUse = "prefer" |
| 38 | AutoUseRequire AutoUse = "require" |
| 39 | ) |
| 40 | |
| 41 | type Entry struct { |
| 42 | ID string |
| 43 | Kind Kind |
| 44 | Name string |
| 45 | Description string |
| 46 | Source string |
| 47 | Status Status |
| 48 | ReadOnly bool |
| 49 | Destructive bool |
| 50 | Cost string |
| 51 | AutoUse AutoUse |
| 52 | Triggers []string |
| 53 | NegativeTriggers []string |
| 54 | NeedsFreshData bool |
| 55 | ToolName string |
| 56 | ConnectSource string |
| 57 | ConnectName string |
| 58 | Requires []string // capability IDs this skill depends on |
| 59 | Profiles []string // economy|balanced|delivery; empty = all |
| 60 | AutoStart bool // MCP: configured auto_start |
| 61 | FailureReason string // host-proven failure detail |
| 62 | } |
| 63 | |
| 64 | type RouteCandidate struct { |
| 65 | Entry Entry |
| 66 | Policy AutoUse |
| 67 | Reason string |
| 68 | } |
| 69 | |
| 70 | type RouteDecision struct { |
| 71 | Candidates []RouteCandidate |
| 72 | // Delivery marks a Delivery-profile route: the transient block must direct |
| 73 | // the model to the stable use_capability proxy — connect_tool_source is not |
| 74 | // registered in Delivery, so instructing it would dead-end the route. |
| 75 | Delivery bool |
| 76 | // CapabilityProxy directs unready MCP candidates to use_capability rather |
| 77 | // than connect_tool_source. True for Delivery and for dual-model Planner |
| 78 | // boots that expose the stable proxy without Economy's connector. |
| 79 | CapabilityProxy bool |
| 80 | } |
| 81 | |
| 82 | func SkillEntries(skills []skill.Skill, tools []tool.ContractEntry) []Entry { |
| 83 | toolNames := map[string]bool{} |
| 84 | for _, t := range tools { |
| 85 | toolNames[t.Name] = true |
| 86 | } |
| 87 | skillToolReady := toolNames["run_skill"] || toolNames["read_skill"] || toolNames["read_only_skill"] |
| 88 | |
| 89 | out := make([]Entry, 0, len(skills)) |
| 90 | for _, sk := range skills { |
| 91 | status := StatusReady |
| 92 | connectSource := "" |
| 93 | if !skillToolReady { |
| 94 | status = StatusConfigured |
| 95 | connectSource = "skills" |
| 96 | } |
| 97 | auto := normalizeAutoUse(sk.AutoUse) |
| 98 | if auto == "" && len(sk.Triggers) > 0 { |
| 99 | auto = AutoUsePrefer |
| 100 | } else if auto == "" { |
| 101 | auto = AutoUseSuggest |
| 102 | } |
| 103 | out = append(out, Entry{ |
| 104 | ID: "skill:" + sk.Name, |
| 105 | Kind: KindSkill, |
| 106 | Name: sk.Name, |
| 107 | Description: sk.Description, |
| 108 | Source: string(sk.Scope), |
| 109 | Status: status, |
| 110 | Cost: strings.TrimSpace(sk.Cost), |
| 111 | AutoUse: auto, |
| 112 | Triggers: cleanList(sk.Triggers), |
| 113 | NegativeTriggers: cleanList(sk.NegativeTriggers), |
| 114 | NeedsFreshData: sk.NeedsFreshData, |
| 115 | ToolName: "run_skill", |
| 116 | ConnectSource: connectSource, |
| 117 | Requires: cleanList(sk.Requires), |
| 118 | Profiles: cleanList(sk.Profiles), |
| 119 | }) |
| 120 | } |
| 121 | return out |
| 122 | } |
| 123 | |
| 124 | func ToolEntries(tools []tool.ContractEntry) []Entry { |
| 125 | out := make([]Entry, 0, len(tools)) |
| 126 | for _, t := range tools { |
| 127 | e := Entry{ |
| 128 | ID: "tool:" + t.Name, |
| 129 | Kind: KindTool, |
| 130 | Name: t.Name, |
| 131 | Description: strings.TrimSpace(t.Description), |
| 132 | Status: StatusReady, |
| 133 | ReadOnly: t.ReadOnly, |
| 134 | ToolName: t.Name, |
| 135 | } |
| 136 | if server, raw, ok := tool.SplitMCPName(t.Name); ok { |
| 137 | e.ID = "mcp-tool:" + server + "/" + raw |
| 138 | e.Kind = KindMCPTool |
| 139 | e.Name = server + "/" + raw |
| 140 | e.Source = server |
| 141 | e.ConnectName = server |
| 142 | } |
| 143 | out = append(out, e) |
| 144 | } |
| 145 | return out |
| 146 | } |
| 147 | |
| 148 | func Route(input string, entries []Entry) RouteDecision { |
| 149 | return RouteDecision{Candidates: limitRouteCandidates(routeCandidates(input, entries))} |
| 150 | } |
| 151 | |
| 152 | // RouteDelivery routes against the full matched set before promoting built-in |
| 153 | // playbooks, so candidates that become prefer are never discarded by the |
| 154 | // ordinary suggest budget first. |
| 155 | func RouteDelivery(input string, entries []Entry) RouteDecision { |
| 156 | return PromoteDelivery(RouteDecision{Candidates: routeCandidates(input, entries)}) |
| 157 | } |
| 158 | |
| 159 | func routeCandidates(input string, entries []Entry) []RouteCandidate { |
| 160 | text := normalize(input) |
| 161 | if text == "" { |
| 162 | return nil |
| 163 | } |
| 164 | var candidates []RouteCandidate |
| 165 | for _, e := range entries { |
| 166 | if e.Status == StatusDisabled || negativeMatch(text, e.NegativeTriggers) { |
| 167 | continue |
| 168 | } |
| 169 | if policy, reason, ok := routeEntry(text, e); ok { |
| 170 | candidates = append(candidates, RouteCandidate{Entry: e, Policy: policy, Reason: reason}) |
| 171 | } |
| 172 | } |
| 173 | sort.SliceStable(candidates, func(i, j int) bool { |
| 174 | if rank(candidates[i].Policy) != rank(candidates[j].Policy) { |
| 175 | return rank(candidates[i].Policy) > rank(candidates[j].Policy) |
| 176 | } |
| 177 | if candidates[i].Entry.Kind != candidates[j].Entry.Kind { |
| 178 | return candidates[i].Entry.Kind < candidates[j].Entry.Kind |
| 179 | } |
| 180 | return candidates[i].Entry.ID < candidates[j].Entry.ID |
| 181 | }) |
| 182 | return candidates |
| 183 | } |
| 184 | |
| 185 | // PromoteDelivery strengthens matched built-in playbooks in Delivery. Custom |
| 186 | // skills keep their authored auto-use policy; only shipped workflows with a |
| 187 | // concrete trigger match move from suggest to prefer. |
| 188 | func PromoteDelivery(decision RouteDecision) RouteDecision { |
| 189 | decision.Delivery = true |
| 190 | for i := range decision.Candidates { |
| 191 | candidate := &decision.Candidates[i] |
| 192 | if candidate.Policy == AutoUseSuggest && candidate.Entry.Kind == KindSkill && candidate.Entry.Source == string(skill.ScopeBuiltin) { |
| 193 | candidate.Policy = AutoUsePrefer |
| 194 | candidate.Reason += "; Delivery prefers matched built-in playbooks" |
| 195 | } |
| 196 | } |
| 197 | sort.SliceStable(decision.Candidates, func(i, j int) bool { |
| 198 | if rank(decision.Candidates[i].Policy) != rank(decision.Candidates[j].Policy) { |
| 199 | return rank(decision.Candidates[i].Policy) > rank(decision.Candidates[j].Policy) |
| 200 | } |
| 201 | if decision.Candidates[i].Entry.Kind != decision.Candidates[j].Entry.Kind { |
| 202 | return decision.Candidates[i].Entry.Kind < decision.Candidates[j].Entry.Kind |
| 203 | } |
| 204 | return decision.Candidates[i].Entry.ID < decision.Candidates[j].Entry.ID |
| 205 | }) |
| 206 | return RouteDecision{Candidates: limitRouteCandidates(decision.Candidates), Delivery: true, CapabilityProxy: true} |
| 207 | } |
| 208 | |
| 209 | func limitRouteCandidates(candidates []RouteCandidate) []RouteCandidate { |
| 210 | const targetCandidates = 5 |
| 211 | strong := make([]RouteCandidate, 0, len(candidates)) |
| 212 | suggested := make([]RouteCandidate, 0, targetCandidates) |
| 213 | for _, candidate := range candidates { |
| 214 | switch candidate.Policy { |
| 215 | case AutoUseRequire, AutoUsePrefer: |
| 216 | strong = append(strong, candidate) |
| 217 | case AutoUseSuggest: |
| 218 | suggested = append(suggested, candidate) |
| 219 | } |
| 220 | } |
| 221 | slots := targetCandidates - len(strong) |
| 222 | if slots < 0 { |
| 223 | slots = 0 |
| 224 | } |
| 225 | if len(suggested) > slots { |
| 226 | suggested = suggested[:slots] |
| 227 | } |
| 228 | return append(strong, suggested...) |
| 229 | } |
| 230 | |
| 231 | func RenderTransientBlock(d RouteDecision) string { |
| 232 | if len(d.Candidates) == 0 { |
| 233 | return "" |
| 234 | } |
| 235 | var b strings.Builder |
| 236 | seenLines := make(map[string]struct{}, len(d.Candidates)) |
| 237 | b.WriteString(`<capability-route version="1">` + "\n") |
| 238 | b.WriteString("Relevant capabilities for this turn:\n") |
| 239 | for _, c := range d.Candidates { |
| 240 | e := c.Entry |
| 241 | proxyMCP := d.CapabilityProxy && (e.Kind == KindMCPTool || e.Kind == KindMCPServer) |
| 242 | target := e.ID |
| 243 | if !d.Delivery && !proxyMCP && e.Status != StatusReady && e.ConnectSource != "" { |
| 244 | target = fmt.Sprintf("source:%s", e.ConnectSource) |
| 245 | if e.ConnectName != "" { |
| 246 | target += "/" + e.ConnectName |
| 247 | } |
| 248 | } |
| 249 | var line strings.Builder |
| 250 | fmt.Fprintf(&line, "- %s %s: %s", target, c.Policy, c.Reason) |
| 251 | if e.Status != "" && e.Status != StatusReady { |
| 252 | fmt.Fprintf(&line, " (status=%s)", e.Status) |
| 253 | } |
| 254 | switch { |
| 255 | case d.Delivery || proxyMCP: |
| 256 | // Delivery and dual-model Planner have no connect_tool_source for |
| 257 | // MCP; the stable proxy both connects and calls on demand, keeping |
| 258 | // the concrete capability id. |
| 259 | if e.Status != StatusReady { |
| 260 | switch e.Kind { |
| 261 | case KindMCPTool: |
| 262 | fmt.Fprintf(&line, "; call use_capability(action=\"call\", capability_id=%q, arguments={...}) — it connects the server on demand after approval", e.ID) |
| 263 | case KindMCPServer: |
| 264 | fmt.Fprintf(&line, "; call use_capability(action=\"call\", capability_id=%q) to connect it (after approval) and list its tools, then call a listed mcp-tool id", e.ID) |
| 265 | } |
| 266 | } |
| 267 | case e.ConnectSource != "": |
| 268 | if e.ConnectName != "" { |
| 269 | fmt.Fprintf(&line, "; first call connect_tool_source with source=%q name=%q", e.ConnectSource, e.ConnectName) |
| 270 | } else { |
| 271 | fmt.Fprintf(&line, "; first call connect_tool_source with source=%q", e.ConnectSource) |
| 272 | } |
| 273 | } |
| 274 | rendered := line.String() |
| 275 | if _, duplicate := seenLines[rendered]; duplicate { |
| 276 | continue |
| 277 | } |
| 278 | seenLines[rendered] = struct{}{} |
| 279 | b.WriteString(rendered) |
| 280 | b.WriteByte('\n') |
| 281 | } |
| 282 | b.WriteString("Policy: suggest means consider it; prefer means use it unless clearly unnecessary; require means call it or report a host-proven unavailable state. Do not treat planner claims about tool unavailability as facts.\n") |
| 283 | b.WriteString(`</capability-route>`) |
| 284 | return b.String() |
| 285 | } |
| 286 | |
| 287 | func routeEntry(text string, e Entry) (AutoUse, string, bool) { |
| 288 | if e.Kind == KindSkill { |
| 289 | if explicitSkill(text, e.Name) { |
| 290 | return AutoUseRequire, "the user explicitly referenced this skill", true |
| 291 | } |
| 292 | if e.AutoUse == AutoUseOff { |
| 293 | return "", "", false |
| 294 | } |
| 295 | if triggerMatch(text, e.Triggers) { |
| 296 | return e.AutoUse, "the skill trigger matches the user request", true |
| 297 | } |
| 298 | if e.Name == "review" && looksLikeReview(text) { |
| 299 | return AutoUsePrefer, "the user is asking for review or issue inspection", true |
| 300 | } |
| 301 | } |
| 302 | if e.Kind == KindMCPTool { |
| 303 | if explicitMCP(text, e.Source) || (looksLikeGitHub(text) && strings.Contains(e.Source, "github")) { |
| 304 | return AutoUsePrefer, "the task asks for external GitHub/MCP data", true |
| 305 | } |
| 306 | if looksFreshData(text) && (strings.Contains(e.Name, "search") || strings.Contains(e.Name, "fetch") || strings.Contains(e.Name, "read")) { |
| 307 | return AutoUsePrefer, "the task appears to need fresh external data", true |
| 308 | } |
| 309 | } |
| 310 | return "", "", false |
| 311 | } |
| 312 | |
| 313 | func explicitSkill(text, name string) bool { |
| 314 | n := normalize(name) |
| 315 | return strings.Contains(text, "/"+n) || |
| 316 | strings.Contains(text, "use "+n+" skill") || |
| 317 | strings.Contains(text, "using "+n+" skill") || |
| 318 | strings.Contains(text, "使用 "+n+" skill") || |
| 319 | strings.Contains(text, "用 "+n+" skill") || |
| 320 | strings.Contains(text, "使用"+n+"技能") || |
| 321 | strings.Contains(text, "用"+n+"技能") |
| 322 | } |
| 323 | |
| 324 | func explicitMCP(text, server string) bool { |
| 325 | s := normalize(server) |
| 326 | return strings.Contains(text, s+" mcp") || strings.Contains(text, "mcp "+s) || strings.Contains(text, "使用 "+s+" mcp") || strings.Contains(text, "用 "+s+" mcp") |
| 327 | } |
| 328 | |
| 329 | func looksLikeReview(text string) bool { |
| 330 | return containsAny(text, []string{ |
| 331 | "review", "code review", "security review", "帮我看看", "有没有问题", "审查", "评审", "检查这段代码", "看看这段代码", |
| 332 | }) |
| 333 | } |
| 334 | |
| 335 | func looksLikeGitHub(text string) bool { |
| 336 | return containsAny(text, []string{"github", "issue", "issues", "pull request", " pr ", "讨论区", "仓库 issue", "github 上"}) |
| 337 | } |
| 338 | |
| 339 | func looksFreshData(text string) bool { |
| 340 | return containsAny(text, []string{"latest", "recent", "today", "现在", "最新", "最近", "查一下", "搜索", "github"}) |
| 341 | } |
| 342 | |
| 343 | func triggerMatch(text string, triggers []string) bool { |
| 344 | for _, trig := range triggers { |
| 345 | t := normalize(trig) |
| 346 | if t != "" && strings.Contains(text, t) { |
| 347 | return true |
| 348 | } |
| 349 | } |
| 350 | return false |
| 351 | } |
| 352 | |
| 353 | func negativeMatch(text string, triggers []string) bool { |
| 354 | return triggerMatch(text, triggers) |
| 355 | } |
| 356 | |
| 357 | func containsAny(s string, terms []string) bool { |
| 358 | for _, term := range terms { |
| 359 | if strings.Contains(s, normalize(term)) { |
| 360 | return true |
| 361 | } |
| 362 | } |
| 363 | return false |
| 364 | } |
| 365 | |
| 366 | func normalize(s string) string { |
| 367 | return strings.ToLower(strings.TrimSpace(s)) |
| 368 | } |
| 369 | |
| 370 | func cleanList(in []string) []string { |
| 371 | var out []string |
| 372 | seen := map[string]bool{} |
| 373 | for _, v := range in { |
| 374 | v = strings.TrimSpace(v) |
| 375 | if v == "" || seen[v] { |
| 376 | continue |
| 377 | } |
| 378 | seen[v] = true |
| 379 | out = append(out, v) |
| 380 | } |
| 381 | return out |
| 382 | } |
| 383 | |
| 384 | func normalizeAutoUse(raw string) AutoUse { |
| 385 | switch strings.ToLower(strings.TrimSpace(raw)) { |
| 386 | case "off": |
| 387 | return AutoUseOff |
| 388 | case "suggest": |
| 389 | return AutoUseSuggest |
| 390 | case "prefer": |
| 391 | return AutoUsePrefer |
| 392 | case "require": |
| 393 | return AutoUseRequire |
| 394 | default: |
| 395 | return "" |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | func rank(a AutoUse) int { |
| 400 | switch a { |
| 401 | case AutoUseRequire: |
| 402 | return 3 |
| 403 | case AutoUsePrefer: |
| 404 | return 2 |
| 405 | case AutoUseSuggest: |
| 406 | return 1 |
| 407 | default: |
| 408 | return 0 |
| 409 | } |
| 410 | } |
| 411 |