legion-mcp 0.1.0
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 +7 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +40 -0
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +101 -0
- data/Gemfile +10 -0
- data/LICENSE +167 -0
- data/README.md +182 -0
- data/Rakefile +5 -0
- data/legion-mcp.gemspec +35 -0
- data/lib/legion/mcp/auth.rb +50 -0
- data/lib/legion/mcp/context_compiler.rb +173 -0
- data/lib/legion/mcp/context_guard.rb +105 -0
- data/lib/legion/mcp/embedding_index.rb +113 -0
- data/lib/legion/mcp/observer.rb +171 -0
- data/lib/legion/mcp/pattern_store.rb +303 -0
- data/lib/legion/mcp/resources/extension_info.rb +67 -0
- data/lib/legion/mcp/resources/runner_catalog.rb +63 -0
- data/lib/legion/mcp/server.rb +178 -0
- data/lib/legion/mcp/tier_router.rb +122 -0
- data/lib/legion/mcp/tool_governance.rb +77 -0
- data/lib/legion/mcp/tools/create_chain.rb +50 -0
- data/lib/legion/mcp/tools/create_relationship.rb +51 -0
- data/lib/legion/mcp/tools/create_schedule.rb +64 -0
- data/lib/legion/mcp/tools/delete_chain.rb +52 -0
- data/lib/legion/mcp/tools/delete_relationship.rb +52 -0
- data/lib/legion/mcp/tools/delete_schedule.rb +52 -0
- data/lib/legion/mcp/tools/delete_task.rb +49 -0
- data/lib/legion/mcp/tools/describe_runner.rb +92 -0
- data/lib/legion/mcp/tools/disable_extension.rb +50 -0
- data/lib/legion/mcp/tools/discover_tools.rb +53 -0
- data/lib/legion/mcp/tools/do_action.rb +85 -0
- data/lib/legion/mcp/tools/enable_extension.rb +50 -0
- data/lib/legion/mcp/tools/get_config.rb +63 -0
- data/lib/legion/mcp/tools/get_extension.rb +56 -0
- data/lib/legion/mcp/tools/get_status.rb +50 -0
- data/lib/legion/mcp/tools/get_task.rb +48 -0
- data/lib/legion/mcp/tools/get_task_logs.rb +56 -0
- data/lib/legion/mcp/tools/list_chains.rb +48 -0
- data/lib/legion/mcp/tools/list_extensions.rb +46 -0
- data/lib/legion/mcp/tools/list_relationships.rb +45 -0
- data/lib/legion/mcp/tools/list_schedules.rb +51 -0
- data/lib/legion/mcp/tools/list_tasks.rb +50 -0
- data/lib/legion/mcp/tools/list_workers.rb +54 -0
- data/lib/legion/mcp/tools/rbac_assignments.rb +45 -0
- data/lib/legion/mcp/tools/rbac_check.rb +46 -0
- data/lib/legion/mcp/tools/rbac_grants.rb +41 -0
- data/lib/legion/mcp/tools/routing_stats.rb +51 -0
- data/lib/legion/mcp/tools/run_task.rb +68 -0
- data/lib/legion/mcp/tools/show_worker.rb +48 -0
- data/lib/legion/mcp/tools/team_summary.rb +55 -0
- data/lib/legion/mcp/tools/update_chain.rb +54 -0
- data/lib/legion/mcp/tools/update_relationship.rb +55 -0
- data/lib/legion/mcp/tools/update_schedule.rb +65 -0
- data/lib/legion/mcp/tools/worker_costs.rb +55 -0
- data/lib/legion/mcp/tools/worker_lifecycle.rb +54 -0
- data/lib/legion/mcp/usage_filter.rb +86 -0
- data/lib/legion/mcp/version.rb +7 -0
- data/lib/legion/mcp.rb +30 -0
- metadata +195 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'embedding_index'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module MCP
|
|
7
|
+
module ContextCompiler
|
|
8
|
+
CATEGORIES = {
|
|
9
|
+
tasks: {
|
|
10
|
+
tools: %w[legion.run_task legion.list_tasks legion.get_task legion.delete_task legion.get_task_logs],
|
|
11
|
+
summary: 'Create, list, query, and delete tasks. Run functions via dot-notation task identifiers.'
|
|
12
|
+
},
|
|
13
|
+
chains: {
|
|
14
|
+
tools: %w[legion.list_chains legion.create_chain legion.update_chain legion.delete_chain],
|
|
15
|
+
summary: 'Manage task chains - ordered sequences of tasks that execute in series.'
|
|
16
|
+
},
|
|
17
|
+
relationships: {
|
|
18
|
+
tools: %w[legion.list_relationships legion.create_relationship legion.update_relationship
|
|
19
|
+
legion.delete_relationship],
|
|
20
|
+
summary: 'Manage trigger-action relationships between functions.'
|
|
21
|
+
},
|
|
22
|
+
extensions: {
|
|
23
|
+
tools: %w[legion.list_extensions legion.get_extension legion.enable_extension
|
|
24
|
+
legion.disable_extension],
|
|
25
|
+
summary: 'Manage LEX extensions - list installed, inspect details, enable/disable.'
|
|
26
|
+
},
|
|
27
|
+
schedules: {
|
|
28
|
+
tools: %w[legion.list_schedules legion.create_schedule legion.update_schedule legion.delete_schedule],
|
|
29
|
+
summary: 'Manage scheduled tasks - cron-style recurring task execution.'
|
|
30
|
+
},
|
|
31
|
+
workers: {
|
|
32
|
+
tools: %w[legion.list_workers legion.show_worker legion.worker_lifecycle legion.worker_costs],
|
|
33
|
+
summary: 'Manage digital workers - list, inspect, lifecycle transitions, cost tracking.'
|
|
34
|
+
},
|
|
35
|
+
rbac: {
|
|
36
|
+
tools: %w[legion.rbac_check legion.rbac_assignments legion.rbac_grants],
|
|
37
|
+
summary: 'Role-based access control - check permissions, view assignments and grants.'
|
|
38
|
+
},
|
|
39
|
+
status: {
|
|
40
|
+
tools: %w[legion.get_status legion.get_config legion.team_summary legion.routing_stats],
|
|
41
|
+
summary: 'System status, configuration, team overview, and routing statistics.'
|
|
42
|
+
},
|
|
43
|
+
describe: {
|
|
44
|
+
tools: %w[legion.describe_runner],
|
|
45
|
+
summary: 'Inspect a specific runner function - parameters, return type, metadata.'
|
|
46
|
+
}
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# Returns a compressed summary of all categories with tool counts and tool name lists.
|
|
52
|
+
# @return [Array<Hash>] array of { category:, summary:, tool_count:, tools: }
|
|
53
|
+
def compressed_catalog
|
|
54
|
+
CATEGORIES.map do |category, config|
|
|
55
|
+
tool_names = config[:tools]
|
|
56
|
+
{
|
|
57
|
+
category: category,
|
|
58
|
+
summary: config[:summary],
|
|
59
|
+
tool_count: tool_names.length,
|
|
60
|
+
tools: tool_names
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns tools for a specific category, filtered to only those present in TOOL_CLASSES.
|
|
66
|
+
# @param category_sym [Symbol] one of the CATEGORIES keys
|
|
67
|
+
# @return [Hash, nil] { category:, summary:, tools: [{ name:, description:, params: }] } or nil
|
|
68
|
+
def category_tools(category_sym)
|
|
69
|
+
config = CATEGORIES[category_sym]
|
|
70
|
+
return nil unless config
|
|
71
|
+
|
|
72
|
+
index = tool_index
|
|
73
|
+
tools = config[:tools].filter_map { |name| index[name] }
|
|
74
|
+
return nil if tools.empty?
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
category: category_sym,
|
|
78
|
+
summary: config[:summary],
|
|
79
|
+
tools: tools
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Keyword-match intent against tool names and descriptions.
|
|
84
|
+
# @param intent_string [String] natural language intent
|
|
85
|
+
# @return [Class, nil] best matching tool CLASS from Server::TOOL_CLASSES or nil
|
|
86
|
+
def match_tool(intent_string)
|
|
87
|
+
scored = scored_tools(intent_string)
|
|
88
|
+
return nil if scored.empty?
|
|
89
|
+
|
|
90
|
+
best = scored.max_by { |entry| entry[:score] }
|
|
91
|
+
return nil if best[:score].zero?
|
|
92
|
+
|
|
93
|
+
Server::TOOL_CLASSES.find { |klass| klass.tool_name == best[:name] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns top N keyword-matched tools ranked by score.
|
|
97
|
+
# @param intent_string [String] natural language intent
|
|
98
|
+
# @param limit [Integer] max results (default 5)
|
|
99
|
+
# @return [Array<Hash>] array of { name:, description:, score: }
|
|
100
|
+
def match_tools(intent_string, limit: 5)
|
|
101
|
+
scored = scored_tools(intent_string)
|
|
102
|
+
.select { |entry| entry[:score].positive? }
|
|
103
|
+
.sort_by { |entry| -entry[:score] }
|
|
104
|
+
scored.first(limit)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns a hash keyed by tool_name with compressed param info.
|
|
108
|
+
# Memoized — call reset! to clear.
|
|
109
|
+
# @return [Hash<String, Hash>] { name:, description:, params: [String] }
|
|
110
|
+
def tool_index
|
|
111
|
+
@tool_index ||= build_tool_index
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Clears the memoized tool_index.
|
|
115
|
+
def reset!
|
|
116
|
+
@tool_index = nil
|
|
117
|
+
Legion::MCP::EmbeddingIndex.reset! if defined?(Legion::MCP::EmbeddingIndex)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_tool_index
|
|
121
|
+
Server::TOOL_CLASSES.each_with_object({}) do |klass, idx|
|
|
122
|
+
raw_schema = klass.input_schema
|
|
123
|
+
schema = raw_schema.is_a?(Hash) ? raw_schema : raw_schema.to_h
|
|
124
|
+
properties = schema[:properties] || {}
|
|
125
|
+
idx[klass.tool_name] = {
|
|
126
|
+
name: klass.tool_name,
|
|
127
|
+
description: klass.description,
|
|
128
|
+
params: properties.keys.map(&:to_s)
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def scored_tools(intent_string)
|
|
134
|
+
keywords = intent_string.downcase.split
|
|
135
|
+
return [] if keywords.empty?
|
|
136
|
+
|
|
137
|
+
kw_scores = keyword_score_map(keywords)
|
|
138
|
+
sem_scores = semantic_score_map(intent_string)
|
|
139
|
+
use_semantic = !sem_scores.empty?
|
|
140
|
+
|
|
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|
|
|
158
|
+
haystack = "#{entry[:name].downcase} #{entry[:description].downcase}"
|
|
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]]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module ContextGuard
|
|
6
|
+
DEFAULT_MAX_STALE_SECONDS = 3600
|
|
7
|
+
DEFAULT_RAPID_FIRE_THRESHOLD = 5
|
|
8
|
+
DEFAULT_RAPID_FIRE_WINDOW_SECS = 600
|
|
9
|
+
DEFAULT_ANOMALY_MISS_THRESHOLD = 2
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def check(pattern, _params, _context)
|
|
14
|
+
return staleness_failure(pattern) if stale?(pattern)
|
|
15
|
+
return anomaly_failure(pattern) if anomalous?(pattern)
|
|
16
|
+
return rapid_fire_failure(pattern) if rapid_fire?(pattern[:intent_hash])
|
|
17
|
+
|
|
18
|
+
{ passed: true }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record_request(intent_hash)
|
|
22
|
+
mutex.synchronize do
|
|
23
|
+
requests[intent_hash] ||= []
|
|
24
|
+
requests[intent_hash] << Time.now
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset!
|
|
29
|
+
mutex.synchronize { requests.clear }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stale?(pattern)
|
|
33
|
+
last_hit = pattern[:last_hit_at]
|
|
34
|
+
return false unless last_hit
|
|
35
|
+
|
|
36
|
+
(Time.now - last_hit) > max_stale_seconds
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def anomalous?(pattern)
|
|
40
|
+
(pattern[:miss_count] || 0) >= anomaly_miss_threshold
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rapid_fire?(intent_hash)
|
|
44
|
+
return false unless intent_hash
|
|
45
|
+
|
|
46
|
+
window = Time.now - rapid_fire_window_seconds
|
|
47
|
+
count = mutex.synchronize do
|
|
48
|
+
entries = requests[intent_hash]
|
|
49
|
+
return false unless entries
|
|
50
|
+
|
|
51
|
+
entries.reject! { |t| t < window }
|
|
52
|
+
entries.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
count > rapid_fire_threshold
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def max_stale_seconds
|
|
59
|
+
setting(:max_stale_seconds) || DEFAULT_MAX_STALE_SECONDS
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rapid_fire_threshold
|
|
63
|
+
setting(:rapid_fire_threshold) || DEFAULT_RAPID_FIRE_THRESHOLD
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rapid_fire_window_seconds
|
|
67
|
+
setting(:rapid_fire_window_seconds) || DEFAULT_RAPID_FIRE_WINDOW_SECS
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def anomaly_miss_threshold
|
|
71
|
+
DEFAULT_ANOMALY_MISS_THRESHOLD
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def setting(key)
|
|
75
|
+
return nil unless defined?(Legion::Settings)
|
|
76
|
+
|
|
77
|
+
Legion::Settings.dig(:mcp, :tier0, :guards, key)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def staleness_failure(pattern)
|
|
83
|
+
age = pattern[:last_hit_at] ? (Time.now - pattern[:last_hit_at]).round(0) : 0
|
|
84
|
+
{ passed: false, guard: :staleness, reason: "pattern stale (#{age}s since last hit)" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def anomaly_failure(pattern)
|
|
88
|
+
{ passed: false, guard: :anomaly, reason: "#{pattern[:miss_count]} consecutive misses" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def rapid_fire_failure(_pattern)
|
|
92
|
+
{ passed: false, guard: :rapid_fire,
|
|
93
|
+
reason: "exceeded #{rapid_fire_threshold} requests in #{rapid_fire_window_seconds}s" }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def requests
|
|
97
|
+
@requests ||= {}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def mutex
|
|
101
|
+
@mutex ||= Mutex.new
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent-ruby'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module MCP
|
|
8
|
+
module Observer
|
|
9
|
+
RING_BUFFER_MAX = 500
|
|
10
|
+
INTENT_BUFFER_MAX = 200
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def record(tool_name:, duration_ms:, success:, params_keys: [], error: nil)
|
|
15
|
+
now = Time.now
|
|
16
|
+
|
|
17
|
+
counters_mutex.synchronize do
|
|
18
|
+
entry = counters[tool_name] || { call_count: 0, total_latency_ms: 0.0, failure_count: 0,
|
|
19
|
+
last_used: nil, last_error: nil }
|
|
20
|
+
counters[tool_name] = {
|
|
21
|
+
call_count: entry[:call_count] + 1,
|
|
22
|
+
total_latency_ms: entry[:total_latency_ms] + duration_ms.to_f,
|
|
23
|
+
failure_count: entry[:failure_count] + (success ? 0 : 1),
|
|
24
|
+
last_used: now,
|
|
25
|
+
last_error: success ? entry[:last_error] : error
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
buffer_mutex.synchronize do
|
|
30
|
+
ring_buffer << {
|
|
31
|
+
tool_name: tool_name,
|
|
32
|
+
duration_ms: duration_ms,
|
|
33
|
+
success: success,
|
|
34
|
+
params_keys: params_keys,
|
|
35
|
+
error: error,
|
|
36
|
+
recorded_at: now
|
|
37
|
+
}
|
|
38
|
+
ring_buffer.shift if ring_buffer.size > RING_BUFFER_MAX
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record_intent(intent, matched_tool_name)
|
|
43
|
+
intent_mutex.synchronize do
|
|
44
|
+
intent_buffer << { intent: intent, matched_tool: matched_tool_name, recorded_at: Time.now }
|
|
45
|
+
intent_buffer.shift if intent_buffer.size > INTENT_BUFFER_MAX
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_intent_with_result(intent:, tool_name:, success:)
|
|
50
|
+
record_intent(intent, tool_name)
|
|
51
|
+
return unless success
|
|
52
|
+
return unless defined?(Legion::MCP::PatternStore)
|
|
53
|
+
|
|
54
|
+
normalized = intent.to_s.strip.downcase.gsub(/\s+/, ' ')
|
|
55
|
+
intent_hash = Digest::SHA256.hexdigest(normalized)
|
|
56
|
+
|
|
57
|
+
promotion = Legion::MCP::PatternStore.record_candidate(
|
|
58
|
+
intent_hash: intent_hash,
|
|
59
|
+
tool_chain: [tool_name],
|
|
60
|
+
intent_text: intent
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return unless promotion&.dig(:promote)
|
|
64
|
+
|
|
65
|
+
Legion::MCP::PatternStore.promote_candidate(
|
|
66
|
+
intent_hash: promotion[:intent_hash],
|
|
67
|
+
tool_chain: promotion[:tool_chain],
|
|
68
|
+
intent_text: promotion[:intent_text],
|
|
69
|
+
intent_vector: try_embed(normalized)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tool_stats(tool_name)
|
|
74
|
+
entry = counters_mutex.synchronize { counters[tool_name] }
|
|
75
|
+
return nil unless entry
|
|
76
|
+
|
|
77
|
+
count = entry[:call_count]
|
|
78
|
+
avg = count.positive? ? (entry[:total_latency_ms] / count).round(2) : 0.0
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
name: tool_name,
|
|
82
|
+
call_count: count,
|
|
83
|
+
avg_latency_ms: avg,
|
|
84
|
+
failure_count: entry[:failure_count],
|
|
85
|
+
last_used: entry[:last_used],
|
|
86
|
+
last_error: entry[:last_error]
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def all_tool_stats
|
|
91
|
+
names = counters_mutex.synchronize { counters.keys.dup }
|
|
92
|
+
names.to_h { |name| [name, tool_stats(name)] }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def stats
|
|
96
|
+
all_names = counters_mutex.synchronize { counters.keys.dup }
|
|
97
|
+
total = all_names.sum { |n| counters_mutex.synchronize { counters[n][:call_count] } }
|
|
98
|
+
failures = all_names.sum { |n| counters_mutex.synchronize { counters[n][:failure_count] } }
|
|
99
|
+
rate = total.positive? ? (failures.to_f / total).round(4) : 0.0
|
|
100
|
+
|
|
101
|
+
top = all_names
|
|
102
|
+
.map { |n| tool_stats(n) }
|
|
103
|
+
.sort_by { |s| -s[:call_count] }
|
|
104
|
+
.first(10)
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
total_calls: total,
|
|
108
|
+
tool_count: all_names.size,
|
|
109
|
+
failure_rate: rate,
|
|
110
|
+
top_tools: top,
|
|
111
|
+
since: started_at
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def recent(limit = 10)
|
|
116
|
+
buffer_mutex.synchronize { ring_buffer.last(limit) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def recent_intents(limit = 10)
|
|
120
|
+
intent_mutex.synchronize { intent_buffer.last(limit) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def reset!
|
|
124
|
+
counters_mutex.synchronize { counters.clear }
|
|
125
|
+
buffer_mutex.synchronize { ring_buffer.clear }
|
|
126
|
+
intent_mutex.synchronize { intent_buffer.clear }
|
|
127
|
+
@started_at = Time.now
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Internal state accessors
|
|
131
|
+
def counters
|
|
132
|
+
@counters ||= {}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def counters_mutex
|
|
136
|
+
@counters_mutex ||= Mutex.new
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def ring_buffer
|
|
140
|
+
@ring_buffer ||= []
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def buffer_mutex
|
|
144
|
+
@buffer_mutex ||= Mutex.new
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def intent_buffer
|
|
148
|
+
@intent_buffer ||= []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def intent_mutex
|
|
152
|
+
@intent_mutex ||= Mutex.new
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def started_at
|
|
156
|
+
@started_at ||= Time.now
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def try_embed(text)
|
|
160
|
+
return nil unless defined?(Legion::MCP::EmbeddingIndex)
|
|
161
|
+
|
|
162
|
+
embedder = Legion::MCP::EmbeddingIndex.instance_variable_get(:@embedder)
|
|
163
|
+
return nil unless embedder
|
|
164
|
+
|
|
165
|
+
embedder.call(text)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|