legionio 1.4.71 → 1.4.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/legion/cli/observe_command.rb +115 -0
- data/lib/legion/cli.rb +5 -1
- data/lib/legion/mcp/context_compiler.rb +33 -2
- data/lib/legion/mcp/embedding_index.rb +113 -0
- data/lib/legion/mcp/observer.rb +135 -0
- data/lib/legion/mcp/server.rb +45 -0
- data/lib/legion/mcp/usage_filter.rb +86 -0
- data/lib/legion/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7693068b5190222c508dd07a248424e8f9e1eff77e80059cbd8a4fc81e9e6325
|
|
4
|
+
data.tar.gz: 43cc0da2efe7d604101ab87426c21b5f8ce677a8c023aeec38e5b91018b515ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2d75a86b1646113be917a9f8ed17f8f7aa8ffb48ff371db78ee62fbfdba1929d85e675f2bf7d6a5469b077f10370f3d044eb6f5049db353d471f5be1c3cbab5
|
|
7
|
+
data.tar.gz: 2f00a29a6ddf7be18ee71839b97e1a8aeeb74f2cecd820932447bc574fcfeb595a6adce0d757909bf3e3d68909235fcbb32ad95182192b5471dea0a2f3d39a0e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.73] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- TBI Phase 3: semantic tool retrieval via embedding vectors
|
|
7
|
+
- `Legion::MCP::EmbeddingIndex` module: in-memory embedding cache with pure-Ruby cosine similarity
|
|
8
|
+
- `ContextCompiler` semantic score blending: 60% semantic + 40% keyword when embeddings available, keyword-only fallback
|
|
9
|
+
- `Server.populate_embedding_index`: auto-populates tool embeddings on MCP server build (no-op if LLM unavailable)
|
|
10
|
+
- `legion observe embeddings` subcommand: index size, coverage, and populated status
|
|
11
|
+
- 61 new specs (1666 total): EmbeddingIndex unit, ContextCompiler semantic blending, integration wiring, CLI
|
|
12
|
+
|
|
13
|
+
## [1.4.72] - 2026-03-19
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- TBI Phase 0+2: MCP tool observation pipeline and usage-based filtering
|
|
17
|
+
- `Legion::MCP::Observer` module: in-memory tool call recording with counters, ring buffer, and intent tracking
|
|
18
|
+
- `Legion::MCP::UsageFilter` module: scores tools by frequency, recency, and keyword match; prunes dead tools
|
|
19
|
+
- MCP `instrumentation_callback` wiring: automatically records all `tools/call` invocations via Observer
|
|
20
|
+
- MCP `tools_list_handler` wiring: dynamically filters and ranks tools per-request based on usage data
|
|
21
|
+
- `legion observe` CLI command: `stats`, `recent`, `reset` subcommands for MCP tool usage inspection
|
|
22
|
+
- 96 new specs covering Observer, UsageFilter, CLI command, and integration wiring
|
|
23
|
+
|
|
3
24
|
## [1.4.71] - 2026-03-19
|
|
4
25
|
|
|
5
26
|
### Added
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'legion/mcp/observer'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module CLI
|
|
8
|
+
class ObserveCommand < Thor
|
|
9
|
+
namespace :observe
|
|
10
|
+
|
|
11
|
+
desc 'stats', 'Show MCP tool usage statistics'
|
|
12
|
+
def stats
|
|
13
|
+
data = Legion::MCP::Observer.stats
|
|
14
|
+
|
|
15
|
+
if options['json']
|
|
16
|
+
puts ::JSON.pretty_generate(serialize_stats(data))
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
puts 'MCP Tool Observation Stats'
|
|
21
|
+
puts '=' * 40
|
|
22
|
+
puts "Total Calls: #{data[:total_calls]}"
|
|
23
|
+
puts "Tools Used: #{data[:tool_count]}"
|
|
24
|
+
puts "Failure Rate: #{(data[:failure_rate] * 100).round(1)}%"
|
|
25
|
+
puts "Since: #{data[:since]&.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
26
|
+
puts
|
|
27
|
+
|
|
28
|
+
return if data[:top_tools].empty?
|
|
29
|
+
|
|
30
|
+
puts 'Top Tools:'
|
|
31
|
+
puts '-' * 60
|
|
32
|
+
puts 'Tool Calls Avg(ms) Fails'
|
|
33
|
+
puts '-' * 60
|
|
34
|
+
data[:top_tools].each do |tool|
|
|
35
|
+
puts format('%-30<name>s %6<calls>d %8<avg>d %6<fails>d',
|
|
36
|
+
name: tool[:name], calls: tool[:call_count],
|
|
37
|
+
avg: tool[:avg_latency_ms], fails: tool[:failure_count])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
desc 'recent', 'Show recent MCP tool calls'
|
|
42
|
+
method_option :limit, type: :numeric, default: 20, aliases: '-n'
|
|
43
|
+
def recent
|
|
44
|
+
calls = Legion::MCP::Observer.recent(options['limit'] || 20)
|
|
45
|
+
|
|
46
|
+
if options['json']
|
|
47
|
+
puts ::JSON.pretty_generate(calls.map { |c| serialize_call(c) })
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if calls.empty?
|
|
52
|
+
puts 'No recent tool calls recorded.'
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
puts 'Tool Duration Status Time'
|
|
57
|
+
puts '-' * 70
|
|
58
|
+
calls.reverse_each do |call|
|
|
59
|
+
status = call[:success] ? 'OK' : 'FAIL'
|
|
60
|
+
time = call[:timestamp]&.strftime('%H:%M:%S')
|
|
61
|
+
puts format('%-30<tool>s %6<dur>dms %7<st>s %<tm>s',
|
|
62
|
+
tool: call[:tool_name], dur: call[:duration_ms], st: status, tm: time)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
desc 'reset', 'Clear all observation data'
|
|
67
|
+
def reset
|
|
68
|
+
print 'Clear all observation data? (yes/no): '
|
|
69
|
+
return unless $stdin.gets&.strip&.downcase == 'yes'
|
|
70
|
+
|
|
71
|
+
Legion::MCP::Observer.reset!
|
|
72
|
+
puts 'Observation data cleared.'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc 'embeddings', 'Show MCP tool embedding index status'
|
|
76
|
+
def embeddings
|
|
77
|
+
require 'legion/mcp/embedding_index'
|
|
78
|
+
data = {
|
|
79
|
+
index_size: Legion::MCP::EmbeddingIndex.size,
|
|
80
|
+
coverage: Legion::MCP::EmbeddingIndex.coverage,
|
|
81
|
+
populated: Legion::MCP::EmbeddingIndex.populated?
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if options['json']
|
|
85
|
+
puts ::JSON.pretty_generate(data.transform_keys(&:to_s))
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
puts 'MCP Embedding Index'
|
|
90
|
+
puts '=' * 40
|
|
91
|
+
puts "Index Size: #{data[:index_size]}"
|
|
92
|
+
puts "Coverage: #{(data[:coverage] * 100).round(1)}%"
|
|
93
|
+
puts "Populated: #{data[:populated]}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def serialize_stats(data)
|
|
99
|
+
{
|
|
100
|
+
total_calls: data[:total_calls],
|
|
101
|
+
tool_count: data[:tool_count],
|
|
102
|
+
failure_rate: data[:failure_rate],
|
|
103
|
+
since: data[:since]&.iso8601,
|
|
104
|
+
top_tools: data[:top_tools].map { |t| t.transform_keys(&:to_s) }
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def serialize_call(call)
|
|
109
|
+
call.transform_keys(&:to_s).tap do |c|
|
|
110
|
+
c['timestamp'] = c['timestamp']&.iso8601 if c['timestamp']
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/legion/cli.rb
CHANGED
|
@@ -43,7 +43,8 @@ module Legion
|
|
|
43
43
|
autoload :Marketplace, 'legion/cli/marketplace_command'
|
|
44
44
|
autoload :Notebook, 'legion/cli/notebook_command'
|
|
45
45
|
autoload :Llm, 'legion/cli/llm_command'
|
|
46
|
-
autoload :Tty,
|
|
46
|
+
autoload :Tty, 'legion/cli/tty_command'
|
|
47
|
+
autoload :ObserveCommand, 'legion/cli/observe_command'
|
|
47
48
|
autoload :Interactive, 'legion/cli/interactive'
|
|
48
49
|
|
|
49
50
|
class Main < Thor
|
|
@@ -241,6 +242,9 @@ module Legion
|
|
|
241
242
|
desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)'
|
|
242
243
|
subcommand 'tty', Legion::CLI::Tty
|
|
243
244
|
|
|
245
|
+
desc 'observe SUBCOMMAND', 'MCP tool observation stats'
|
|
246
|
+
subcommand 'observe', Legion::CLI::ObserveCommand
|
|
247
|
+
|
|
244
248
|
desc 'tree', 'Print a tree of all available commands'
|
|
245
249
|
def tree
|
|
246
250
|
legion_print_command_tree(self.class, 'legion', '')
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'embedding_index'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module MCP
|
|
5
7
|
module ContextCompiler
|
|
@@ -112,6 +114,7 @@ module Legion
|
|
|
112
114
|
# Clears the memoized tool_index.
|
|
113
115
|
def reset!
|
|
114
116
|
@tool_index = nil
|
|
117
|
+
Legion::MCP::EmbeddingIndex.reset! if defined?(Legion::MCP::EmbeddingIndex)
|
|
115
118
|
end
|
|
116
119
|
|
|
117
120
|
def build_tool_index
|
|
@@ -131,10 +134,38 @@ module Legion
|
|
|
131
134
|
keywords = intent_string.downcase.split
|
|
132
135
|
return [] if keywords.empty?
|
|
133
136
|
|
|
137
|
+
kw_scores = keyword_score_map(keywords)
|
|
138
|
+
sem_scores = semantic_score_map(intent_string)
|
|
139
|
+
use_semantic = !sem_scores.empty?
|
|
140
|
+
|
|
134
141
|
tool_index.values.map do |entry|
|
|
142
|
+
kw_raw = kw_scores[entry[:name]] || 0
|
|
143
|
+
if use_semantic
|
|
144
|
+
max_kw = kw_scores.values.max || 1
|
|
145
|
+
normalized_kw = max_kw.positive? ? kw_raw.to_f / max_kw : 0.0
|
|
146
|
+
sem = sem_scores[entry[:name]] || 0.0
|
|
147
|
+
blended = (normalized_kw * 0.4) + (sem * 0.6)
|
|
148
|
+
else
|
|
149
|
+
blended = kw_raw.to_f
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
{ name: entry[:name], description: entry[:description], score: blended }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def keyword_score_map(keywords)
|
|
157
|
+
tool_index.values.to_h do |entry|
|
|
135
158
|
haystack = "#{entry[:name].downcase} #{entry[:description].downcase}"
|
|
136
|
-
score
|
|
137
|
-
|
|
159
|
+
score = keywords.count { |kw| haystack.include?(kw) }
|
|
160
|
+
[entry[:name], score]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def semantic_score_map(intent_string)
|
|
165
|
+
return {} unless defined?(Legion::MCP::EmbeddingIndex) && Legion::MCP::EmbeddingIndex.populated?
|
|
166
|
+
|
|
167
|
+
Legion::MCP::EmbeddingIndex.semantic_match(intent_string, limit: tool_index.size).to_h do |result|
|
|
168
|
+
[result[:name], result[:score]]
|
|
138
169
|
end
|
|
139
170
|
end
|
|
140
171
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module EmbeddingIndex
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_from_tool_data(tool_data, embedder: default_embedder)
|
|
9
|
+
@embedder = embedder
|
|
10
|
+
mutex.synchronize do
|
|
11
|
+
tool_data.each do |tool|
|
|
12
|
+
composite = build_composite(tool[:name], tool[:description], tool[:params])
|
|
13
|
+
vector = safe_embed(composite, embedder)
|
|
14
|
+
next unless vector
|
|
15
|
+
|
|
16
|
+
index[tool[:name]] = {
|
|
17
|
+
name: tool[:name],
|
|
18
|
+
composite_text: composite,
|
|
19
|
+
vector: vector,
|
|
20
|
+
built_at: Time.now
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def semantic_match(intent, embedder: @embedder || default_embedder, limit: 5)
|
|
27
|
+
return [] if index.empty?
|
|
28
|
+
|
|
29
|
+
intent_vec = safe_embed(intent, embedder)
|
|
30
|
+
return [] unless intent_vec
|
|
31
|
+
|
|
32
|
+
scores = mutex.synchronize do
|
|
33
|
+
index.values.filter_map do |entry|
|
|
34
|
+
next unless entry[:vector]
|
|
35
|
+
|
|
36
|
+
score = cosine_similarity(intent_vec, entry[:vector])
|
|
37
|
+
{ name: entry[:name], score: score }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
scores.sort_by { |s| -s[:score] }.first(limit)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cosine_similarity(vec_a, vec_b)
|
|
45
|
+
dot = vec_a.zip(vec_b).sum { |a, b| a * b }
|
|
46
|
+
mag_a = Math.sqrt(vec_a.sum { |x| x**2 })
|
|
47
|
+
mag_b = Math.sqrt(vec_b.sum { |x| x**2 })
|
|
48
|
+
return 0.0 if mag_a.zero? || mag_b.zero?
|
|
49
|
+
|
|
50
|
+
dot / (mag_a * mag_b)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def entry(tool_name)
|
|
54
|
+
mutex.synchronize { index[tool_name] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def size
|
|
58
|
+
mutex.synchronize { index.size }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def populated?
|
|
62
|
+
mutex.synchronize { !index.empty? }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def coverage
|
|
66
|
+
mutex.synchronize do
|
|
67
|
+
return 0.0 if index.empty?
|
|
68
|
+
|
|
69
|
+
with_vectors = index.values.count { |e| e[:vector] }
|
|
70
|
+
with_vectors.to_f / index.size
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reset!
|
|
75
|
+
@embedder = nil
|
|
76
|
+
mutex.synchronize { index.clear }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def index
|
|
80
|
+
@index ||= {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def mutex
|
|
84
|
+
@mutex ||= Mutex.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_composite(name, description, params)
|
|
88
|
+
parts = [name, '--', description]
|
|
89
|
+
parts << "Params: #{params.join(', ')}" unless params.empty?
|
|
90
|
+
parts.join(' ')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def safe_embed(text, embedder)
|
|
94
|
+
return nil unless embedder
|
|
95
|
+
|
|
96
|
+
result = embedder.call(text)
|
|
97
|
+
return nil unless result.is_a?(Array) && !result.empty?
|
|
98
|
+
|
|
99
|
+
result
|
|
100
|
+
rescue StandardError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def default_embedder
|
|
105
|
+
return nil unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
|
|
106
|
+
|
|
107
|
+
->(text) { Legion::LLM.embed(text)[:vector] }
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent-ruby'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module MCP
|
|
7
|
+
module Observer
|
|
8
|
+
RING_BUFFER_MAX = 500
|
|
9
|
+
INTENT_BUFFER_MAX = 200
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def record(tool_name:, duration_ms:, success:, params_keys: [], error: nil)
|
|
14
|
+
now = Time.now
|
|
15
|
+
|
|
16
|
+
counters_mutex.synchronize do
|
|
17
|
+
entry = counters[tool_name] || { call_count: 0, total_latency_ms: 0.0, failure_count: 0,
|
|
18
|
+
last_used: nil, last_error: nil }
|
|
19
|
+
counters[tool_name] = {
|
|
20
|
+
call_count: entry[:call_count] + 1,
|
|
21
|
+
total_latency_ms: entry[:total_latency_ms] + duration_ms.to_f,
|
|
22
|
+
failure_count: entry[:failure_count] + (success ? 0 : 1),
|
|
23
|
+
last_used: now,
|
|
24
|
+
last_error: success ? entry[:last_error] : error
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
buffer_mutex.synchronize do
|
|
29
|
+
ring_buffer << {
|
|
30
|
+
tool_name: tool_name,
|
|
31
|
+
duration_ms: duration_ms,
|
|
32
|
+
success: success,
|
|
33
|
+
params_keys: params_keys,
|
|
34
|
+
error: error,
|
|
35
|
+
recorded_at: now
|
|
36
|
+
}
|
|
37
|
+
ring_buffer.shift if ring_buffer.size > RING_BUFFER_MAX
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record_intent(intent, matched_tool_name)
|
|
42
|
+
intent_mutex.synchronize do
|
|
43
|
+
intent_buffer << { intent: intent, matched_tool: matched_tool_name, recorded_at: Time.now }
|
|
44
|
+
intent_buffer.shift if intent_buffer.size > INTENT_BUFFER_MAX
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tool_stats(tool_name)
|
|
49
|
+
entry = counters_mutex.synchronize { counters[tool_name] }
|
|
50
|
+
return nil unless entry
|
|
51
|
+
|
|
52
|
+
count = entry[:call_count]
|
|
53
|
+
avg = count.positive? ? (entry[:total_latency_ms] / count).round(2) : 0.0
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
name: tool_name,
|
|
57
|
+
call_count: count,
|
|
58
|
+
avg_latency_ms: avg,
|
|
59
|
+
failure_count: entry[:failure_count],
|
|
60
|
+
last_used: entry[:last_used],
|
|
61
|
+
last_error: entry[:last_error]
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def all_tool_stats
|
|
66
|
+
names = counters_mutex.synchronize { counters.keys.dup }
|
|
67
|
+
names.to_h { |name| [name, tool_stats(name)] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stats
|
|
71
|
+
all_names = counters_mutex.synchronize { counters.keys.dup }
|
|
72
|
+
total = all_names.sum { |n| counters_mutex.synchronize { counters[n][:call_count] } }
|
|
73
|
+
failures = all_names.sum { |n| counters_mutex.synchronize { counters[n][:failure_count] } }
|
|
74
|
+
rate = total.positive? ? (failures.to_f / total).round(4) : 0.0
|
|
75
|
+
|
|
76
|
+
top = all_names
|
|
77
|
+
.map { |n| tool_stats(n) }
|
|
78
|
+
.sort_by { |s| -s[:call_count] }
|
|
79
|
+
.first(10)
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
total_calls: total,
|
|
83
|
+
tool_count: all_names.size,
|
|
84
|
+
failure_rate: rate,
|
|
85
|
+
top_tools: top,
|
|
86
|
+
since: started_at
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recent(limit = 10)
|
|
91
|
+
buffer_mutex.synchronize { ring_buffer.last(limit) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def recent_intents(limit = 10)
|
|
95
|
+
intent_mutex.synchronize { intent_buffer.last(limit) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reset!
|
|
99
|
+
counters_mutex.synchronize { counters.clear }
|
|
100
|
+
buffer_mutex.synchronize { ring_buffer.clear }
|
|
101
|
+
intent_mutex.synchronize { intent_buffer.clear }
|
|
102
|
+
@started_at = Time.now
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Internal state accessors
|
|
106
|
+
def counters
|
|
107
|
+
@counters ||= {}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def counters_mutex
|
|
111
|
+
@counters_mutex ||= Mutex.new
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ring_buffer
|
|
115
|
+
@ring_buffer ||= []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def buffer_mutex
|
|
119
|
+
@buffer_mutex ||= Mutex.new
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def intent_buffer
|
|
123
|
+
@intent_buffer ||= []
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def intent_mutex
|
|
127
|
+
@intent_mutex ||= Mutex.new
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def started_at
|
|
131
|
+
@started_at ||= Time.now
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/legion/mcp/server.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'observer'
|
|
4
|
+
require_relative 'usage_filter'
|
|
3
5
|
require_relative 'tools/run_task'
|
|
4
6
|
require_relative 'tools/describe_runner'
|
|
5
7
|
require_relative 'tools/list_tasks'
|
|
@@ -34,6 +36,7 @@ require_relative 'tools/rbac_check'
|
|
|
34
36
|
require_relative 'tools/rbac_assignments'
|
|
35
37
|
require_relative 'tools/rbac_grants'
|
|
36
38
|
require_relative 'context_compiler'
|
|
39
|
+
require_relative 'embedding_index'
|
|
37
40
|
require_relative 'tools/do_action'
|
|
38
41
|
require_relative 'tools/discover_tools'
|
|
39
42
|
require_relative 'resources/runner_catalog'
|
|
@@ -97,12 +100,54 @@ module Legion
|
|
|
97
100
|
resource_templates: Resources::ExtensionInfo.resource_templates
|
|
98
101
|
)
|
|
99
102
|
|
|
103
|
+
if defined?(Observer)
|
|
104
|
+
::MCP.configure do |c|
|
|
105
|
+
c.instrumentation_callback = ->(idata) { Server.wire_observer(idata) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
server.tools_list_handler do |_params|
|
|
110
|
+
build_filtered_tool_list.map(&:to_h)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Populate embedding index for semantic tool matching (lazy — no-op if LLM unavailable)
|
|
114
|
+
populate_embedding_index
|
|
115
|
+
|
|
100
116
|
Resources::RunnerCatalog.register(server)
|
|
101
117
|
Resources::ExtensionInfo.register_read_handler(server)
|
|
102
118
|
|
|
103
119
|
server
|
|
104
120
|
end
|
|
105
121
|
|
|
122
|
+
def populate_embedding_index(embedder: EmbeddingIndex.default_embedder)
|
|
123
|
+
return unless embedder
|
|
124
|
+
|
|
125
|
+
tool_data = ContextCompiler.tool_index.values
|
|
126
|
+
EmbeddingIndex.build_from_tool_data(tool_data, embedder: embedder)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def wire_observer(data)
|
|
130
|
+
return unless data[:method] == 'tools/call' && data[:tool_name]
|
|
131
|
+
|
|
132
|
+
duration_ms = (data[:duration].to_f * 1000).to_i
|
|
133
|
+
params_keys = data[:tool_arguments].respond_to?(:keys) ? data[:tool_arguments].keys : []
|
|
134
|
+
success = data[:error].nil?
|
|
135
|
+
|
|
136
|
+
Observer.record(
|
|
137
|
+
tool_name: data[:tool_name],
|
|
138
|
+
duration_ms: duration_ms,
|
|
139
|
+
success: success,
|
|
140
|
+
params_keys: params_keys,
|
|
141
|
+
error: data[:error]
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_filtered_tool_list(keywords: [])
|
|
146
|
+
tool_names = TOOL_CLASSES.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
|
|
147
|
+
ranked = UsageFilter.ranked_tools(tool_names, keywords: keywords)
|
|
148
|
+
ranked.filter_map { |name| TOOL_CLASSES.find { |tc| (tc.respond_to?(:tool_name) ? tc.tool_name : tc.name) == name } }
|
|
149
|
+
end
|
|
150
|
+
|
|
106
151
|
private
|
|
107
152
|
|
|
108
153
|
def instructions
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module UsageFilter
|
|
6
|
+
ESSENTIAL_TOOLS = %w[
|
|
7
|
+
legion.do legion.tools legion.run_task legion.get_status legion.describe_runner
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
FREQUENCY_WEIGHT = 0.5
|
|
11
|
+
RECENCY_WEIGHT = 0.3
|
|
12
|
+
KEYWORD_WEIGHT = 0.2
|
|
13
|
+
BASELINE_SCORE = 0.1
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def score_tools(tool_names, keywords: [])
|
|
18
|
+
all_stats = Observer.all_tool_stats
|
|
19
|
+
call_counts = tool_names.map { |n| all_stats.dig(n, :call_count) || 0 }
|
|
20
|
+
max_calls = call_counts.max || 0
|
|
21
|
+
|
|
22
|
+
tool_names.each_with_object({}) do |name, hash|
|
|
23
|
+
stats = all_stats[name]
|
|
24
|
+
|
|
25
|
+
freq_score = if max_calls.positive? && stats
|
|
26
|
+
(stats[:call_count].to_f / max_calls) * FREQUENCY_WEIGHT
|
|
27
|
+
else
|
|
28
|
+
0.0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
rec_score = if stats&.dig(:last_used)
|
|
32
|
+
recency_decay(stats[:last_used]) * RECENCY_WEIGHT
|
|
33
|
+
else
|
|
34
|
+
0.0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
kw_score = keyword_match(name, keywords) * KEYWORD_WEIGHT
|
|
38
|
+
|
|
39
|
+
total = freq_score + rec_score + kw_score
|
|
40
|
+
total = BASELINE_SCORE if total.zero?
|
|
41
|
+
|
|
42
|
+
hash[name] = total.round(6)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ranked_tools(tool_names, limit: nil, keywords: [])
|
|
47
|
+
scores = score_tools(tool_names, keywords: keywords)
|
|
48
|
+
ranked = tool_names.sort_by { |n| -scores.fetch(n, BASELINE_SCORE) }
|
|
49
|
+
limit ? ranked.first(limit) : ranked
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def prune_dead_tools(tool_names, prune_after_seconds: 86_400 * 30)
|
|
53
|
+
stats = Observer.stats
|
|
54
|
+
window = stats[:since]
|
|
55
|
+
elapsed = window ? (Time.now - window) : 0
|
|
56
|
+
|
|
57
|
+
return tool_names if elapsed < prune_after_seconds
|
|
58
|
+
|
|
59
|
+
all_stats = Observer.all_tool_stats
|
|
60
|
+
tool_names.reject do |name|
|
|
61
|
+
next false if ESSENTIAL_TOOLS.include?(name)
|
|
62
|
+
|
|
63
|
+
calls = all_stats.dig(name, :call_count) || 0
|
|
64
|
+
calls.zero?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def recency_decay(last_used)
|
|
69
|
+
return 0.0 unless last_used
|
|
70
|
+
|
|
71
|
+
age_seconds = Time.now - last_used
|
|
72
|
+
return 1.0 if age_seconds <= 0
|
|
73
|
+
|
|
74
|
+
decay = 1.0 - (age_seconds / 86_400.0)
|
|
75
|
+
decay.clamp(0.0, 1.0)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def keyword_match(tool_name, keywords)
|
|
79
|
+
return 0.0 if keywords.nil? || keywords.empty?
|
|
80
|
+
|
|
81
|
+
hits = keywords.count { |kw| tool_name.include?(kw.to_s) }
|
|
82
|
+
hits.to_f / keywords.size
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/legion/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legionio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.4.
|
|
4
|
+
version: 1.4.73
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -490,6 +490,7 @@ files:
|
|
|
490
490
|
- lib/legion/cli/mcp_command.rb
|
|
491
491
|
- lib/legion/cli/memory_command.rb
|
|
492
492
|
- lib/legion/cli/notebook_command.rb
|
|
493
|
+
- lib/legion/cli/observe_command.rb
|
|
493
494
|
- lib/legion/cli/openapi_command.rb
|
|
494
495
|
- lib/legion/cli/output.rb
|
|
495
496
|
- lib/legion/cli/plan_command.rb
|
|
@@ -559,6 +560,8 @@ files:
|
|
|
559
560
|
- lib/legion/mcp.rb
|
|
560
561
|
- lib/legion/mcp/auth.rb
|
|
561
562
|
- lib/legion/mcp/context_compiler.rb
|
|
563
|
+
- lib/legion/mcp/embedding_index.rb
|
|
564
|
+
- lib/legion/mcp/observer.rb
|
|
562
565
|
- lib/legion/mcp/resources/extension_info.rb
|
|
563
566
|
- lib/legion/mcp/resources/runner_catalog.rb
|
|
564
567
|
- lib/legion/mcp/server.rb
|
|
@@ -598,6 +601,7 @@ files:
|
|
|
598
601
|
- lib/legion/mcp/tools/update_schedule.rb
|
|
599
602
|
- lib/legion/mcp/tools/worker_costs.rb
|
|
600
603
|
- lib/legion/mcp/tools/worker_lifecycle.rb
|
|
604
|
+
- lib/legion/mcp/usage_filter.rb
|
|
601
605
|
- lib/legion/metrics.rb
|
|
602
606
|
- lib/legion/process.rb
|
|
603
607
|
- lib/legion/readiness.rb
|