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 +4 -4
- data/CHANGELOG.md +27 -0
- data/lib/legion/api/gaia.rb +11 -0
- data/lib/legion/api/llm.rb +6 -71
- data/lib/legion/cli/do_command.rb +76 -37
- data/lib/legion/extensions/builders/runners.rb +6 -0
- data/lib/legion/extensions/core.rb +8 -0
- data/lib/legion/extensions.rb +11 -42
- data/lib/legion/service.rb +39 -2
- data/lib/legion/tools/base.rb +87 -0
- data/lib/legion/tools/config.rb +64 -0
- data/lib/legion/tools/discovery.rb +190 -0
- data/lib/legion/tools/do.rb +151 -0
- data/lib/legion/tools/embedding_cache/migrations/001_create_tool_embedding_cache.rb +15 -0
- data/lib/legion/tools/embedding_cache.rb +417 -0
- data/lib/legion/tools/registry.rb +76 -0
- data/lib/legion/tools/status.rb +50 -0
- data/lib/legion/tools.rb +38 -0
- data/lib/legion/version.rb +1 -1
- data/lib/legion.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fc7545d6fe4e3dc832eba2265dc27fed7546fb3d770dec04b4375b35505479f
|
|
4
|
+
data.tar.gz: 513b2ff1093560b1d88c427b176d6f27959a6c94abd3106173d462d29f8cd69c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/legion/api/gaia.rb
CHANGED
|
@@ -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?
|
data/lib/legion/api/llm.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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::
|
|
56
|
+
return nil unless defined?(Legion::Tools::Registry)
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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::
|
|
114
|
+
return nil unless defined?(Legion::Tools::Registry) && defined?(Legion::LLM)
|
|
79
115
|
|
|
80
|
-
|
|
81
|
-
return nil if
|
|
116
|
+
tools = Legion::Tools::Registry.all_tools
|
|
117
|
+
return nil if tools.empty?
|
|
82
118
|
|
|
83
|
-
catalog =
|
|
84
|
-
prompt = "Given these
|
|
85
|
-
"Which
|
|
86
|
-
'Reply with ONLY the
|
|
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
|
-
|
|
97
|
-
return nil unless
|
|
132
|
+
tool = Legion::Tools::Registry.find(chosen)
|
|
133
|
+
return nil unless tool
|
|
98
134
|
|
|
99
|
-
|
|
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::
|
|
143
|
+
return nil unless defined?(Legion::Tools::Registry)
|
|
110
144
|
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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::
|
|
154
|
+
return nil unless defined?(Legion::Tools::Registry)
|
|
119
155
|
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -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
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
data/lib/legion/service.rb
CHANGED
|
@@ -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
|