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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +6 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +40 -0
  6. data/CHANGELOG.md +20 -0
  7. data/CLAUDE.md +101 -0
  8. data/Gemfile +10 -0
  9. data/LICENSE +167 -0
  10. data/README.md +182 -0
  11. data/Rakefile +5 -0
  12. data/legion-mcp.gemspec +35 -0
  13. data/lib/legion/mcp/auth.rb +50 -0
  14. data/lib/legion/mcp/context_compiler.rb +173 -0
  15. data/lib/legion/mcp/context_guard.rb +105 -0
  16. data/lib/legion/mcp/embedding_index.rb +113 -0
  17. data/lib/legion/mcp/observer.rb +171 -0
  18. data/lib/legion/mcp/pattern_store.rb +303 -0
  19. data/lib/legion/mcp/resources/extension_info.rb +67 -0
  20. data/lib/legion/mcp/resources/runner_catalog.rb +63 -0
  21. data/lib/legion/mcp/server.rb +178 -0
  22. data/lib/legion/mcp/tier_router.rb +122 -0
  23. data/lib/legion/mcp/tool_governance.rb +77 -0
  24. data/lib/legion/mcp/tools/create_chain.rb +50 -0
  25. data/lib/legion/mcp/tools/create_relationship.rb +51 -0
  26. data/lib/legion/mcp/tools/create_schedule.rb +64 -0
  27. data/lib/legion/mcp/tools/delete_chain.rb +52 -0
  28. data/lib/legion/mcp/tools/delete_relationship.rb +52 -0
  29. data/lib/legion/mcp/tools/delete_schedule.rb +52 -0
  30. data/lib/legion/mcp/tools/delete_task.rb +49 -0
  31. data/lib/legion/mcp/tools/describe_runner.rb +92 -0
  32. data/lib/legion/mcp/tools/disable_extension.rb +50 -0
  33. data/lib/legion/mcp/tools/discover_tools.rb +53 -0
  34. data/lib/legion/mcp/tools/do_action.rb +85 -0
  35. data/lib/legion/mcp/tools/enable_extension.rb +50 -0
  36. data/lib/legion/mcp/tools/get_config.rb +63 -0
  37. data/lib/legion/mcp/tools/get_extension.rb +56 -0
  38. data/lib/legion/mcp/tools/get_status.rb +50 -0
  39. data/lib/legion/mcp/tools/get_task.rb +48 -0
  40. data/lib/legion/mcp/tools/get_task_logs.rb +56 -0
  41. data/lib/legion/mcp/tools/list_chains.rb +48 -0
  42. data/lib/legion/mcp/tools/list_extensions.rb +46 -0
  43. data/lib/legion/mcp/tools/list_relationships.rb +45 -0
  44. data/lib/legion/mcp/tools/list_schedules.rb +51 -0
  45. data/lib/legion/mcp/tools/list_tasks.rb +50 -0
  46. data/lib/legion/mcp/tools/list_workers.rb +54 -0
  47. data/lib/legion/mcp/tools/rbac_assignments.rb +45 -0
  48. data/lib/legion/mcp/tools/rbac_check.rb +46 -0
  49. data/lib/legion/mcp/tools/rbac_grants.rb +41 -0
  50. data/lib/legion/mcp/tools/routing_stats.rb +51 -0
  51. data/lib/legion/mcp/tools/run_task.rb +68 -0
  52. data/lib/legion/mcp/tools/show_worker.rb +48 -0
  53. data/lib/legion/mcp/tools/team_summary.rb +55 -0
  54. data/lib/legion/mcp/tools/update_chain.rb +54 -0
  55. data/lib/legion/mcp/tools/update_relationship.rb +55 -0
  56. data/lib/legion/mcp/tools/update_schedule.rb +65 -0
  57. data/lib/legion/mcp/tools/worker_costs.rb +55 -0
  58. data/lib/legion/mcp/tools/worker_lifecycle.rb +54 -0
  59. data/lib/legion/mcp/usage_filter.rb +86 -0
  60. data/lib/legion/mcp/version.rb +7 -0
  61. data/lib/legion/mcp.rb +30 -0
  62. metadata +195 -0
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Legion
6
+ module MCP
7
+ module PatternStore
8
+ CONFIDENCE_SUCCESS_DELTA = 0.02
9
+ CONFIDENCE_FAILURE_DELTA = -0.05
10
+ SEEDED_CONFIDENCE = 0.5
11
+
12
+ module_function
13
+
14
+ def store(pattern)
15
+ hash = pattern[:intent_hash]
16
+ mutex.synchronize { patterns_l0[hash] = pattern.dup }
17
+ persist_l1(hash, pattern)
18
+ persist_l2(hash, pattern)
19
+ end
20
+
21
+ def lookup(intent_hash)
22
+ result = mutex.synchronize { patterns_l0[intent_hash]&.dup }
23
+ return result if result
24
+
25
+ result = lookup_l1(intent_hash)
26
+ if result
27
+ mutex.synchronize { patterns_l0[intent_hash] = result }
28
+ return result.dup
29
+ end
30
+
31
+ result = lookup_l2(intent_hash)
32
+ if result
33
+ mutex.synchronize { patterns_l0[intent_hash] = result }
34
+ persist_l1(intent_hash, result)
35
+ return result.dup
36
+ end
37
+
38
+ nil
39
+ end
40
+
41
+ def lookup_semantic(intent_vector, threshold: 0.85)
42
+ return nil unless intent_vector && !patterns_l0.empty?
43
+
44
+ best_hash = nil
45
+ best_score = 0.0
46
+
47
+ mutex.synchronize do
48
+ patterns_l0.each do |hash, pattern|
49
+ next unless pattern[:intent_vector]
50
+
51
+ score = cosine_similarity(intent_vector, pattern[:intent_vector])
52
+ if score > best_score && score >= threshold
53
+ best_score = score
54
+ best_hash = hash
55
+ end
56
+ end
57
+ end
58
+
59
+ best_hash ? lookup(best_hash) : nil
60
+ end
61
+
62
+ def record_hit(intent_hash)
63
+ mutex.synchronize do
64
+ pattern = patterns_l0[intent_hash]
65
+ return unless pattern
66
+
67
+ pattern[:hit_count] = (pattern[:hit_count] || 0) + 1
68
+ pattern[:miss_count] = 0
69
+ pattern[:last_hit_at] = Time.now
70
+ pattern[:confidence] = (pattern[:confidence] + CONFIDENCE_SUCCESS_DELTA).clamp(0.0, 1.0)
71
+ end
72
+ sync_to_persistence(intent_hash)
73
+ end
74
+
75
+ def record_miss(intent_hash)
76
+ mutex.synchronize do
77
+ pattern = patterns_l0[intent_hash]
78
+ return unless pattern
79
+
80
+ pattern[:miss_count] = (pattern[:miss_count] || 0) + 1
81
+ pattern[:confidence] = (pattern[:confidence] + CONFIDENCE_FAILURE_DELTA).clamp(0.0, 1.0)
82
+ end
83
+ sync_to_persistence(intent_hash)
84
+ end
85
+
86
+ def promote_candidate(intent_hash:, tool_chain:, intent_text:, intent_vector: nil)
87
+ pattern = {
88
+ intent_hash: intent_hash,
89
+ intent_text: intent_text,
90
+ intent_vector: intent_vector,
91
+ tool_chain: tool_chain,
92
+ response_template: nil,
93
+ confidence: SEEDED_CONFIDENCE,
94
+ hit_count: 0,
95
+ miss_count: 0,
96
+ last_hit_at: nil,
97
+ created_at: Time.now,
98
+ context_requirements: nil
99
+ }
100
+ store(pattern)
101
+ candidates_mutex.synchronize { candidates_buffer.delete(intent_hash) }
102
+ pattern
103
+ end
104
+
105
+ def record_candidate(intent_hash:, tool_chain:, intent_text:, threshold: 3)
106
+ candidates_mutex.synchronize do
107
+ entry = candidates_buffer[intent_hash] ||= { intent_text: intent_text, tool_chain: tool_chain,
108
+ count: 0 }
109
+ entry[:count] += 1
110
+
111
+ if entry[:count] >= threshold && !pattern_exists?(intent_hash)
112
+ candidates_buffer.delete(intent_hash)
113
+ return { promote: true, intent_hash: intent_hash, tool_chain: tool_chain,
114
+ intent_text: intent_text }
115
+ end
116
+ end
117
+ nil
118
+ end
119
+
120
+ def candidates
121
+ candidates_mutex.synchronize { candidates_buffer.dup }
122
+ end
123
+
124
+ def patterns
125
+ mutex.synchronize { patterns_l0.dup }
126
+ end
127
+
128
+ def size
129
+ mutex.synchronize { patterns_l0.size }
130
+ end
131
+
132
+ def stats
133
+ total_hits = 0
134
+ total_conf = 0.0
135
+ count = 0
136
+
137
+ mutex.synchronize do
138
+ patterns_l0.each_value do |p|
139
+ total_hits += p[:hit_count] || 0
140
+ total_conf += p[:confidence] || 0.0
141
+ count += 1
142
+ end
143
+ end
144
+
145
+ {
146
+ size: count,
147
+ hit_rate: count.positive? ? (total_hits.to_f / [count, 1].max).round(2) : 0.0,
148
+ avg_confidence: count.positive? ? (total_conf / count).round(4) : 0.0
149
+ }
150
+ end
151
+
152
+ def reset!
153
+ mutex.synchronize { patterns_l0.clear }
154
+ candidates_mutex.synchronize { candidates_buffer.clear }
155
+ end
156
+
157
+ # --- Private helpers ---
158
+
159
+ def pattern_exists?(intent_hash)
160
+ mutex.synchronize { patterns_l0.key?(intent_hash) }
161
+ end
162
+
163
+ def cosine_similarity(vec_a, vec_b)
164
+ return 0.0 if vec_a.nil? || vec_b.nil? || vec_a.empty? || vec_b.empty?
165
+
166
+ dot = vec_a.zip(vec_b).sum { |a, b| a * b }
167
+ mag_a = Math.sqrt(vec_a.sum { |x| x**2 })
168
+ mag_b = Math.sqrt(vec_b.sum { |x| x**2 })
169
+ return 0.0 if mag_a.zero? || mag_b.zero?
170
+
171
+ dot / (mag_a * mag_b)
172
+ end
173
+
174
+ # --- L1: Cache (optional) ---
175
+
176
+ def persist_l1(intent_hash, pattern)
177
+ return unless defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
178
+
179
+ Legion::Cache.set("tbi:pattern:#{intent_hash}", Legion::JSON.dump(pattern), 3600)
180
+ rescue StandardError
181
+ nil
182
+ end
183
+
184
+ def lookup_l1(intent_hash)
185
+ return nil unless defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
186
+
187
+ raw = Legion::Cache.get("tbi:pattern:#{intent_hash}")
188
+ raw ? Legion::JSON.load(raw) : nil
189
+ rescue StandardError
190
+ nil
191
+ end
192
+
193
+ # --- L2: Data::Local SQLite (optional) ---
194
+
195
+ def persist_l2(intent_hash, pattern)
196
+ return unless local_db_available?
197
+
198
+ table = ensure_local_table
199
+ data = serialize_pattern(pattern)
200
+ if table.where(intent_hash: intent_hash).first
201
+ table.where(intent_hash: intent_hash).update(data)
202
+ else
203
+ table.insert(data)
204
+ end
205
+ rescue StandardError
206
+ nil
207
+ end
208
+
209
+ def lookup_l2(intent_hash)
210
+ return nil unless local_db_available?
211
+
212
+ table = ensure_local_table
213
+ row = table.where(intent_hash: intent_hash).first
214
+ row ? deserialize_pattern(row) : nil
215
+ rescue StandardError
216
+ nil
217
+ end
218
+
219
+ def sync_to_persistence(intent_hash)
220
+ pattern = mutex.synchronize { patterns_l0[intent_hash]&.dup }
221
+ return unless pattern
222
+
223
+ persist_l1(intent_hash, pattern)
224
+ persist_l2(intent_hash, pattern)
225
+ end
226
+
227
+ def local_db_available?
228
+ defined?(Legion::Data::Local) &&
229
+ Legion::Data::Local.respond_to?(:connected?) &&
230
+ Legion::Data::Local.connected?
231
+ end
232
+
233
+ def ensure_local_table
234
+ db = Legion::Data::Local.connection
235
+ unless db.table_exists?(:tbi_patterns)
236
+ db.create_table(:tbi_patterns) do
237
+ primary_key :id
238
+ String :intent_hash, null: false, unique: true
239
+ String :intent_text, text: true
240
+ String :intent_vector, text: true
241
+ String :tool_chain, text: true, null: false
242
+ String :response_template, text: true
243
+ Float :confidence, default: 0.5
244
+ Integer :hit_count, default: 0
245
+ Integer :miss_count, default: 0
246
+ DateTime :last_hit_at
247
+ DateTime :created_at
248
+ String :context_requirements, text: true
249
+ end
250
+ end
251
+ db[:tbi_patterns]
252
+ end
253
+
254
+ def serialize_pattern(pattern)
255
+ {
256
+ intent_hash: pattern[:intent_hash],
257
+ intent_text: pattern[:intent_text],
258
+ intent_vector: pattern[:intent_vector] ? ::JSON.dump(pattern[:intent_vector]) : nil,
259
+ tool_chain: ::JSON.dump(pattern[:tool_chain]),
260
+ response_template: pattern[:response_template],
261
+ confidence: pattern[:confidence],
262
+ hit_count: pattern[:hit_count],
263
+ miss_count: pattern[:miss_count],
264
+ last_hit_at: pattern[:last_hit_at],
265
+ created_at: pattern[:created_at],
266
+ context_requirements: pattern[:context_requirements]&.then { |c| ::JSON.dump(c) }
267
+ }
268
+ end
269
+
270
+ def deserialize_pattern(row)
271
+ {
272
+ intent_hash: row[:intent_hash],
273
+ intent_text: row[:intent_text],
274
+ intent_vector: row[:intent_vector] ? ::JSON.parse(row[:intent_vector]) : nil,
275
+ tool_chain: ::JSON.parse(row[:tool_chain]),
276
+ response_template: row[:response_template],
277
+ confidence: row[:confidence],
278
+ hit_count: row[:hit_count],
279
+ miss_count: row[:miss_count],
280
+ last_hit_at: row[:last_hit_at],
281
+ created_at: row[:created_at],
282
+ context_requirements: row[:context_requirements] ? ::JSON.parse(row[:context_requirements]) : nil
283
+ }
284
+ end
285
+
286
+ def patterns_l0
287
+ @patterns_l0 ||= {}
288
+ end
289
+
290
+ def mutex
291
+ @mutex ||= Mutex.new
292
+ end
293
+
294
+ def candidates_buffer
295
+ @candidates_buffer ||= {}
296
+ end
297
+
298
+ def candidates_mutex
299
+ @candidates_mutex ||= Mutex.new
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Resources
6
+ module ExtensionInfo
7
+ class << self
8
+ def static_resources
9
+ []
10
+ end
11
+
12
+ def resource_templates
13
+ [
14
+ ::MCP::ResourceTemplate.new(
15
+ uri_template: 'legion://extensions/{name}',
16
+ name: 'extension-info',
17
+ description: 'Detailed info about a Legion extension including runners, actors, and functions.',
18
+ mime_type: 'application/json'
19
+ )
20
+ ]
21
+ end
22
+
23
+ def register_read_handler(_server)
24
+ # Read handler is registered by RunnerCatalog to handle both resource types
25
+ end
26
+
27
+ def read(uri)
28
+ name = uri.sub('legion://extensions/', '')
29
+ return [] if name.empty?
30
+
31
+ unless data_connected?
32
+ return [{ uri: uri, mimeType: 'application/json',
33
+ text: Legion::JSON.dump({ error: 'legion-data is not connected' }) }]
34
+ end
35
+
36
+ ext = Legion::Data::Model::Extension.where(name: name).first
37
+ unless ext
38
+ return [{ uri: uri, mimeType: 'application/json',
39
+ text: Legion::JSON.dump({ error: "Extension '#{name}' not found" }) }]
40
+ end
41
+
42
+ runners = Legion::Data::Model::Runner.where(extension_id: ext.values[:id]).all
43
+ result = ext.values.merge(
44
+ runners: runners.map do |r|
45
+ functions = Legion::Data::Model::Function.where(runner_id: r.values[:id]).all
46
+ r.values.merge(functions: functions.map(&:values))
47
+ end
48
+ )
49
+
50
+ [{ uri: uri, mimeType: 'application/json', text: Legion::JSON.dump(result) }]
51
+ rescue StandardError => e
52
+ [{ uri: uri, mimeType: 'application/json',
53
+ text: Legion::JSON.dump({ error: "Failed to read extension: #{e.message}" }) }]
54
+ end
55
+
56
+ private
57
+
58
+ def data_connected?
59
+ Legion::Settings[:data][:connected]
60
+ rescue StandardError
61
+ false
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Resources
6
+ module RunnerCatalog
7
+ RESOURCE = ::MCP::Resource.new(
8
+ uri: 'legion://runners',
9
+ name: 'runner-catalog',
10
+ description: 'All available extension.runner.function paths in this Legion instance.',
11
+ mime_type: 'application/json'
12
+ )
13
+
14
+ class << self
15
+ def register(server)
16
+ server.resources << RESOURCE
17
+
18
+ server.resources_read_handler do |params|
19
+ if params[:uri] == 'legion://runners'
20
+ [{ uri: 'legion://runners', mimeType: 'application/json', text: catalog_json }]
21
+ elsif params[:uri]&.start_with?('legion://extensions/')
22
+ ExtensionInfo.read(params[:uri])
23
+ else
24
+ []
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def catalog_json
32
+ return Legion::JSON.dump({ error: 'legion-data is not connected' }) unless data_connected?
33
+
34
+ extensions = Legion::Data::Model::Extension.all
35
+ catalog = extensions.map do |ext|
36
+ runners = Legion::Data::Model::Runner.where(extension_id: ext.values[:id]).all
37
+ {
38
+ extension: ext.values[:name],
39
+ runners: runners.map do |r|
40
+ functions = Legion::Data::Model::Function.where(runner_id: r.values[:id]).all
41
+ {
42
+ runner: r.values[:namespace],
43
+ functions: functions.map { |f| f.values[:name] }
44
+ }
45
+ end
46
+ }
47
+ end
48
+
49
+ Legion::JSON.dump(catalog)
50
+ rescue StandardError => e
51
+ Legion::JSON.dump({ error: "Failed to build catalog: #{e.message}" })
52
+ end
53
+
54
+ def data_connected?
55
+ Legion::Settings[:data][:connected]
56
+ rescue StandardError
57
+ false
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'observer'
4
+ require_relative 'usage_filter'
5
+ require_relative 'tools/run_task'
6
+ require_relative 'tools/describe_runner'
7
+ require_relative 'tools/list_tasks'
8
+ require_relative 'tools/get_task'
9
+ require_relative 'tools/delete_task'
10
+ require_relative 'tools/get_task_logs'
11
+ require_relative 'tools/list_chains'
12
+ require_relative 'tools/create_chain'
13
+ require_relative 'tools/update_chain'
14
+ require_relative 'tools/delete_chain'
15
+ require_relative 'tools/list_relationships'
16
+ require_relative 'tools/create_relationship'
17
+ require_relative 'tools/update_relationship'
18
+ require_relative 'tools/delete_relationship'
19
+ require_relative 'tools/list_extensions'
20
+ require_relative 'tools/get_extension'
21
+ require_relative 'tools/enable_extension'
22
+ require_relative 'tools/disable_extension'
23
+ require_relative 'tools/list_schedules'
24
+ require_relative 'tools/create_schedule'
25
+ require_relative 'tools/update_schedule'
26
+ require_relative 'tools/delete_schedule'
27
+ require_relative 'tools/get_status'
28
+ require_relative 'tools/get_config'
29
+ require_relative 'tools/list_workers'
30
+ require_relative 'tools/show_worker'
31
+ require_relative 'tools/worker_lifecycle'
32
+ require_relative 'tools/worker_costs'
33
+ require_relative 'tools/team_summary'
34
+ require_relative 'tools/routing_stats'
35
+ require_relative 'tools/rbac_check'
36
+ require_relative 'tools/rbac_assignments'
37
+ require_relative 'tools/rbac_grants'
38
+ require_relative 'context_compiler'
39
+ require_relative 'embedding_index'
40
+ require_relative 'tools/do_action'
41
+ require_relative 'tools/discover_tools'
42
+ require_relative 'resources/runner_catalog'
43
+ require_relative 'resources/extension_info'
44
+
45
+ module Legion
46
+ module MCP
47
+ module Server
48
+ TOOL_CLASSES = [
49
+ Tools::RunTask,
50
+ Tools::DescribeRunner,
51
+ Tools::ListTasks,
52
+ Tools::GetTask,
53
+ Tools::DeleteTask,
54
+ Tools::GetTaskLogs,
55
+ Tools::ListChains,
56
+ Tools::CreateChain,
57
+ Tools::UpdateChain,
58
+ Tools::DeleteChain,
59
+ Tools::ListRelationships,
60
+ Tools::CreateRelationship,
61
+ Tools::UpdateRelationship,
62
+ Tools::DeleteRelationship,
63
+ Tools::ListExtensions,
64
+ Tools::GetExtension,
65
+ Tools::EnableExtension,
66
+ Tools::DisableExtension,
67
+ Tools::ListSchedules,
68
+ Tools::CreateSchedule,
69
+ Tools::UpdateSchedule,
70
+ Tools::DeleteSchedule,
71
+ Tools::GetStatus,
72
+ Tools::GetConfig,
73
+ Tools::ListWorkers,
74
+ Tools::ShowWorker,
75
+ Tools::WorkerLifecycle,
76
+ Tools::WorkerCosts,
77
+ Tools::TeamSummary,
78
+ Tools::RoutingStats,
79
+ Tools::RbacCheck,
80
+ Tools::RbacAssignments,
81
+ Tools::RbacGrants,
82
+ Tools::DoAction,
83
+ Tools::DiscoverTools
84
+ ].freeze
85
+
86
+ class << self
87
+ def build(identity: nil)
88
+ tools = if ToolGovernance.governance_enabled?
89
+ ToolGovernance.filter_tools(TOOL_CLASSES, identity)
90
+ else
91
+ TOOL_CLASSES
92
+ end
93
+
94
+ server = ::MCP::Server.new(
95
+ name: 'legion',
96
+ version: defined?(Legion::VERSION) ? Legion::VERSION : Legion::MCP::VERSION,
97
+ instructions: instructions,
98
+ tools: tools,
99
+ resources: Resources::ExtensionInfo.static_resources,
100
+ resource_templates: Resources::ExtensionInfo.resource_templates
101
+ )
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
+
116
+ Resources::RunnerCatalog.register(server)
117
+ Resources::ExtensionInfo.register_read_handler(server)
118
+
119
+ server
120
+ end
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
+
144
+ # Wire pattern promotion feedback loop for do_action calls
145
+ return unless data[:tool_name] == 'legion.do' && data[:tool_arguments]&.dig(:intent)
146
+
147
+ Observer.record_intent_with_result(
148
+ intent: data[:tool_arguments][:intent],
149
+ tool_name: data[:tool_name],
150
+ success: success
151
+ )
152
+ end
153
+
154
+ def build_filtered_tool_list(keywords: [])
155
+ tool_names = TOOL_CLASSES.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
156
+ ranked = UsageFilter.ranked_tools(tool_names, keywords: keywords)
157
+ ranked.filter_map do |name|
158
+ TOOL_CLASSES.find do |tc|
159
+ (tc.respond_to?(:tool_name) ? tc.tool_name : tc.name) == name
160
+ end
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ def instructions
167
+ <<~TEXT
168
+ Legion is an async job engine. You can run tasks, create chains and relationships between services, manage extensions, and query system status.
169
+
170
+ Use `legion.run_task` with dot notation (e.g., "http.request.get") for quick task execution.
171
+ Use `legion.describe_runner` to discover available functions on a runner.
172
+ CRUD tools follow the pattern: legion.list_*, legion.create_*, legion.get_*, legion.update_*, legion.delete_*.
173
+ TEXT
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end