legionio 1.7.15 → 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: de3b6cd94efb3c63cc92dfcd54303a89fa9517b9a525349b2a40750373e6cb2f
4
- data.tar.gz: 8cbee81b7503b87a10502b47b308d614e1c22a2675f881c110c9cc8b753561c5
3
+ metadata.gz: 6fc7545d6fe4e3dc832eba2265dc27fed7546fb3d770dec04b4375b35505479f
4
+ data.tar.gz: 513b2ff1093560b1d88c427b176d6f27959a6c94abd3106173d462d29f8cd69c
5
5
  SHA512:
6
- metadata.gz: bbaa15bc0e0c87a1f72bc8efc84f005e6a1f6b02bb56464436e7cc3d80ec67a9f691107561b3a817d2bb63b5be0f3c0c2b081a5595a491daa29db5328bffb2f2
7
- data.tar.gz: af119a4f87785f6bc2f05b2c01977e7d352882f20fb1407b6aef7e3ed08f1d96ab5c37f8814f6bd7eb403b554cd9cfb7ea0aa71e46509703e3d34dfe6f6ced2a
6
+ metadata.gz: b6de374974924d397249494b4aed6ff9e99263116a36d47226530c12653d672371565d060b65913d249fce4d0ed808fd6ffae45a669a6e4b1b67d87a056dcbe8
7
+ data.tar.gz: 2d64efa40365a2da40fcc97de5173e2c3bfd3458e8adf861a0efde9c35514d7b876ab200f2d5648ca68f08f5de879c29dde2c4d5c3f53d9c2240ae8f4575debe
data/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
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
+
24
+ ## [1.7.16] - 2026-04-03
25
+
26
+ ### Fixed
27
+ - Inference endpoint now injects daemon MCP tools alongside client tools via class-level cached adapters
28
+ - MCP server pre-warmed in background thread during boot to avoid blocking first inference
29
+ - Gaia ticks route added to fallback API routes
30
+ - Reload endpoint disabled (418) to prevent accidental restart loops
31
+
5
32
  ## [1.7.15] - 2026-04-03
6
33
 
7
34
  ### Added
@@ -6,12 +6,23 @@ module Legion
6
6
  module Gaia
7
7
  def self.registered(app)
8
8
  register_status_route(app)
9
+ register_ticks_route(app)
9
10
  register_channels_route(app)
10
11
  register_buffer_route(app)
11
12
  register_sessions_route(app)
12
13
  register_teams_webhook_route(app)
13
14
  end
14
15
 
16
+ def self.register_ticks_route(app)
17
+ app.get '/api/gaia/ticks' do
18
+ halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available?
19
+
20
+ limit = (params[:limit] || 50).to_i.clamp(1, 200)
21
+ events = defined?(Legion::Gaia) ? Legion::Gaia.tick_history&.recent(limit: limit) : []
22
+ json_response({ events: events || [] })
23
+ end
24
+ end
25
+
15
26
  def self.register_status_route(app)
16
27
  app.get '/api/gaia/status' do
17
28
  if gaia_available?
@@ -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,42 +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
64
- all = []
65
- begin
66
- require 'legion/mcp' unless defined?(Legion::MCP) && Legion::MCP.respond_to?(:server)
67
- rescue LoadError => e
68
- Legion::Logging.log_exception(e, payload_summary: 'cached_mcp_tools: failed to require legion/mcp', component_type: :api)
69
- end
70
- if defined?(Legion::MCP::Server) && Legion::MCP::Server.respond_to?(:tool_registry)
71
- require 'legion/llm/pipeline/mcp_tool_adapter' unless defined?(Legion::LLM::Pipeline::McpToolAdapter)
72
- Legion::MCP::Server.tool_registry.each do |tc|
73
- all << Legion::LLM::Pipeline::McpToolAdapter.new(tc)
74
- rescue StandardError => e
75
- Legion::Logging.log_exception(e, payload_summary: "cached_mcp_tools: failed to adapt #{tc}", component_type: :api)
76
- end
77
- end
78
- {
79
- always: all.select { |t| ALWAYS_LOADED_TOOLS.include?(t.name) }.freeze,
80
- deferred: all.reject { |t| ALWAYS_LOADED_TOOLS.include?(t.name) }.freeze,
81
- all: all.freeze
82
- }.freeze
83
- end
84
- end
85
-
86
- define_method(:inject_mcp_tools) do |session, requested_tools: []|
87
- cache = cached_mcp_tools
88
- cache[:always].each { |t| session.with_tool(t) }
89
-
90
- return if requested_tools.empty?
91
-
92
- requested = requested_tools.map { |n| n.to_s.tr('.', '_') }
93
- cache[:deferred].each do |t|
94
- session.with_tool(t) if requested.include?(t.name)
95
- end
96
- end
97
-
98
32
  define_method(:build_client_tool_class) do |tname, tdesc, tschema|
99
33
  klass = Class.new(RubyLLM::Tool) do
100
34
  description tdesc
@@ -183,7 +117,7 @@ module Legion
183
117
 
184
118
  message = body[:message]
185
119
 
186
- # Tier 0 check serve from PatternStore if available
120
+ # Tier 0 check - serve from PatternStore if available
187
121
  if defined?(Legion::MCP::TierRouter)
188
122
  tier_result = Legion::MCP::TierRouter.route(
189
123
  intent: message,
@@ -204,8 +138,7 @@ module Legion
204
138
  model = body[:model]
205
139
  provider = body[:provider]
206
140
 
207
- # Route through full Legion pipeline when gateway is available:
208
- # Ingress -> RBAC -> Events -> Task -> Gateway (metering + fleet) -> LLM
141
+ # Route through full Legion pipeline when gateway is available
209
142
  if gateway_available?
210
143
  ingress_result = Legion::Ingress.run(
211
144
  payload: { message: message, model: model, provider: provider,
@@ -313,7 +246,7 @@ module Legion
313
246
 
314
247
  caller_identity = env['legion.tenant_id'] || 'api:inference'
315
248
 
316
- # GAIA bridge push InputFrame to sensory buffer
249
+ # GAIA bridge - push InputFrame to sensory buffer
317
250
  last_user = messages.select { |m| (m[:role] || m['role']).to_s == 'user' }.last
318
251
  prompt = (last_user || {})[:content] || (last_user || {})['content'] || ''
319
252
 
@@ -338,10 +271,12 @@ module Legion
338
271
  build_client_tool_class(ts[:name].to_s, ts[:description].to_s, ts[:parameters] || ts[:input_schema])
339
272
  end
340
273
 
274
+ Legion::Logging.debug "[llm][api] inference inbound client_tools=#{tool_classes.size} requested_tools=#{requested_tools.size}"
275
+
341
276
  # Detect streaming mode
342
277
  streaming = body[:stream] == true && env['HTTP_ACCEPT']&.include?('text/event-stream')
343
278
 
344
- # Build pipeline request
279
+ # Executor handles all registry tool injection — API only passes client-defined tools
345
280
  require 'legion/llm/pipeline/request' unless defined?(Legion::LLM::Pipeline::Request)
346
281
  require 'legion/llm/pipeline/executor' unless defined?(Legion::LLM::Pipeline::Executor)
347
282
 
@@ -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,6 +161,15 @@ module Legion
159
161
  setup_metrics
160
162
  setup_task_outcome_observer
161
163
 
164
+ # Pre-warm MCP server in background; async embedding build
165
+ Thread.new do
166
+ require 'legion/mcp' if defined?(Legion::Settings) && !defined?(Legion::MCP)
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)
169
+ rescue StandardError => e
170
+ log.warn("MCP pre-warm failed: #{e.message}")
171
+ end
172
+
162
173
  require 'sinatra/base'
163
174
  require 'legion/api/default_settings'
164
175
  api_settings = Legion::Settings[:api]
@@ -599,7 +610,7 @@ module Legion
599
610
  handle_exception(e, level: :warn, operation: 'service.shutdown_api')
600
611
  end
601
612
 
602
- def shutdown
613
+ def shutdown # rubocop:disable Metrics/CyclomaticComplexity
603
614
  log.info('Legion::Service.shutdown was called')
604
615
  @shutdown = true
605
616
  Legion::Settings[:client][:shutting_down] = true
@@ -623,6 +634,8 @@ module Legion
623
634
 
624
635
  shutdown_component('Dispatch') { Legion::Dispatch.shutdown } if defined?(Legion::Dispatch)
625
636
 
637
+ Legion::Tools::Registry.clear if defined?(Legion::Tools::Registry)
638
+
626
639
  ext_timeout = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15
627
640
  shutdown_component('Extensions', timeout: ext_timeout) { Legion::Extensions.shutdown }
628
641
  Legion::Readiness.mark_not_ready(:extensions)
@@ -657,7 +670,7 @@ module Legion
657
670
  Legion::Events.emit('service.shutdown')
658
671
  end
659
672
 
660
- def reload # rubocop:disable Metrics/MethodLength
673
+ def reload # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
661
674
  return if @reloading
662
675
 
663
676
  @reloading = true
@@ -672,6 +685,9 @@ module Legion
672
685
  Legion::Readiness.mark_not_ready(:gaia)
673
686
  end
674
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
+
675
691
  ext_timeout = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15
676
692
  shutdown_component('Extensions', timeout: ext_timeout) { Legion::Extensions.shutdown }
677
693
  Legion::Readiness.mark_not_ready(:extensions)
@@ -719,8 +735,15 @@ module Legion
719
735
  load_extensions
720
736
  Legion::Readiness.mark_ready(:extensions)
721
737
 
738
+ register_core_tools
739
+
722
740
  Legion::Crypt.cs
723
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
724
747
  setup_network_watchdog
725
748
  Legion::Settings[:client][:ready] = true
726
749
  Legion::Events.emit('service.ready')
@@ -734,6 +757,20 @@ module Legion
734
757
  Legion::Extensions.hook_extensions
735
758
  end
736
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
+
737
774
  def setup_generated_functions
738
775
  return unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
739
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