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 +4 -4
- data/CHANGELOG.md +45 -0
- data/CLAUDE.md +34 -9
- data/lib/legion/api/llm.rb +5 -85
- 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 +69 -93
- data/lib/legion/service.rb +32 -3
- data/lib/legion/tools/base.rb +87 -0
- data/lib/legion/tools/config.rb +65 -0
- data/lib/legion/tools/discovery.rb +217 -0
- data/lib/legion/tools/do.rb +152 -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 +79 -0
- data/lib/legion/tools/status.rb +50 -0
- data/lib/legion/tools.rb +35 -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: c2114c2b32ae4dbc1650bef546391f6abb1499a0b9d96d577739503c47caba79
|
|
4
|
+
data.tar.gz: 1e95267b23de0b635ffb32c9f745f58fe0bac63aa7d806a6581e3557b9b1aad6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
│ └── (
|
|
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.
|
|
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 |
|
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,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
|
|
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
|
|
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
|
-
#
|
|
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:
|
|
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
|
|
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.
|