返回 DeepSeek-Reasonix
usecapability_search.go
根目录 / internal / agent / usecapability_search.go
1 package agent
2
3 import (
4 "encoding/json"
5 "sort"
6 "strings"
7 "unicode"
8
9 "reasonix/internal/capability"
10 "reasonix/internal/plugin"
11 "reasonix/internal/tool"
12 )
13
14 type capabilitySearchResult struct {
15 CapabilityID string `json:"capability_id"`
16 Kind string `json:"kind"`
17 Name string `json:"name"`
18 Description string `json:"description,omitempty"`
19 Status string `json:"status,omitempty"`
20 ReadOnly bool `json:"read_only"`
21 Arguments []string `json:"argument_names,omitempty"`
22 score int
23 }
24
25 // searchCapabilities ranks the in-memory catalog and schema cache only. It is
26 // deliberately incapable of starting an MCP server or issuing tools/list.
27 func (t *UseCapabilityTool) searchCapabilities(query string, limit int) (string, int, error) {
28 if limit == 0 {
29 limit = 5
30 }
31 query = strings.TrimSpace(query)
32 queryNorm := normalizeSearchText(query)
33 queryTokens := searchTokens(query)
34 cat := t.currentCatalog()
35 mcpSchemas := t.mcpSearchSchemaIndex()
36 results := make([]capabilitySearchResult, 0, len(cat.Entries))
37 for _, entry := range cat.Entries {
38 arguments, schemaText := t.capabilitySchemaSearchData(entry, mcpSchemas)
39 document := strings.Join([]string{entry.ID, entry.Name, entry.Source, entry.ToolName, entry.Description, schemaText}, " ")
40 score := capabilitySearchScore(entry, document, queryNorm, queryTokens)
41 if score == 0 {
42 continue
43 }
44 results = append(results, capabilitySearchResult{
45 CapabilityID: entry.ID,
46 Kind: string(entry.Kind),
47 Name: entry.Name,
48 Description: truncateSearchDescription(entry.Description),
49 Status: string(entry.Status),
50 ReadOnly: entry.ReadOnly,
51 Arguments: arguments,
52 score: score,
53 })
54 }
55 sort.Slice(results, func(i, j int) bool {
56 if results[i].score != results[j].score {
57 return results[i].score > results[j].score
58 }
59 return results[i].CapabilityID < results[j].CapabilityID
60 })
61 total := len(results)
62 if total > limit {
63 results = results[:limit]
64 }
65 payload := struct {
66 Query string `json:"query"`
67 Results []capabilitySearchResult `json:"results"`
68 CatalogVersion string `json:"catalog_version"`
69 Truncated bool `json:"truncated"`
70 SnapshotStale bool `json:"snapshot_stale"`
71 Incomplete bool `json:"incomplete"`
72 Note string `json:"note"`
73 }{
74 Query: query, Results: results, CatalogVersion: cat.Fingerprint,
75 Truncated: total > len(results), SnapshotStale: cat.Stale, Incomplete: cat.Incomplete,
76 Note: "Local catalog search only; no MCP process, network request, or tools/list call was made. Inspect one exact capability_id before calling when its argument contract is unfamiliar.",
77 }
78 b, err := json.MarshalIndent(payload, "", " ")
79 return string(b), len(results), err
80 }
81
82 func capabilitySearchScore(entry capability.Entry, document, queryNorm string, queryTokens []string) int {
83 id := normalizeSearchText(entry.ID)
84 name := normalizeSearchText(entry.Name)
85 doc := normalizeSearchText(document)
86 score := 0
87 switch {
88 case id == queryNorm:
89 score += 10000
90 case name == queryNorm:
91 score += 8000
92 case strings.Contains(id, queryNorm):
93 score += 3000
94 case strings.Contains(name, queryNorm):
95 score += 2000
96 case strings.Contains(doc, queryNorm):
97 score += 1000
98 }
99 for _, token := range queryTokens {
100 if token == "" {
101 continue
102 }
103 switch {
104 case containsSearchToken(id, token):
105 score += 300
106 case containsSearchToken(name, token):
107 score += 200
108 case containsSearchToken(doc, token):
109 score += 80
110 }
111 }
112 return score
113 }
114
115 func (t *UseCapabilityTool) capabilitySchemaSearchData(entry capability.Entry, mcpSchemas map[string]plugin.CachedTool) ([]string, string) {
116 var schema json.RawMessage
117 if entry.Kind == capability.KindMCPTool {
118 server, raw, err := parseMCPCapabilityID(entry.ID)
119 if err == nil {
120 if cached, ok := mcpSchemas[server+"\x00"+raw]; ok {
121 schema = cached.Schema
122 }
123 }
124 } else if strings.HasPrefix(entry.ID, "skill:") {
125 if contract, ok := capabilityArgumentContract(entry); ok {
126 schema = contract.Schema
127 }
128 } else if t.registry != nil {
129 name := strings.TrimSpace(entry.ToolName)
130 if name == "" {
131 name = strings.TrimSpace(strings.TrimPrefix(entry.ID, "tool:"))
132 }
133 if target, ok := t.registry.Get(name); ok {
134 schema = target.Schema()
135 }
136 }
137 return schemaSearchData(schema)
138 }
139
140 // mcpSearchSchemaIndex takes one local snapshot per search. The old per-entry
141 // localMCPTool path deep-copied the whole runtime catalog for every MCP tool,
142 // making a large 88KB catalog quadratic even though discovery is local-only.
143 func (t *UseCapabilityTool) mcpSearchSchemaIndex() map[string]plugin.CachedTool {
144 index := map[string]plugin.CachedTool{}
145 add := func(server string, tools []plugin.CachedTool) {
146 for _, cached := range tools {
147 key := server + "\x00" + cached.Name
148 if _, exists := index[key]; !exists {
149 index[key] = cached
150 }
151 }
152 }
153 if t.runtime != nil {
154 _, cached, _, _, live := t.runtime.CapabilityCatalogState()
155 for server, tools := range live {
156 add(server, tools)
157 }
158 for server, tools := range cached {
159 add(server, tools)
160 }
161 } else {
162 for server, tools := range t.ensureState().snapshotLiveTools() {
163 add(server, tools)
164 }
165 if t.host != nil {
166 for _, spec := range t.specs {
167 if live, ok := t.host.CachedTools(spec.Name); ok {
168 add(spec.Name, snapshotMCPTools(live))
169 }
170 }
171 }
172 for _, spec := range t.specs {
173 if cached, ok := plugin.LoadCachedSchemaForSpecProfile(spec, t.hostProfileFor()); ok {
174 add(spec.Name, cached.Tools)
175 }
176 }
177 }
178 if t.registry != nil {
179 for _, name := range t.registry.AllNames() {
180 target, ok := t.registry.Get(name)
181 if !ok {
182 continue
183 }
184 metadata, ok := target.(tool.MCPMetadata)
185 if !ok {
186 continue
187 }
188 add(metadata.MCPServerName(), []plugin.CachedTool{{
189 Name: metadata.MCPRawToolName(), Description: target.Description(),
190 Schema: target.Schema(), ReadOnly: target.ReadOnly(),
191 }})
192 }
193 }
194 return index
195 }
196
197 func schemaSearchData(raw json.RawMessage) ([]string, string) {
198 var root struct {
199 Properties map[string]struct {
200 Type any `json:"type"`
201 Description string `json:"description"`
202 } `json:"properties"`
203 }
204 if json.Unmarshal(raw, &root) != nil || len(root.Properties) == 0 {
205 return nil, ""
206 }
207 names := make([]string, 0, len(root.Properties))
208 parts := make([]string, 0, len(root.Properties))
209 for name := range root.Properties {
210 names = append(names, name)
211 }
212 sort.Strings(names)
213 for _, name := range names {
214 property := root.Properties[name]
215 parts = append(parts, name+" "+property.Description)
216 }
217 return names, strings.Join(parts, " ")
218 }
219
220 func capabilityArgumentContract(entry capability.Entry) (tool.CapabilityArgumentContract, bool) {
221 if entry.Kind != capability.KindSkill || !strings.HasPrefix(entry.ID, "skill:") {
222 return tool.CapabilityArgumentContract{}, false
223 }
224 required := ""
225 if entry.SkillRunAs == "subagent" {
226 required = `,"required":["arguments"]`
227 }
228 schema := json.RawMessage(`{"type":"object","properties":{"arguments":{"type":"string","description":"Concrete task or inline skill arguments."},"continue_from":{"type":"string","description":"Optional compatible subagent reference."}}` + required + `}`)
229 example, _ := json.Marshal(map[string]any{
230 "action": "call", "capability_id": entry.ID,
231 "arguments": map[string]any{"arguments": "specific task for " + entry.Name},
232 })
233 return tool.CapabilityArgumentContract{Schema: schema, Example: example}, true
234 }
235
236 // localMCPTools returns live/shared-host metadata first, then the already
237 // registered adapter, then disk/schema cache metadata. It performs no remote
238 // calls and never starts a server.
239 func (t *UseCapabilityTool) localMCPTools(server string) ([]plugin.CachedTool, string) {
240 if t.runtime != nil {
241 _, cached, _, _, live := t.runtime.CapabilityCatalogState()
242 if tools := live[server]; len(tools) > 0 {
243 return cloneCachedTools(tools), "shared_host"
244 }
245 if tools := cached[server]; len(tools) > 0 {
246 return cloneCachedTools(tools), "disk_cache"
247 }
248 } else if tools := t.ensureState().snapshotLiveTools()[server]; len(tools) > 0 {
249 return cloneCachedTools(tools), "shared_host"
250 } else if t.host != nil {
251 if live, ok := t.host.CachedTools(server); ok && len(live) > 0 {
252 cached := snapshotMCPTools(live)
253 t.ensureState().setLiveTools(server, cached)
254 return cloneCachedTools(cached), "shared_host"
255 }
256 }
257 if t.registry != nil {
258 var registered []plugin.CachedTool
259 for _, name := range t.registry.AllNames() {
260 target, ok := t.registry.Get(name)
261 if !ok {
262 continue
263 }
264 metadata, ok := target.(tool.MCPMetadata)
265 if !ok || metadata.MCPServerName() != server {
266 continue
267 }
268 registered = append(registered, plugin.CachedTool{
269 Name: metadata.MCPRawToolName(),
270 Description: target.Description(),
271 Schema: target.Schema(),
272 ReadOnly: target.ReadOnly(),
273 })
274 }
275 if len(registered) > 0 {
276 sort.Slice(registered, func(i, j int) bool { return registered[i].Name < registered[j].Name })
277 return registered, "shared_host"
278 }
279 }
280 if spec, ok := t.specFor(server); ok {
281 if cached, ok := plugin.LoadCachedSchemaForSpecProfile(spec, t.hostProfileFor()); ok && len(cached.Tools) > 0 {
282 return cloneCachedTools(cached.Tools), "disk_cache"
283 }
284 }
285 return nil, ""
286 }
287
288 func (t *UseCapabilityTool) localMCPTool(server, raw string) (plugin.CachedTool, string, bool) {
289 tools, source := t.localMCPTools(server)
290 for _, candidate := range tools {
291 if candidate.Name == raw {
292 return candidate, source, true
293 }
294 }
295 return plugin.CachedTool{}, source, false
296 }
297
298 func searchTokens(value string) []string {
299 normalized := normalizeSearchText(value)
300 if normalized == "" {
301 return nil
302 }
303 return strings.Fields(normalized)
304 }
305
306 func normalizeSearchText(value string) string {
307 var b strings.Builder
308 var previous rune
309 for i, r := range value {
310 if i > 0 && unicode.IsUpper(r) && (unicode.IsLower(previous) || unicode.IsDigit(previous)) {
311 b.WriteByte(' ')
312 }
313 if unicode.IsLetter(r) || unicode.IsDigit(r) {
314 b.WriteRune(unicode.ToLower(r))
315 } else {
316 b.WriteByte(' ')
317 }
318 previous = r
319 }
320 return strings.Join(strings.Fields(b.String()), " ")
321 }
322
323 func containsSearchToken(document, token string) bool {
324 for candidate := range strings.FieldsSeq(document) {
325 if candidate == token || strings.Contains(candidate, token) {
326 return true
327 }
328 }
329 return false
330 }
331
332 func truncateSearchDescription(value string) string {
333 value = strings.TrimSpace(value)
334 if len(value) <= 240 {
335 return value
336 }
337 return value[:237] + "..."
338 }
339
339 lines GO