返回 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 // proxy means use_capability is the only connector: connect_tool_source
244 // is unregistered for closed-loop routes and every boot that exposes the
245 // stable proxy. Never rewrite to an unusable source target.
246 proxy := d.ClosedLoop || d.CapabilityProxy
247 target := e.ID
248 if !proxy && e.Status != StatusReady && e.ConnectSource != "" {
249 target = fmt.Sprintf("source:%s", e.ConnectSource)
250 if e.ConnectName != "" {
251 target += "/" + e.ConnectName
252 }
253 }
254 var line strings.Builder
255 fmt.Fprintf(&line, "- %s %s: %s", target, c.Policy, c.Reason)
256 if e.Status != "" && e.Status != StatusReady {
257 fmt.Fprintf(&line, " (status=%s)", e.Status)
258 }
259 switch {
260 case proxy:
261 // Closed-loop routes and dual-model Planner have no
262 // connect_tool_source; the stable proxy both connects and
263 // calls on demand, keeping the concrete capability id.
264 if e.Status != StatusReady {
265 switch e.Kind {
266 case KindMCPTool:
267 fmt.Fprintf(&line, "; call use_capability(action=\"call\", capability_id=%q, arguments={...}) — it connects the server on demand after approval", e.ID)
268 case KindMCPServer:
269 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)
270 case KindSkill:
271 fmt.Fprintf(&line, "; call use_capability(action=\"call\", capability_id=%q, arguments={...}) — it runs the skill on demand", e.ID)
272 }
273 }
274 case e.ConnectSource != "":
275 if e.ConnectName != "" {
276 fmt.Fprintf(&line, "; first call connect_tool_source with source=%q name=%q", e.ConnectSource, e.ConnectName)
277 } else {
278 fmt.Fprintf(&line, "; first call connect_tool_source with source=%q", e.ConnectSource)
279 }
280 }
281 rendered := line.String()
282 if _, duplicate := seenLines[rendered]; duplicate {
283 continue
284 }
285 seenLines[rendered] = struct{}{}
286 b.WriteString(rendered)
287 b.WriteByte('\n')
288 }
289 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")
290 b.WriteString(`</capability-route>`)
291 return b.String()
292 }
293
294 func routeEntry(text string, e Entry) (AutoUse, string, bool) {
295 if e.Kind == KindSkill {
296 if e.AutoUse == AutoUseOff {
297 return "", "", false
298 }
299 if triggerMatch(text, e.Triggers) {
300 return e.AutoUse, "the skill trigger matches the user request", true
301 }
302 if e.Name == "review" && looksLikeReview(text) {
303 return AutoUsePrefer, "the user is asking for review or issue inspection", true
304 }
305 }
306 if e.Kind == KindMCPTool && namesMCPTool(text, e) {
307 return AutoUsePrefer, "the user named this MCP tool", true
308 }
309 return "", "", false
310 }
311
312 // namesMCPTool matches the identifier the host mints for the tool. A server
313 // name or a topic word selects every tool the server exposes, so it is left to
314 // the semantic router, which reads the whole request.
315 func namesMCPTool(text string, e Entry) bool {
316 tool := normalize(e.ToolName)
317 return tool != "" && strings.Contains(text, tool)
318 }
319
320 func looksLikeReview(text string) bool {
321 return containsAny(text, []string{
322 "review", "code review", "security review", "帮我看看", "有没有问题", "审查", "评审", "检查这段代码", "看看这段代码",
323 })
324 }
325
326 func triggerMatch(text string, triggers []string) bool {
327 for _, trig := range triggers {
328 t := normalize(trig)
329 if t != "" && strings.Contains(text, t) {
330 return true
331 }
332 }
333 return false
334 }
335
336 func negativeMatch(text string, triggers []string) bool {
337 return triggerMatch(text, triggers)
338 }
339
340 func containsAny(s string, terms []string) bool {
341 for _, term := range terms {
342 if strings.Contains(s, normalize(term)) {
343 return true
344 }
345 }
346 return false
347 }
348
349 func normalize(s string) string {
350 return strings.ToLower(strings.TrimSpace(s))
351 }
352
353 func cleanList(in []string) []string {
354 var out []string
355 seen := map[string]bool{}
356 for _, v := range in {
357 v = strings.TrimSpace(v)
358 if v == "" || seen[v] {
359 continue
360 }
361 seen[v] = true
362 out = append(out, v)
363 }
364 return out
365 }
366
367 func normalizeAutoUse(raw string) AutoUse {
368 switch strings.ToLower(strings.TrimSpace(raw)) {
369 case "off":
370 return AutoUseOff
371 case "suggest":
372 return AutoUseSuggest
373 case "prefer":
374 return AutoUsePrefer
375 case "require":
376 return AutoUseRequire
377 default:
378 return ""
379 }
380 }
381
382 func rank(a AutoUse) int {
383 switch a {
384 case AutoUseRequire:
385 return 3
386 case AutoUsePrefer:
387 return 2
388 case AutoUseSuggest:
389 return 1
390 default:
391 return 0
392 }
393 }
394
394 lines GO