返回 DeepSeek-Reasonix
capability.go
根目录 / internal / capability / capability.go
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
411 lines GO