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