legionio 1.7.16 → 1.7.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60d6e7e501be5410e50a0096185b33af3f10b500ac766d2138efa67bb80908e0
4
- data.tar.gz: 10d105942a000d3eb726125a6e55c3dbe1c70474623671ac09a69278a5c998db
3
+ metadata.gz: 6fc7545d6fe4e3dc832eba2265dc27fed7546fb3d770dec04b4375b35505479f
4
+ data.tar.gz: 513b2ff1093560b1d88c427b176d6f27959a6c94abd3106173d462d29f8cd69c
5
5
  SHA512:
6
- metadata.gz: c3d0342c0a3131bf421c7139ed2917eb94e037713e8d3cb6183680d1c3a04ffcd1bdca68679fadaa3c68237f5c0a994b33814776e56ea9bcdb865c0931b0c5f5
7
- data.tar.gz: 0df1adc61ab111c31b1c6a87ed5b3e15d4243bf112b9914b01f7e9e0d9c6dc2ac6272708ddc4337187443d028da80447fc33a97a69e296f110f95b83404b1065
6
+ metadata.gz: b6de374974924d397249494b4aed6ff9e99263116a36d47226530c12653d672371565d060b65913d249fce4d0ed808fd6ffae45a669a6e4b1b67d87a056dcbe8
7
+ data.tar.gz: 2d64efa40365a2da40fcc97de5173e2c3bfd3458e8adf861a0efde9c35514d7b876ab200f2d5648ca68f08f5de879c29dde2c4d5c3f53d9c2240ae8f4575debe
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Added
6
+ - `Legion::Tools::Base` - canonical tool base class with DSL
7
+ - `Legion::Tools::Registry` - always/deferred tool classification
8
+ - `Legion::Tools::Discovery` - auto-discovers tools from extension runners with hierarchical DSL
9
+ - `Legion::Tools::EmbeddingCache` - 5-tier persistent embedding cache (L0 memory + Cache + Data)
10
+ - `mcp_tools?` and `mcp_tools_deferred?` extension Core DSL
11
+ - `runner_modules` accessor on extension builders
12
+ - `loaded_extension_modules` accessor on `Legion::Extensions`
13
+ - Static tools: `Do`, `Status`, `Config` with `Legion::Logging::Helper`
14
+
15
+ ### Changed
16
+ - Boot registers tools into Tools::Registry after extension load
17
+ - Embedding index build is async (non-blocking)
18
+ - API inference reads from Tools::Registry instead of MCP
19
+ - Capability registration methods are now no-ops (replaced by Tools::Discovery)
20
+
21
+ ### Removed
22
+ - Direct MCP dependency for tool access in API inference
23
+
5
24
  ## [1.7.16] - 2026-04-03
6
25
 
7
26
  ### Fixed
@@ -3,36 +3,6 @@
3
3
  require 'securerandom'
4
4
  require 'open3'
5
5
 
6
- begin
7
- require 'legion/cli/chat/tools/search_traces'
8
- if defined?(Legion::LLM::ToolRegistry) && defined?(Legion::CLI::Chat::Tools::SearchTraces)
9
- Legion::LLM::ToolRegistry.register(Legion::CLI::Chat::Tools::SearchTraces)
10
- end
11
- rescue LoadError => e
12
- Legion::Logging.log_exception(e, payload_summary: 'SearchTraces not available for API', component_type: :api) if defined?(Legion::Logging)
13
- end
14
-
15
- ALWAYS_LOADED_TOOLS = %w[
16
- legion_do
17
- legion_get_status
18
- legion_run_task
19
- legion_describe_runner
20
- legion_list_extensions
21
- legion_get_extension
22
- legion_list_tasks
23
- legion_get_task
24
- legion_get_task_logs
25
- legion_query_knowledge
26
- legion_knowledge_health
27
- legion_knowledge_context
28
- legion_list_workers
29
- legion_show_worker
30
- legion_mesh_status
31
- legion_list_peers
32
- legion_tools
33
- legion_search_sessions
34
- ].freeze
35
-
36
6
  module Legion
37
7
  class API < Sinatra::Base
38
8
  module Routes
@@ -59,44 +29,6 @@ module Legion
59
29
  defined?(Legion::Extensions::LLM::Gateway::Runners::Inference)
60
30
  end
61
31
 
62
- define_method(:cached_mcp_tools) do
63
- @@cached_mcp_tools ||= begin # rubocop:disable Style/ClassVars
64
- all = []
65
- begin
66
- require 'legion/mcp' unless defined?(Legion::MCP) && Legion::MCP.respond_to?(:server)
67
- Legion::MCP.server if defined?(Legion::MCP) && Legion::MCP.respond_to?(:server)
68
- rescue LoadError => e
69
- Legion::Logging.log_exception(e, payload_summary: 'cached_mcp_tools: failed to require legion/mcp', component_type: :api)
70
- end
71
- if defined?(Legion::MCP::Server) && Legion::MCP::Server.respond_to?(:tool_registry)
72
- require 'legion/llm/pipeline/mcp_tool_adapter' unless defined?(Legion::LLM::Pipeline::McpToolAdapter)
73
- Legion::Logging.info "[llm][api] cached_mcp_tools building from #{Legion::MCP::Server.tool_registry.size} MCP tools"
74
- Legion::MCP::Server.tool_registry.each do |tc|
75
- all << Legion::LLM::Pipeline::McpToolAdapter.new(tc)
76
- rescue StandardError => e
77
- Legion::Logging.log_exception(e, payload_summary: "cached_mcp_tools: failed to adapt #{tc}", component_type: :api)
78
- end
79
- end
80
- {
81
- always: all.select { |t| ALWAYS_LOADED_TOOLS.include?(t.name) }.freeze,
82
- deferred: all.reject { |t| ALWAYS_LOADED_TOOLS.include?(t.name) }.freeze,
83
- all: all.freeze
84
- }.freeze
85
- end
86
- end
87
-
88
- define_method(:inject_mcp_tools) do |session, requested_tools: []|
89
- cache = cached_mcp_tools
90
- cache[:always].each { |t| session.with_tool(t) }
91
-
92
- return if requested_tools.empty?
93
-
94
- requested = requested_tools.map { |n| n.to_s.tr('.', '_') }
95
- cache[:deferred].each do |t|
96
- session.with_tool(t) if requested.include?(t.name)
97
- end
98
- end
99
-
100
32
  define_method(:build_client_tool_class) do |tname, tdesc, tschema|
101
33
  klass = Class.new(RubyLLM::Tool) do
102
34
  description tdesc
@@ -185,7 +117,7 @@ module Legion
185
117
 
186
118
  message = body[:message]
187
119
 
188
- # Tier 0 check serve from PatternStore if available
120
+ # Tier 0 check - serve from PatternStore if available
189
121
  if defined?(Legion::MCP::TierRouter)
190
122
  tier_result = Legion::MCP::TierRouter.route(
191
123
  intent: message,
@@ -206,8 +138,7 @@ module Legion
206
138
  model = body[:model]
207
139
  provider = body[:provider]
208
140
 
209
- # Route through full Legion pipeline when gateway is available:
210
- # Ingress -> RBAC -> Events -> Task -> Gateway (metering + fleet) -> LLM
141
+ # Route through full Legion pipeline when gateway is available
211
142
  if gateway_available?
212
143
  ingress_result = Legion::Ingress.run(
213
144
  payload: { message: message, model: model, provider: provider,
@@ -315,7 +246,7 @@ module Legion
315
246
 
316
247
  caller_identity = env['legion.tenant_id'] || 'api:inference'
317
248
 
318
- # GAIA bridge push InputFrame to sensory buffer
249
+ # GAIA bridge - push InputFrame to sensory buffer
319
250
  last_user = messages.select { |m| (m[:role] || m['role']).to_s == 'user' }.last
320
251
  prompt = (last_user || {})[:content] || (last_user || {})['content'] || ''
321
252
 
@@ -345,18 +276,7 @@ module Legion
345
276
  # Detect streaming mode
346
277
  streaming = body[:stream] == true && env['HTTP_ACCEPT']&.include?('text/event-stream')
347
278
 
348
- # Inject MCP tools from daemon alongside client tools
349
- all_tools = tool_classes.dup
350
- begin
351
- mcp_cache = cached_mcp_tools
352
- mcp_to_inject = requested_tools.empty? ? mcp_cache[:always] : mcp_cache[:all]
353
- all_tools.concat(mcp_to_inject) if mcp_to_inject&.any?
354
- Legion::Logging.debug "[llm][api] inference mcp_injected=#{mcp_to_inject&.size || 0} total_tools=#{all_tools.size}"
355
- rescue StandardError => e
356
- Legion::Logging.log_exception(e, payload_summary: 'mcp tool injection failed', component_type: :api)
357
- end
358
-
359
- # Build pipeline request
279
+ # Executor handles all registry tool injection — API only passes client-defined tools
360
280
  require 'legion/llm/pipeline/request' unless defined?(Legion::LLM::Pipeline::Request)
361
281
  require 'legion/llm/pipeline/executor' unless defined?(Legion::LLM::Pipeline::Executor)
362
282
 
@@ -364,7 +284,7 @@ module Legion
364
284
  messages: messages,
365
285
  system: body[:system],
366
286
  routing: { provider: provider, model: model },
367
- tools: all_tools,
287
+ tools: tool_classes,
368
288
  caller: { requested_by: { identity: caller_identity, type: :user, credential: :api } },
369
289
  conversation_id: body[:conversation_id],
370
290
  metadata: { requested_tools: requested_tools },
@@ -15,7 +15,7 @@ module Legion
15
15
  result = try_daemon(intent, options) || try_in_process(intent) || try_llm_classify(intent)
16
16
 
17
17
  if result.nil?
18
- formatter.error('No matching capability found')
18
+ formatter.error('No matching tool found')
19
19
  formatter.detail('Try: legion lex list (to see available extensions)')
20
20
  raise SystemExit, 1
21
21
  end
@@ -53,37 +53,73 @@ module Legion
53
53
  end
54
54
 
55
55
  def try_in_process(intent)
56
- return nil unless defined?(Legion::Extensions::Catalog::Registry)
56
+ return nil unless defined?(Legion::Tools::Registry)
57
57
 
58
- matches = Legion::Extensions::Catalog::Registry.find_by_intent(intent)
59
- return nil if matches.empty?
58
+ matched = Legion::Tools::Registry.all_tools.find do |t|
59
+ t.tool_name.include?(intent.downcase.tr(' ', '_')) ||
60
+ t.description.downcase.include?(intent.downcase)
61
+ end
62
+ return nil unless matched
63
+
64
+ begin
65
+ result = matched.call
66
+ normalize_in_process_result(result, matched.tool_name)
67
+ rescue ArgumentError
68
+ { matched: matched.tool_name, status: 'requires_daemon',
69
+ note: 'Tool requires arguments; start the daemon and retry: legion start' }
70
+ end
71
+ end
72
+
73
+ def normalize_in_process_result(result, tool_name)
74
+ return { matched: tool_name, result: result } unless result.is_a?(Hash)
75
+
76
+ normalized = result.dup
77
+ normalized[:matched] = tool_name
78
+ extracted = extract_tool_text(normalized)
60
79
 
61
- best = matches.first
62
- runner_class = build_runner_class(best.extension, best.runner)
80
+ if normalized[:error] == true
81
+ normalized[:error] = extracted.empty? ? 'Tool execution failed' : extracted
82
+ elsif !normalized.key?(:result) && !extracted.empty?
83
+ normalized[:result] = extracted
84
+ end
85
+
86
+ normalized
87
+ end
63
88
 
64
- if defined?(Legion::Ingress)
65
- Legion::Ingress.run(
66
- payload: { intent: intent },
67
- runner_class: runner_class,
68
- function: best.function,
69
- source: 'cli:do'
70
- )
89
+ def extract_tool_text(value)
90
+ case value
91
+ when Hash
92
+ error_val = value[:error] || value['error']
93
+ return error_val.to_s unless error_val == true || error_val.nil? || error_val.to_s.empty?
94
+
95
+ %i[message result response detail content].each do |key|
96
+ extracted = extract_tool_text(value[key] || value[key.to_s])
97
+ return extracted unless extracted.empty?
98
+ end
99
+
100
+ ''
101
+ when Array
102
+ value.filter_map do |item|
103
+ text = extract_tool_text(item)
104
+ text unless text.empty?
105
+ end.join("\n")
106
+ when String
107
+ value.strip
71
108
  else
72
- { matched: best.name, runner_class: runner_class, function: best.function,
73
- status: 'resolved', note: 'Daemon not running; cannot execute. Start with: legion start' }
109
+ value.nil? ? '' : value.to_s
74
110
  end
75
111
  end
76
112
 
77
113
  def try_llm_classify(intent)
78
- return nil unless defined?(Legion::Extensions::Catalog::Registry) && defined?(Legion::LLM)
114
+ return nil unless defined?(Legion::Tools::Registry) && defined?(Legion::LLM)
79
115
 
80
- caps = Legion::Extensions::Catalog::Registry.capabilities
81
- return nil if caps.empty?
116
+ tools = Legion::Tools::Registry.all_tools
117
+ return nil if tools.empty?
82
118
 
83
- catalog = caps.map { |c| "#{c.name}: #{c.description || "#{c.extension} #{c.runner}##{c.function}"}" }
84
- prompt = "Given these capabilities:\n#{catalog.join("\n")}\n\n" \
85
- "Which capability best matches this intent: \"#{intent}\"?\n" \
86
- 'Reply with ONLY the capability name (e.g., lex-consul:health_check:run). ' \
119
+ catalog = tools.map { |t| "#{t.tool_name}: #{t.description}" }
120
+ prompt = "Given these tools:\n#{catalog.join("\n")}\n\n" \
121
+ "Which tool best matches this intent: \"#{intent}\"?\n" \
122
+ 'Reply with ONLY the tool name (e.g., legion.do). ' \
87
123
  'If none match, reply NONE.'
88
124
 
89
125
  response = Legion::LLM.ask(
@@ -93,12 +129,10 @@ module Legion
93
129
  chosen = response.is_a?(Hash) ? response[:response].to_s.strip : response.to_s.strip
94
130
  return nil if chosen.empty? || chosen.upcase == 'NONE'
95
131
 
96
- cap = Legion::Extensions::Catalog::Registry.find(name: chosen)
97
- return nil unless cap
132
+ tool = Legion::Tools::Registry.find(chosen)
133
+ return nil unless tool
98
134
 
99
- runner_class = build_runner_class(cap.extension, cap.runner)
100
- { matched: cap.name, runner_class: runner_class, function: cap.function,
101
- status: 'resolved', source: 'llm',
135
+ { matched: tool.tool_name, status: 'resolved', source: 'llm',
102
136
  note: 'Daemon not running; cannot execute. Start with: legion start' }
103
137
  rescue StandardError => e
104
138
  Legion::Logging.debug("DoCommand#try_llm_classify failed: #{e.message}") if defined?(Legion::Logging)
@@ -106,26 +140,31 @@ module Legion
106
140
  end
107
141
 
108
142
  def resolve_runner_class(intent)
109
- return nil unless defined?(Legion::Extensions::Catalog::Registry)
143
+ return nil unless defined?(Legion::Tools::Registry)
110
144
 
111
- matches = Legion::Extensions::Catalog::Registry.find_by_intent(intent)
112
- return nil if matches.empty?
145
+ matched = Legion::Tools::Registry.all_tools.find do |t|
146
+ t.description.downcase.include?(intent.downcase)
147
+ end
148
+ return nil unless matched.respond_to?(:extension) && matched.respond_to?(:runner)
113
149
 
114
- build_runner_class(matches.first.extension, matches.first.runner)
150
+ build_runner_class(matched.extension, matched.runner)
115
151
  end
116
152
 
117
153
  def resolve_function(intent)
118
- return nil unless defined?(Legion::Extensions::Catalog::Registry)
154
+ return nil unless defined?(Legion::Tools::Registry)
119
155
 
120
- matches = Legion::Extensions::Catalog::Registry.find_by_intent(intent)
121
- return nil if matches.empty?
156
+ matched = Legion::Tools::Registry.all_tools.find do |t|
157
+ t.description.downcase.include?(intent.downcase)
158
+ end
159
+ return nil unless matched
122
160
 
123
- matches.first.function
161
+ matched.tool_name.split('.').last
124
162
  end
125
163
 
126
164
  def build_runner_class(extension, runner)
127
- ext_part = extension.delete_prefix('lex-').split(/[-_]/).map(&:capitalize).join
128
- "Legion::Extensions::#{ext_part}::Runners::#{runner}"
165
+ ext_part = extension.to_s.delete_prefix('lex-').split(/[-_]/).map(&:capitalize).join
166
+ runner_part = runner.to_s.split('_').map(&:capitalize).join
167
+ "Legion::Extensions::#{ext_part}::Runners::#{runner_part}"
129
168
  end
130
169
 
131
170
  def daemon_port(options)
@@ -59,6 +59,12 @@ module Legion
59
59
  end
60
60
  end
61
61
 
62
+ def runner_modules
63
+ return [] unless defined?(@runners) && @runners.is_a?(Hash)
64
+
65
+ @runners.values.filter_map { |r| r[:runner_module] }
66
+ end
67
+
62
68
  def runner_files
63
69
  @runner_files ||= find_files('runners')
64
70
  end
@@ -112,6 +112,14 @@ module Legion
112
112
  true
113
113
  end
114
114
 
115
+ def mcp_tools?
116
+ true
117
+ end
118
+
119
+ def mcp_tools_deferred?
120
+ true
121
+ end
122
+
115
123
  # Auto-generate AMQP message classes for each runner method that has a definition.
116
124
  # Explicit Messages::* classes in the transport directory take precedence.
117
125
  # Runs after build_runners so definitions are populated.
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/core'
4
- require 'legion/extensions/capability'
5
4
  require 'legion/extensions/catalog'
6
5
  require 'legion/extensions/permissions'
7
6
  require 'legion/runner'
@@ -567,54 +566,24 @@ module Legion
567
566
 
568
567
  public
569
568
 
570
- def unregister_capabilities(gem_name)
571
- Extensions::Catalog::Registry.unregister_extension(gem_name)
572
- end
569
+ def loaded_extension_modules
570
+ constants(false).filter_map do |const_name|
571
+ mod = const_get(const_name, false)
572
+ next nil unless mod.is_a?(Module) && mod.respond_to?(:runner_modules)
573
573
 
574
- def register_absorber_capabilities(gem_name, absorbers)
575
- absorbers.each_value do |absorber_meta|
576
- cap = Extensions::Capability.from_absorber(
577
- extension: gem_name,
578
- absorber: absorber_meta[:absorber_module],
579
- patterns: absorber_meta[:patterns],
580
- description: absorber_meta[:description]
581
- )
582
- Extensions::Catalog::Registry.register(cap)
574
+ mod
583
575
  rescue StandardError => e
584
- if defined?(Legion::Logging)
585
- Legion::Logging.warn(
586
- "Absorber catalog registration error for #{gem_name} " \
587
- "(#{absorber_meta[:absorber_module]}): #{e.message}"
588
- )
589
- end
576
+ Legion::Logging.warn("[Extensions] loaded_extension_modules: #{e.message}") if defined?(Legion::Logging)
577
+ nil
590
578
  end
591
579
  end
592
580
 
593
- def register_capabilities(gem_name, runners)
594
- runners.each_value do |runner_meta|
595
- runner_name = runner_meta[:runner_name]
596
- (runner_meta[:class_methods] || {}).each do |fn_name, fn_meta|
597
- next if fn_name.to_s.start_with?('_')
581
+ # Legacy capability registration - now handled by Tools::Discovery
582
+ def unregister_capabilities(_gem_name); end
598
583
 
599
- params = {}
600
- (fn_meta[:args] || []).each do |arg|
601
- type, name = arg
602
- params[name] = { type: :string, required: type == :keyreq }
603
- end
584
+ def register_absorber_capabilities(_gem_name, _absorbers); end
604
585
 
605
- cap = Extensions::Capability.from_runner(
606
- extension: gem_name,
607
- runner: runner_name.to_s.split('_').map(&:capitalize).join,
608
- function: fn_name.to_s,
609
- parameters: params,
610
- tags: [gem_name.delete_prefix('lex-')]
611
- )
612
- Extensions::Catalog::Registry.register(cap)
613
- end
614
- rescue StandardError => e
615
- Legion::Logging.warn("Catalog registration error for #{gem_name}: #{e.message}") if defined?(Legion::Logging)
616
- end
617
- end
586
+ def register_capabilities(_gem_name, _runners); end
618
587
 
619
588
  def gem_load(entry)
620
589
  gem_name = entry[:gem_name]
@@ -149,6 +149,8 @@ module Legion
149
149
  setup_generated_functions
150
150
  end
151
151
 
152
+ register_core_tools
153
+
152
154
  Legion::Gaia.registry&.rediscover if gaia && defined?(Legion::Gaia) && Legion::Gaia.started?
153
155
 
154
156
  Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer.setup if defined?(Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer)
@@ -159,10 +161,11 @@ module Legion
159
161
  setup_metrics
160
162
  setup_task_outcome_observer
161
163
 
162
- # Pre-warm MCP server in background so first inference isn't blocked by 837-tool build
164
+ # Pre-warm MCP server in background; async embedding build
163
165
  Thread.new do
164
166
  require 'legion/mcp' if defined?(Legion::Settings) && !defined?(Legion::MCP)
165
167
  Legion::MCP.server if defined?(Legion::MCP) && Legion::MCP.respond_to?(:server)
168
+ Legion::MCP::Server.populate_embedding_index if defined?(Legion::MCP::Server) && Legion::MCP::Server.respond_to?(:populate_embedding_index)
166
169
  rescue StandardError => e
167
170
  log.warn("MCP pre-warm failed: #{e.message}")
168
171
  end
@@ -607,7 +610,7 @@ module Legion
607
610
  handle_exception(e, level: :warn, operation: 'service.shutdown_api')
608
611
  end
609
612
 
610
- def shutdown
613
+ def shutdown # rubocop:disable Metrics/CyclomaticComplexity
611
614
  log.info('Legion::Service.shutdown was called')
612
615
  @shutdown = true
613
616
  Legion::Settings[:client][:shutting_down] = true
@@ -631,6 +634,8 @@ module Legion
631
634
 
632
635
  shutdown_component('Dispatch') { Legion::Dispatch.shutdown } if defined?(Legion::Dispatch)
633
636
 
637
+ Legion::Tools::Registry.clear if defined?(Legion::Tools::Registry)
638
+
634
639
  ext_timeout = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15
635
640
  shutdown_component('Extensions', timeout: ext_timeout) { Legion::Extensions.shutdown }
636
641
  Legion::Readiness.mark_not_ready(:extensions)
@@ -665,7 +670,7 @@ module Legion
665
670
  Legion::Events.emit('service.shutdown')
666
671
  end
667
672
 
668
- def reload # rubocop:disable Metrics/MethodLength
673
+ def reload # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
669
674
  return if @reloading
670
675
 
671
676
  @reloading = true
@@ -680,6 +685,9 @@ module Legion
680
685
  Legion::Readiness.mark_not_ready(:gaia)
681
686
  end
682
687
 
688
+ Legion::Tools::Registry.clear if defined?(Legion::Tools::Registry)
689
+ Legion::Tools::EmbeddingCache.clear_memory if defined?(Legion::Tools::EmbeddingCache) && Legion::Tools::EmbeddingCache.respond_to?(:clear_memory)
690
+
683
691
  ext_timeout = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15
684
692
  shutdown_component('Extensions', timeout: ext_timeout) { Legion::Extensions.shutdown }
685
693
  Legion::Readiness.mark_not_ready(:extensions)
@@ -727,8 +735,15 @@ module Legion
727
735
  load_extensions
728
736
  Legion::Readiness.mark_ready(:extensions)
729
737
 
738
+ register_core_tools
739
+
730
740
  Legion::Crypt.cs
731
741
  setup_api if @api_enabled
742
+
743
+ if defined?(Legion::MCP)
744
+ Legion::MCP.reset!
745
+ Legion::MCP.server if Legion::MCP.respond_to?(:server)
746
+ end
732
747
  setup_network_watchdog
733
748
  Legion::Settings[:client][:ready] = true
734
749
  Legion::Events.emit('service.ready')
@@ -742,6 +757,20 @@ module Legion
742
757
  Legion::Extensions.hook_extensions
743
758
  end
744
759
 
760
+ def register_core_tools
761
+ require 'legion/tools'
762
+ Legion::Tools.register_all
763
+ Legion::Tools::Discovery.discover_and_register
764
+ Legion::Tools::EmbeddingCache.setup
765
+
766
+ log.info(
767
+ "Tools registered: #{Legion::Tools::Registry.tools.size} always, " \
768
+ "#{Legion::Tools::Registry.deferred_tools.size} deferred"
769
+ )
770
+ rescue StandardError => e
771
+ handle_exception(e, level: :warn, operation: 'service.register_core_tools')
772
+ end
773
+
745
774
  def setup_generated_functions
746
775
  return unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
747
776
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Tools
5
+ class Base
6
+ class << self
7
+ # Lazy delegation instead of include Helper — Base loads at require time
8
+ # before Settings is initialized; Helper#log builds TaggedLogger which
9
+ # calls derive_log_segments -> Settings -> possible recursion.
10
+ # Subclass static tools (Do, Status, Config) CAN include Helper safely.
11
+ def log
12
+ Legion::Logging.respond_to?(:logger) ? Legion::Logging.logger : nil
13
+ end
14
+
15
+ def handle_exception(err, **opts)
16
+ log&.warn("[Legion::Tools] #{opts[:operation] || 'unknown'}: #{err.message}")
17
+ end
18
+
19
+ def tool_name(name = nil)
20
+ name ? @tool_name = name : @tool_name
21
+ end
22
+
23
+ def description(desc = nil)
24
+ desc ? @description = desc : (@description || '')
25
+ end
26
+
27
+ def input_schema(schema = nil)
28
+ schema ? @input_schema = schema : @input_schema
29
+ end
30
+
31
+ def deferred(val = nil)
32
+ return @deferred || false if val.nil?
33
+
34
+ @deferred = val
35
+ end
36
+
37
+ def deferred?
38
+ deferred
39
+ end
40
+
41
+ # Metadata that replaces Capability - Tools::Registry IS the catalog
42
+ def extension(val = nil)
43
+ return @extension if val.nil?
44
+
45
+ @extension = val
46
+ end
47
+
48
+ def runner(val = nil)
49
+ return @runner if val.nil?
50
+
51
+ @runner = val
52
+ end
53
+
54
+ def tags(val = nil)
55
+ return @tags || [] if val.nil?
56
+
57
+ @tags = val
58
+ end
59
+
60
+ def mcp_category(val = nil)
61
+ return @mcp_category if val.nil?
62
+
63
+ @mcp_category = val
64
+ end
65
+
66
+ def mcp_tier(val = nil)
67
+ return @mcp_tier if val.nil?
68
+
69
+ @mcp_tier = val
70
+ end
71
+
72
+ def call(**_args)
73
+ raise NotImplementedError, "#{name} must implement .call"
74
+ end
75
+
76
+ def text_response(data)
77
+ text = data.is_a?(String) ? data : Legion::JSON.dump(data)
78
+ { content: [{ type: 'text', text: text }] }
79
+ end
80
+
81
+ def error_response(msg)
82
+ { content: [{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end