legionio 1.7.16 → 1.7.19

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: c2114c2b32ae4dbc1650bef546391f6abb1499a0b9d96d577739503c47caba79
4
+ data.tar.gz: 1e95267b23de0b635ffb32c9f745f58fe0bac63aa7d806a6581e3557b9b1aad6
5
5
  SHA512:
6
- metadata.gz: c3d0342c0a3131bf421c7139ed2917eb94e037713e8d3cb6183680d1c3a04ffcd1bdca68679fadaa3c68237f5c0a994b33814776e56ea9bcdb865c0931b0c5f5
7
- data.tar.gz: 0df1adc61ab111c31b1c6a87ed5b3e15d4243bf112b9914b01f7e9e0d9c6dc2ac6272708ddc4337187443d028da80447fc33a97a69e296f110f95b83404b1065
6
+ metadata.gz: 56c140c38d73a16c97574b405c4f27bdb91de199758692e76aecd0ec55afad66acab08fc011cf31a658df408cfcf8506e5e2971f4fbe92a4276e8856153a7481
7
+ data.tar.gz: 903faf2b78e2f27e1176e03558138f58ee9e6bdfcc34ac3f61c8c933424a0f7a0c641ba97d38d27c527b41cad93c9d8f69bffb64fed8a032a5d1aeb2bd6cb5ff
data/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.7.19] - 2026-04-06
6
+
7
+ ### Added
8
+ - `ALWAYS_LOADED` constant in `Tools::Discovery` — pins apollo/knowledge and eval/evaluation runners to always-loaded regardless of extension DSL
9
+ - `always_loaded_names` method on `Tools::Registry` returning names of all non-deferred registered tools
10
+
11
+ ### Changed
12
+ - Tool name format changed from dot-separated to dash-separated (`legion-ext-runner-func`) for LLM provider compatibility
13
+ - Reduced noisy debug logging in `Tools::Discovery` and `Tools::Registry`
14
+
15
+ ## [1.7.18] - 2026-04-06
16
+
17
+ ### Added
18
+ - Multi-phase extension loading: identity providers (`lex-identity-*`) load in phase 0 before all other extensions in phase 1
19
+ - `identity` category in extension registry with prefix matching for `lex-identity-*` gems at tier 0, phase 0
20
+ - `group_by_phase` method groups discovered extensions by phase from the category registry
21
+ - `load_phase_extensions` replaces `load_extensions` — scopes parallel loading to a subset of entries per phase
22
+ - `hook_phase_actors` replaces `hook_all_actors` — hooks deferred actors after each phase completes
23
+ - Per-phase logging during extension loading shows cumulative actor counts
24
+
25
+ ### Changed
26
+ - `hook_extensions` now iterates phases sequentially (phase 0 then phase 1), running full load+hook cycle per phase
27
+ - `default_category_registry` includes `phase:` key on all categories; all non-identity categories default to phase 1
28
+ - Catalog transitions (`transition(:running)` + `flush_persisted_transitions`) happen after all phases complete
29
+ - Reserved prefixes list now includes `identity`
30
+
31
+ ### Added
32
+ - `Legion::Tools::Base` - canonical tool base class with DSL
33
+ - `Legion::Tools::Registry` - always/deferred tool classification
34
+ - `Legion::Tools::Discovery` - auto-discovers tools from extension runners with hierarchical DSL
35
+ - `Legion::Tools::EmbeddingCache` - 5-tier persistent embedding cache (L0 memory + Cache + Data)
36
+ - `mcp_tools?` and `mcp_tools_deferred?` extension Core DSL
37
+ - `runner_modules` accessor on extension builders
38
+ - `loaded_extension_modules` accessor on `Legion::Extensions`
39
+ - Static tools: `Do`, `Status`, `Config` with `Legion::Logging::Helper`
40
+
41
+ ### Changed
42
+ - Boot registers tools into Tools::Registry after extension load
43
+ - Embedding index build is async (non-blocking)
44
+ - API inference reads from Tools::Registry instead of MCP
45
+ - Capability registration methods are now no-ops (replaced by Tools::Discovery)
46
+
47
+ ### Removed
48
+ - Direct MCP dependency for tool access in API inference
49
+
5
50
  ## [1.7.16] - 2026-04-03
6
51
 
7
52
  ### Fixed
data/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/LegionIO
11
11
  **Gem**: `legionio`
12
- **Version**: 1.6.0
12
+ **Version**: 1.7.18
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
@@ -51,14 +51,14 @@ Legion.start
51
51
  ├── 10. setup_gaia (legion-gaia, cognitive coordination layer, optional)
52
52
  ├── 11. setup_telemetry (OpenTelemetry, optional)
53
53
  ├── 12. setup_supervision (process supervision)
54
- ├── 13. load_extensions (two-phase parallel: require+autobuild on FixedThreadPool, then hook_all_actors)
54
+ ├── 13. load_extensions (multi-phase: phase 0 (identity providers) loads and hooks actors first, then phase 1 (everything else))
55
55
  ├── 14. Legion::Crypt.cs (distribute cluster secret)
56
56
  └── 15. setup_api (start Sinatra/Puma on port 4567)
57
57
  ```
58
58
 
59
59
  Each phase calls `Legion::Readiness.mark_ready(:component)`. All phases are individually toggleable via `Service.new(transport: false, ...)`.
60
60
 
61
- Extension loading is two-phase and parallel: all extensions are `require`d and `autobuild` runs concurrently on a `Concurrent::FixedThreadPool(min(count, extensions.parallel_pool_size))`, collecting actors into a thread-safe `Concurrent::Array` of `@pending_actors`. Pool size defaults to 24, configurable via `Legion::Settings[:extensions][:parallel_pool_size]`. After all extensions are loaded, `hook_all_actors` starts AMQP subscriptions, timers, and other actor types sequentially. This prevents race conditions where early extensions start ticking while later ones haven't loaded yet. Thread safety relies on ThreadLocal AMQP channels, per-extension Settings keys, and sequential post-processing of Catalog transitions and Registry writes.
61
+ Extension loading is multi-phase and parallel: `hook_extensions` calls `group_by_phase` to partition discovered extensions by phase number (from the category registry), then iterates phases sequentially. Phase 0 contains identity providers (`lex-identity-*` gems, category `:identity`, tier 0); phase 1 contains all other extensions. Within each phase, extensions are `require`d and `autobuild` runs concurrently on a `Concurrent::FixedThreadPool(min(count, extensions.parallel_pool_size))`, collecting actors into a thread-safe `Concurrent::Array` of `@pending_actors`. Pool size defaults to 24, configurable via `Legion::Settings[:extensions][:parallel_pool_size]`. After each phase's extensions are loaded, `hook_phase_actors` starts AMQP subscriptions, timers, and other actor types for that phase sequentially ensuring identity providers are fully running before any other extension boots. Catalog transitions (`transition(:running)` and `flush_persisted_transitions`) happen after all phases complete. Thread safety relies on ThreadLocal AMQP channels, per-extension Settings keys, and sequential post-processing of Catalog transitions and Registry writes.
62
62
 
63
63
  ### Reload Sequence
64
64
 
@@ -85,7 +85,7 @@ Legion (lib/legion.rb)
85
85
  │ # Ingress.run(payload:, runner_class:, function:, source:)
86
86
  │ # Ingress.normalize returns message hash without executing
87
87
  ├── Extensions # LEX discovery, loading, and lifecycle management
88
- │ ├── Core # Mixin: data_required?, cache_required?, crypt_required?, etc.
88
+ │ ├── Core # Mixin: data_required?, cache_required?, crypt_required?, mcp_tools?, mcp_tools_deferred?, etc.
89
89
  │ ├── Actors/ # Actor execution modes
90
90
  │ │ ├── Base # Base actor class
91
91
  │ │ ├── Every # Run at interval (timer)
@@ -96,7 +96,7 @@ Legion (lib/legion.rb)
96
96
  │ │ └── Nothing # No-op actor
97
97
  │ ├── Builders/ # Build actors and runners from LEX definitions
98
98
  │ │ ├── Actors # Build actors from extension definitions
99
- │ │ ├── Runners # Build runners from extension definitions (stores runner_module ref)
99
+ │ │ ├── Runners # Build runners from extension definitions; exposes `runner_modules` accessor for Discovery
100
100
  │ │ ├── Helpers # Builder utilities
101
101
  │ │ ├── Hooks # Webhook hook system builder
102
102
  │ │ └── Routes # Auto-route builder: introspects runners, registers POST /api/extensions/* routes
@@ -149,7 +149,17 @@ Legion (lib/legion.rb)
149
149
  │ # Populated by Builders::Routes during autobuild via LexDispatch
150
150
 
151
151
  ├── MCP (legion-mcp gem) # Extracted to standalone gem — see legion-mcp/CLAUDE.md
152
- │ └── (58 tools, 2 resources, TierRouter, PatternStore, ContextGuard, Observer, EmbeddingIndex)
152
+ │ └── (tools, 2 resources, TierRouter, PatternStore, ContextGuard, Observer, EmbeddingIndex)
153
+
154
+ ├── Tools # Canonical tool layer — replaces Extensions::Capability and Catalog::Registry
155
+ │ ├── Base # Base class for all framework tools (Do, Status, Config are built-in statics)
156
+ │ ├── Registry # always/deferred classification for all tools; replaces Catalog::Registry
157
+ │ │ # Extensions declare tools via `mcp_tools?` / `mcp_tools_deferred?` DSL on Core
158
+ │ ├── Discovery # Auto-discovers tools from extension runner modules at boot
159
+ │ │ # `runner_modules` accessor on Builders::Runners feeds Discovery
160
+ │ │ # `loaded_extension_modules` on Extensions exposes the full set
161
+ │ └── EmbeddingCache # 5-tier persistent embedding cache:
162
+ │ # L0 in-memory hash → L1 Cache::Local → L2 Cache → L3 Data::Local → L4 Data
153
163
 
154
164
  ├── DigitalWorker # Digital worker platform (AI-as-labor governance)
155
165
  │ ├── Lifecycle # Worker state machine (active/paused/retired/terminated)
@@ -248,6 +258,16 @@ Legion (lib/legion.rb)
248
258
 
249
259
  `Legion::Extensions.find_extensions` discovers lex-* gems via `Bundler.load.specs` (when running under Bundler) or falls back to `Gem::Specification.all_names`. It also processes `Legion::Settings[:extensions]` for explicitly configured extensions, attempting `Gem.install` for missing ones if `auto_install` is enabled.
250
260
 
261
+ **Category registry**: Extensions are classified by `categorize_and_order` using `default_category_registry`. Each category has a `type` (`:list` or `:prefix`), `tier` (load order within a phase), and `phase`:
262
+
263
+ | Category | Type | Tier | Phase | Matches |
264
+ |----------|------|------|-------|---------|
265
+ | `identity` | prefix | 0 | 0 | `lex-identity-*` gems |
266
+ | `core` | list | 1 | 1 | explicitly listed core extensions |
267
+ | `ai` | list | 2 | 1 | explicitly listed AI provider extensions |
268
+ | `gaia` | list | 3 | 1 | explicitly listed GAIA extensions |
269
+ | `agentic` | prefix | 4 | 1 | `lex-agentic-*` gems |
270
+
251
271
  **Role-based filtering**: After discovery, `apply_role_filter` prunes extensions based on `Legion::Settings[:role][:profile]`:
252
272
 
253
273
  | Profile | What loads |
@@ -479,7 +499,7 @@ legion
479
499
 
480
500
  ### MCP Design
481
501
 
482
- Extracted to the `legion-mcp` gem (v0.5.9). See `legion-mcp/CLAUDE.md` for full architecture.
502
+ Extracted to the `legion-mcp` gem (v0.7.3). See `legion-mcp/CLAUDE.md` for full architecture.
483
503
 
484
504
  - `Legion::MCP.server` is memoized singleton — call `Legion::MCP.reset!` in tests
485
505
  - Tool naming: `legion.snake_case_name` (dot namespace, not slash)
@@ -571,7 +591,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
571
591
  | `lib/legion/readiness.rb` | Component readiness tracking (COMPONENTS constant, `ready?`, `to_h`) |
572
592
  | `lib/legion/events.rb` | In-process pub/sub: `on`, `emit`, `once`, `off`, wildcard `*` |
573
593
  | `lib/legion/ingress.rb` | Universal runner invocation: `normalize`, `run` |
574
- | `lib/legion/extensions.rb` | LEX discovery, loading, actor hooking, shutdown |
594
+ | `lib/legion/extensions.rb` | LEX discovery, loading, actor hooking, shutdown; exposes `loaded_extension_modules` for Tools::Discovery |
575
595
  | `lib/legion/extensions/core.rb` | Extension mixin (requirement flags, autobuild) |
576
596
  | `lib/legion/extensions/actors/` | Actor types: base, every, loop, once, poll, subscription, nothing, defaults |
577
597
  | `lib/legion/extensions/builders/` | Build actors, runners, helpers, hooks, routes from definitions |
@@ -586,7 +606,12 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
586
606
  | `lib/legion/isolation.rb` | Process isolation for untrusted extension execution |
587
607
  | `lib/legion/sandbox.rb` | Sandboxed execution environment for extensions |
588
608
  | `lib/legion/context.rb` | Thread-local execution context (request tracing, tenant) |
589
- | `lib/legion/catalog.rb` | Extension catalog: registry of available extensions with metadata |
609
+ | `lib/legion/catalog.rb` | Extension catalog: registry of available extensions with metadata (Catalog::Registry removed — replaced by Tools::Registry) |
610
+ | `lib/legion/tools.rb` | Tools module entry point |
611
+ | `lib/legion/tools/base.rb` | Tools::Base — canonical base class for all tools |
612
+ | `lib/legion/tools/registry.rb` | Tools::Registry — always/deferred classification, replaces Catalog::Registry |
613
+ | `lib/legion/tools/discovery.rb` | Tools::Discovery — auto-discovers tools from extension runner_modules at boot |
614
+ | `lib/legion/tools/embedding_cache.rb` | Tools::EmbeddingCache — 5-tier persistent embedding cache (L0–L4) |
590
615
  | `lib/legion/registry.rb` | Extension registry with security scanning |
591
616
  | `lib/legion/registry/security_scanner.rb` | Gem security scanner (CVE checks, signature verification) |
592
617
  | `lib/legion/webhooks.rb` | Webhook delivery system: HTTP POST with retry, HMAC signing |
@@ -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.