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