legion-llm 0.6.16 → 0.6.18
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 +16 -0
- data/CLAUDE.md +6 -2
- data/lib/legion/llm/pipeline/executor.rb +34 -55
- data/lib/legion/llm/pipeline/mcp_tool_adapter.rb +3 -67
- data/lib/legion/llm/pipeline/profile.rb +3 -3
- data/lib/legion/llm/pipeline/steps/tool_discovery.rb +97 -0
- data/lib/legion/llm/pipeline/steps.rb +1 -1
- data/lib/legion/llm/pipeline/tool_adapter.rb +85 -0
- data/lib/legion/llm/pipeline.rb +1 -1
- data/lib/legion/llm/routes.rb +2 -50
- data/lib/legion/llm/version.rb +1 -1
- data/lib/legion/llm.rb +22 -5
- metadata +3 -2
- data/lib/legion/llm/tool_registry.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83b1bf0eb338e47eb37627eb7004afb30b3fba91627577034554827edd797bcf
|
|
4
|
+
data.tar.gz: 54ba4b787dfb7ebbbe6ef10f363cb237075f5b37f74467c9408db723d9a36c48
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a546dca02b403bc025621c1a980cf081114c383f97bd3ce3eef245a7b3314cdaacc7328cbbbfd7879cbf95dd53069e48b9af8f2401099e68966e0ee4e0b624f
|
|
7
|
+
data.tar.gz: ddb6aabeca04cdbdacfd2412ee2713dfaecc4b17911b55246ec680538874df7725eabef8768fe69c8202952d6a3f14d0a0ae27696cd64ebf2727679f7947cd61
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Fixed
|
|
6
|
+
- fix Process namespace collision by using ::Process::CLOCK_MONOTONIC prefix inside Legion namespace
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- `Legion::LLM::Pipeline::ToolAdapter` - wraps Tools::Base for RubyLLM sessions
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Renamed `McpToolAdapter` to `ToolAdapter` (backwards compat alias kept)
|
|
13
|
+
- Pipeline step `McpDiscovery` renamed to `ToolDiscovery`
|
|
14
|
+
- Executor reads from `Legion::Tools::Registry`
|
|
15
|
+
- `chat_single` wraps registry tools with ToolAdapter
|
|
16
|
+
- Routes: executor handles all tool injection, routes only pass client tools
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
- MCP server dependency for tool injection in routes
|
|
20
|
+
|
|
5
21
|
## [0.6.16] - 2026-04-03
|
|
6
22
|
|
|
7
23
|
### Fixed
|
data/CLAUDE.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
Core LegionIO gem providing LLM capabilities to all extensions. Wraps ruby_llm to provide a consistent interface for chat, embeddings, tool use, and agents across multiple providers (Bedrock, Anthropic, OpenAI, Gemini, Ollama). Includes a dynamic weighted routing engine that dispatches requests across local, fleet, and cloud tiers based on caller intent, priority rules, time schedules, cost multipliers, and real-time provider health.
|
|
9
9
|
|
|
10
10
|
**GitHub**: https://github.com/LegionIO/legion-llm
|
|
11
|
-
**Version**: 0.
|
|
11
|
+
**Version**: 0.6.18
|
|
12
12
|
**License**: Apache-2.0
|
|
13
13
|
|
|
14
14
|
## Architecture
|
|
@@ -61,8 +61,12 @@ Legion::LLM (lib/legion/llm.rb)
|
|
|
61
61
|
│ ├── Timeline # Ordered event recording with participant tracking
|
|
62
62
|
│ ├── Executor # 18-step pipeline skeleton with profile-aware execution
|
|
63
63
|
│ ├── Steps/
|
|
64
|
-
│ │
|
|
64
|
+
│ │ ├── Metering # Metering event builder (absorbed from lex-llm-gateway)
|
|
65
|
+
│ │ └── ToolDiscovery # Step 9 — formerly McpDiscovery; renamed to ToolDiscovery (McpDiscovery kept as backwards alias)
|
|
65
66
|
│ └── Executor#call_stream # Streaming variant: pre-provider steps, yield chunks, post-provider steps
|
|
67
|
+
│
|
|
68
|
+
│ Note: Legion::LLM::ToolRegistry was removed. Tool registration now lives in Legion::Tools::Registry (LegionIO gem).
|
|
69
|
+
│ McpToolAdapter renamed to ToolAdapter; McpToolAdapter kept as a backwards-compatible alias.
|
|
66
70
|
├── CostEstimator # Model cost estimation with fuzzy pricing (absorbed from lex-llm-gateway)
|
|
67
71
|
├── Fleet # Fleet RPC dispatch (absorbed from lex-llm-gateway)
|
|
68
72
|
│ ├── Dispatcher # Fleet dispatch with timeout and availability checks
|
|
@@ -18,7 +18,7 @@ module Legion
|
|
|
18
18
|
:audit, :warnings, :discovered_tools, :confidence_score,
|
|
19
19
|
:escalation_chain
|
|
20
20
|
|
|
21
|
-
include Steps::
|
|
21
|
+
include Steps::ToolDiscovery
|
|
22
22
|
include Steps::ToolCalls
|
|
23
23
|
include Steps::KnowledgeCapture
|
|
24
24
|
include Steps::ConfidenceScoring
|
|
@@ -28,14 +28,14 @@ module Legion
|
|
|
28
28
|
|
|
29
29
|
STEPS = %i[
|
|
30
30
|
tracing_init idempotency conversation_uuid context_load
|
|
31
|
-
rbac classification billing gaia_advisory tier_assignment rag_context
|
|
31
|
+
rbac classification billing gaia_advisory tier_assignment rag_context tool_discovery
|
|
32
32
|
routing request_normalization token_budget provider_call response_normalization
|
|
33
33
|
debate confidence_scoring tool_calls context_store post_response knowledge_capture response_return
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
36
|
PRE_PROVIDER_STEPS = %i[
|
|
37
37
|
tracing_init idempotency conversation_uuid context_load
|
|
38
|
-
rbac classification billing gaia_advisory tier_assignment rag_context
|
|
38
|
+
rbac classification billing gaia_advisory tier_assignment rag_context tool_discovery
|
|
39
39
|
routing request_normalization token_budget
|
|
40
40
|
].freeze
|
|
41
41
|
|
|
@@ -45,27 +45,6 @@ module Legion
|
|
|
45
45
|
|
|
46
46
|
ASYNC_SAFE_STEPS = %i[post_response knowledge_capture response_return].freeze
|
|
47
47
|
|
|
48
|
-
ALWAYS_LOADED_MCP_TOOLS = %w[
|
|
49
|
-
legion_do
|
|
50
|
-
legion_get_status
|
|
51
|
-
legion_run_task
|
|
52
|
-
legion_describe_runner
|
|
53
|
-
legion_list_extensions
|
|
54
|
-
legion_get_extension
|
|
55
|
-
legion_list_tasks
|
|
56
|
-
legion_get_task
|
|
57
|
-
legion_get_task_logs
|
|
58
|
-
legion_query_knowledge
|
|
59
|
-
legion_knowledge_health
|
|
60
|
-
legion_knowledge_context
|
|
61
|
-
legion_list_workers
|
|
62
|
-
legion_show_worker
|
|
63
|
-
legion_mesh_status
|
|
64
|
-
legion_list_peers
|
|
65
|
-
legion_tools
|
|
66
|
-
legion_search_sessions
|
|
67
|
-
].freeze
|
|
68
|
-
|
|
69
48
|
ASYNC_THREAD_POOL = Concurrent::FixedThreadPool.new(4, fallback_policy: :caller_runs)
|
|
70
49
|
|
|
71
50
|
def initialize(request)
|
|
@@ -104,27 +83,42 @@ module Legion
|
|
|
104
83
|
|
|
105
84
|
private
|
|
106
85
|
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
return unless server.respond_to?(:tool_registry)
|
|
86
|
+
def inject_registry_tools(session)
|
|
87
|
+
return unless defined?(::Legion::Tools::Registry)
|
|
110
88
|
|
|
111
|
-
requested = requested_deferred_tool_names
|
|
112
89
|
injected_names = []
|
|
113
90
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
91
|
+
# Always-loaded tools — inject all unconditionally
|
|
92
|
+
::Legion::Tools::Registry.tools.each do |tool_class|
|
|
93
|
+
adapter = ToolAdapter.new(tool_class)
|
|
118
94
|
session.with_tool(adapter)
|
|
119
95
|
injected_names << adapter.name
|
|
120
96
|
rescue StandardError => e
|
|
121
|
-
@warnings << "Failed to inject tool: #{e.message}"
|
|
122
|
-
handle_exception(e, level: :warn, operation: 'llm.pipeline.
|
|
97
|
+
@warnings << "Failed to inject always tool: #{e.message}"
|
|
98
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_always_tool')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Requested deferred tools — inject only if explicitly requested
|
|
102
|
+
deferred = ::Legion::Tools::Registry.respond_to?(:deferred_tools) ? ::Legion::Tools::Registry.deferred_tools : []
|
|
103
|
+
requested = requested_deferred_tool_names
|
|
104
|
+
if requested.any?
|
|
105
|
+
deferred.each do |tool_class|
|
|
106
|
+
adapter = ToolAdapter.new(tool_class)
|
|
107
|
+
next unless requested.include?(adapter.name)
|
|
108
|
+
|
|
109
|
+
session.with_tool(adapter)
|
|
110
|
+
injected_names << adapter.name
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
@warnings << "Failed to inject deferred tool: #{e.message}"
|
|
113
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_deferred_tool')
|
|
114
|
+
end
|
|
123
115
|
end
|
|
124
116
|
|
|
125
117
|
log.info(
|
|
126
|
-
"[llm][
|
|
127
|
-
"
|
|
118
|
+
"[llm][tools] inject request_id=#{@request.id} " \
|
|
119
|
+
"always=#{::Legion::Tools::Registry.tools.size} " \
|
|
120
|
+
"deferred_available=#{deferred.size} " \
|
|
121
|
+
"requested_deferred=#{requested.size} " \
|
|
128
122
|
"injected=#{injected_names.size} names=#{injected_names.first(25).join(',')}"
|
|
129
123
|
)
|
|
130
124
|
rescue StandardError => e
|
|
@@ -132,6 +126,9 @@ module Legion
|
|
|
132
126
|
handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_tools')
|
|
133
127
|
end
|
|
134
128
|
|
|
129
|
+
# Backwards compatibility alias
|
|
130
|
+
alias inject_discovered_tools inject_registry_tools
|
|
131
|
+
|
|
135
132
|
def execute_steps
|
|
136
133
|
executed = 0
|
|
137
134
|
skipped = 0
|
|
@@ -678,8 +675,7 @@ module Legion
|
|
|
678
675
|
session.with_tool(tool)
|
|
679
676
|
end
|
|
680
677
|
|
|
681
|
-
|
|
682
|
-
inject_discovered_tools(session)
|
|
678
|
+
inject_registry_tools(session)
|
|
683
679
|
end
|
|
684
680
|
|
|
685
681
|
def apply_ruby_llm_instructions(session)
|
|
@@ -864,23 +860,6 @@ module Legion
|
|
|
864
860
|
)
|
|
865
861
|
end
|
|
866
862
|
|
|
867
|
-
def mcp_server
|
|
868
|
-
return ::Legion::MCP.server if defined?(::Legion::MCP) && ::Legion::MCP.respond_to?(:server)
|
|
869
|
-
|
|
870
|
-
require 'legion/mcp'
|
|
871
|
-
return unless defined?(::Legion::MCP) && ::Legion::MCP.respond_to?(:server)
|
|
872
|
-
|
|
873
|
-
::Legion::MCP.server
|
|
874
|
-
rescue LoadError => e
|
|
875
|
-
@warnings << "MCP unavailable: #{e.message}"
|
|
876
|
-
handle_exception(e, level: :debug, operation: 'llm.pipeline.mcp_server.require')
|
|
877
|
-
nil
|
|
878
|
-
rescue StandardError => e
|
|
879
|
-
@warnings << "MCP server load error: #{e.message}"
|
|
880
|
-
handle_exception(e, level: :warn, operation: 'llm.pipeline.mcp_server')
|
|
881
|
-
nil
|
|
882
|
-
end
|
|
883
|
-
|
|
884
863
|
def requested_deferred_tool_names
|
|
885
864
|
metadata = @request.metadata || {}
|
|
886
865
|
requested = metadata[:requested_tools] || metadata['requested_tools'] || []
|
|
@@ -1,69 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require
|
|
5
|
-
|
|
6
|
-
module Legion
|
|
7
|
-
module LLM
|
|
8
|
-
module Pipeline
|
|
9
|
-
class McpToolAdapter < RubyLLM::Tool
|
|
10
|
-
include Legion::Logging::Helper
|
|
11
|
-
|
|
12
|
-
def initialize(mcp_tool_class)
|
|
13
|
-
@mcp_tool_class = mcp_tool_class
|
|
14
|
-
raw_name = mcp_tool_class.respond_to?(:tool_name) ? mcp_tool_class.tool_name : mcp_tool_class.name.to_s
|
|
15
|
-
@tool_name = raw_name.tr('.', '_')
|
|
16
|
-
@tool_desc = mcp_tool_class.respond_to?(:description) ? mcp_tool_class.description.to_s : ''
|
|
17
|
-
@tool_schema = mcp_tool_class.respond_to?(:input_schema) ? mcp_tool_class.input_schema : nil
|
|
18
|
-
super()
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def name
|
|
22
|
-
@tool_name
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def description
|
|
26
|
-
@tool_desc
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def params_schema
|
|
30
|
-
return @params_schema if defined?(@params_schema)
|
|
31
|
-
|
|
32
|
-
@params_schema = (RubyLLM::Utils.deep_stringify_keys(@tool_schema) if @tool_schema.is_a?(Hash))
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def execute(**args)
|
|
36
|
-
log.info("[llm][tools] adapter.execute name=#{@tool_name} arguments=#{summarize_payload(args)}")
|
|
37
|
-
result = @mcp_tool_class.call(**args)
|
|
38
|
-
content = extract_content(result)
|
|
39
|
-
log.info("[llm][tools] adapter.result name=#{@tool_name} output=#{summarize_payload(content)}")
|
|
40
|
-
content
|
|
41
|
-
rescue StandardError => e
|
|
42
|
-
handle_exception(e, level: :warn, operation: 'llm.pipeline.mcp_tool_adapter.execute', tool_name: @tool_name)
|
|
43
|
-
"Tool error: #{e.message}"
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def extract_content(result)
|
|
49
|
-
# MCP::Tool::Response — has .content array of {type: 'text', text: '...'}
|
|
50
|
-
if result.respond_to?(:content) && result.content.is_a?(Array)
|
|
51
|
-
result.content.filter_map { |c| c[:text] || c['text'] || c.to_s }.join("\n")
|
|
52
|
-
elsif result.is_a?(Hash) && result[:content].is_a?(Array)
|
|
53
|
-
result[:content].filter_map { |c| c[:text] || c['text'] }.join("\n")
|
|
54
|
-
elsif result.is_a?(Hash)
|
|
55
|
-
Legion::JSON.dump(result)
|
|
56
|
-
elsif result.is_a?(String)
|
|
57
|
-
result
|
|
58
|
-
else
|
|
59
|
-
result.to_s
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def summarize_payload(payload)
|
|
64
|
-
payload.to_s[0, 200].inspect
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
3
|
+
# Backwards-compatibility shim — the implementation moved to tool_adapter.rb.
|
|
4
|
+
# Callers that require this path directly will still find McpToolAdapter via the alias.
|
|
5
|
+
require_relative 'tool_adapter'
|
|
@@ -6,18 +6,18 @@ module Legion
|
|
|
6
6
|
module Profile
|
|
7
7
|
GAIA_SKIP = %i[
|
|
8
8
|
idempotency conversation_uuid context_load rbac classification
|
|
9
|
-
billing gaia_advisory
|
|
9
|
+
billing gaia_advisory tool_discovery context_store post_response
|
|
10
10
|
].freeze
|
|
11
11
|
|
|
12
12
|
SYSTEM_SKIP = %i[
|
|
13
13
|
idempotency conversation_uuid context_load rbac classification
|
|
14
|
-
billing gaia_advisory rag_context
|
|
14
|
+
billing gaia_advisory rag_context tool_discovery context_store
|
|
15
15
|
post_response
|
|
16
16
|
].freeze
|
|
17
17
|
|
|
18
18
|
QUICK_REPLY_SKIP = %i[
|
|
19
19
|
idempotency conversation_uuid context_load classification
|
|
20
|
-
gaia_advisory rag_context
|
|
20
|
+
gaia_advisory rag_context tool_discovery confidence_scoring
|
|
21
21
|
tool_calls context_store post_response knowledge_capture
|
|
22
22
|
].freeze
|
|
23
23
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Pipeline
|
|
8
|
+
module Steps
|
|
9
|
+
module ToolDiscovery
|
|
10
|
+
include Legion::Logging::Helper
|
|
11
|
+
|
|
12
|
+
def step_tool_discovery
|
|
13
|
+
@discovered_tools ||= []
|
|
14
|
+
start_time = Time.now
|
|
15
|
+
|
|
16
|
+
discover_registry_tools
|
|
17
|
+
discover_client_tools
|
|
18
|
+
|
|
19
|
+
total = @discovered_tools.size
|
|
20
|
+
if total.positive?
|
|
21
|
+
sources = @discovered_tools.filter_map { |t| t.dig(:source, :server) || t.dig(:source, :type) }.uniq
|
|
22
|
+
@enrichments['tool:discovery'] = {
|
|
23
|
+
content: "#{total} tools from #{sources.length} sources",
|
|
24
|
+
data: { tool_count: total, sources: sources },
|
|
25
|
+
timestamp: Time.now
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
record_tool_discovery_timeline(total, start_time)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
@warnings << "Tool discovery error: #{e.message}"
|
|
32
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.tool_discovery')
|
|
33
|
+
record_tool_discovery_timeline(0)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Backwards compatibility alias — step name used in STEPS array is tool_discovery
|
|
37
|
+
alias step_mcp_discovery step_tool_discovery
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def discover_registry_tools
|
|
42
|
+
return unless defined?(::Legion::Tools::Registry)
|
|
43
|
+
|
|
44
|
+
::Legion::Tools::Registry.tools.each do |tool_class|
|
|
45
|
+
name = tool_class.respond_to?(:tool_name) ? tool_class.tool_name : tool_class.name
|
|
46
|
+
desc = tool_class.respond_to?(:description) ? tool_class.description : ''
|
|
47
|
+
schema = tool_class.respond_to?(:input_schema) ? tool_class.input_schema : {}
|
|
48
|
+
@discovered_tools << {
|
|
49
|
+
name: name,
|
|
50
|
+
description: desc,
|
|
51
|
+
parameters: schema,
|
|
52
|
+
source: { type: :registry, server: 'legion' }
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
log.info(
|
|
57
|
+
"[llm][tools] discover request_id=#{@request.id} " \
|
|
58
|
+
"registry_tools=#{::Legion::Tools::Registry.tools.size}"
|
|
59
|
+
)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
@warnings << "Registry tool discovery error: #{e.message}"
|
|
62
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.tool_discovery.registry')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def discover_client_tools
|
|
66
|
+
return unless defined?(::Legion::MCP::Client::Pool)
|
|
67
|
+
|
|
68
|
+
::Legion::MCP::Client::Pool.all_tools.each do |tool|
|
|
69
|
+
@discovered_tools << {
|
|
70
|
+
name: tool[:name],
|
|
71
|
+
description: tool[:description],
|
|
72
|
+
parameters: tool[:input_schema],
|
|
73
|
+
source: tool[:source]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
@warnings << "Client tool discovery error: #{e.message}"
|
|
78
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.tool_discovery.client')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def record_tool_discovery_timeline(count, start_time = nil)
|
|
82
|
+
duration = start_time ? ((Time.now - start_time) * 1000).to_i : 0
|
|
83
|
+
@timeline.record(
|
|
84
|
+
category: :enrichment, key: 'tool:discovery',
|
|
85
|
+
direction: :inbound, detail: "#{count} tools discovered",
|
|
86
|
+
from: 'tool_registry', to: 'pipeline',
|
|
87
|
+
duration_ms: duration
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Backwards compatibility alias
|
|
93
|
+
McpDiscovery = ToolDiscovery
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -17,7 +17,7 @@ require_relative 'steps/billing'
|
|
|
17
17
|
require_relative 'steps/gaia_advisory'
|
|
18
18
|
require_relative 'steps/tier_assigner'
|
|
19
19
|
require_relative 'steps/post_response'
|
|
20
|
-
require_relative 'steps/
|
|
20
|
+
require_relative 'steps/tool_discovery'
|
|
21
21
|
require_relative 'steps/tool_calls'
|
|
22
22
|
require_relative 'steps/rag_context'
|
|
23
23
|
require_relative 'steps/rag_guard'
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
require 'legion/logging/helper'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module LLM
|
|
8
|
+
module Pipeline
|
|
9
|
+
class ToolAdapter < RubyLLM::Tool
|
|
10
|
+
include Legion::Logging::Helper
|
|
11
|
+
|
|
12
|
+
MAX_TOOL_NAME_LENGTH = 64
|
|
13
|
+
|
|
14
|
+
def initialize(tool_class)
|
|
15
|
+
@tool_class = tool_class
|
|
16
|
+
raw_name = tool_class.respond_to?(:tool_name) ? tool_class.tool_name : tool_class.name.to_s
|
|
17
|
+
@tool_name = sanitize_tool_name(raw_name)
|
|
18
|
+
@tool_desc = tool_class.respond_to?(:description) ? tool_class.description.to_s : ''
|
|
19
|
+
@tool_schema = tool_class.respond_to?(:input_schema) ? tool_class.input_schema : nil
|
|
20
|
+
super()
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
@tool_name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def description
|
|
28
|
+
@tool_desc
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def params_schema
|
|
32
|
+
return @params_schema if defined?(@params_schema)
|
|
33
|
+
|
|
34
|
+
@params_schema = (RubyLLM::Utils.deep_stringify_keys(@tool_schema) if @tool_schema.is_a?(Hash))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute(**args)
|
|
38
|
+
log.info("[llm][tools] adapter.execute name=#{@tool_name} arguments=#{summarize_payload(args)}")
|
|
39
|
+
result = @tool_class.call(**args)
|
|
40
|
+
content = extract_content(result)
|
|
41
|
+
log.info("[llm][tools] adapter.result name=#{@tool_name} output=#{summarize_payload(content)}")
|
|
42
|
+
content
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.tool_adapter.execute', tool_name: @tool_name)
|
|
45
|
+
"Tool error: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def extract_content(result)
|
|
51
|
+
# MCP::Tool::Response — has .content array of {type: 'text', text: '...'}
|
|
52
|
+
if result.respond_to?(:content) && result.content.is_a?(Array)
|
|
53
|
+
result.content.filter_map { |c| c[:text] || c['text'] || c.to_s }.join("\n")
|
|
54
|
+
elsif result.is_a?(Hash) && result[:content].is_a?(Array)
|
|
55
|
+
result[:content].filter_map { |c| c[:text] || c['text'] }.join("\n")
|
|
56
|
+
elsif result.is_a?(Hash)
|
|
57
|
+
Legion::JSON.dump(result)
|
|
58
|
+
elsif result.is_a?(String)
|
|
59
|
+
result
|
|
60
|
+
else
|
|
61
|
+
result.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def summarize_payload(payload)
|
|
66
|
+
payload.to_s[0, 200].inspect
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Bedrock constraints: [a-zA-Z0-9_-]+ and max 64 chars.
|
|
70
|
+
# Falls back to a stable name derived from the class object_id if sanitization yields
|
|
71
|
+
# an empty string (e.g. all chars stripped), ensuring the result always satisfies the
|
|
72
|
+
# at-least-one-character requirement.
|
|
73
|
+
def sanitize_tool_name(raw)
|
|
74
|
+
name = raw.tr('.', '_')
|
|
75
|
+
name = name.gsub(/[^a-zA-Z0-9_-]/, '') # strip ?, !, etc.
|
|
76
|
+
name = name[0, MAX_TOOL_NAME_LENGTH] if name.length > MAX_TOOL_NAME_LENGTH
|
|
77
|
+
name.empty? ? "tool_#{@tool_class.object_id}" : name
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Backwards compatibility alias
|
|
82
|
+
McpToolAdapter = ToolAdapter
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/legion/llm/pipeline.rb
CHANGED
|
@@ -11,7 +11,7 @@ require_relative 'pipeline/gaia_caller'
|
|
|
11
11
|
require_relative 'pipeline/tool_dispatcher'
|
|
12
12
|
require_relative 'pipeline/enrichment_injector'
|
|
13
13
|
require_relative 'pipeline/steps'
|
|
14
|
-
require_relative 'pipeline/
|
|
14
|
+
require_relative 'pipeline/tool_adapter'
|
|
15
15
|
require_relative 'pipeline/executor'
|
|
16
16
|
|
|
17
17
|
module Legion
|
data/lib/legion/llm/routes.rb
CHANGED
|
@@ -12,17 +12,6 @@ require 'open3'
|
|
|
12
12
|
require 'time'
|
|
13
13
|
require 'legion/logging/helper'
|
|
14
14
|
|
|
15
|
-
begin
|
|
16
|
-
require 'legion/cli/chat/tools/search_traces'
|
|
17
|
-
if defined?(Legion::LLM::ToolRegistry) && defined?(Legion::CLI::Chat::Tools::SearchTraces)
|
|
18
|
-
Legion::LLM::ToolRegistry.register(Legion::CLI::Chat::Tools::SearchTraces)
|
|
19
|
-
end
|
|
20
|
-
rescue LoadError => e
|
|
21
|
-
if defined?(Legion::Logging) && Legion::Logging.respond_to?(:log_exception)
|
|
22
|
-
Legion::Logging.log_exception(e, payload_summary: 'SearchTraces not available for API', component_type: :api)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
15
|
module Legion
|
|
27
16
|
module LLM
|
|
28
17
|
module Routes
|
|
@@ -214,34 +203,6 @@ module Legion
|
|
|
214
203
|
end
|
|
215
204
|
# rubocop:enable Metrics/BlockLength
|
|
216
205
|
|
|
217
|
-
define_method(:cached_mcp_tools) do
|
|
218
|
-
@@llm_cached_mcp_tools ||= begin # rubocop:disable Style/ClassVars
|
|
219
|
-
all = []
|
|
220
|
-
if defined?(Legion::MCP) && Legion::MCP.respond_to?(:server)
|
|
221
|
-
Legion::MCP.server
|
|
222
|
-
if defined?(Legion::MCP::Server) && Legion::MCP::Server.respond_to?(:tool_registry)
|
|
223
|
-
require 'legion/llm/pipeline/mcp_tool_adapter' unless defined?(Legion::LLM::Pipeline::McpToolAdapter)
|
|
224
|
-
Legion::MCP::Server.tool_registry.each do |tc|
|
|
225
|
-
all << Legion::LLM::Pipeline::McpToolAdapter.new(tc)
|
|
226
|
-
rescue StandardError
|
|
227
|
-
nil
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
always_names = %w[legion_do legion_get_status legion_run_task legion_describe_runner
|
|
232
|
-
legion_list_extensions legion_get_extension legion_list_tasks
|
|
233
|
-
legion_get_task legion_get_task_logs legion_query_knowledge
|
|
234
|
-
legion_knowledge_health legion_knowledge_context legion_list_workers
|
|
235
|
-
legion_show_worker legion_mesh_status legion_list_peers
|
|
236
|
-
legion_tools legion_search_sessions]
|
|
237
|
-
{
|
|
238
|
-
always: all.select { |t| always_names.include?(t.name) }.freeze,
|
|
239
|
-
deferred: all.reject { |t| always_names.include?(t.name) }.freeze,
|
|
240
|
-
all: all.freeze
|
|
241
|
-
}.freeze
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
|
|
245
206
|
define_method(:extract_tool_calls) do |pipeline_response|
|
|
246
207
|
tools_data = pipeline_response.tools
|
|
247
208
|
return [] unless tools_data.is_a?(Array) && !tools_data.empty?
|
|
@@ -505,16 +466,7 @@ module Legion
|
|
|
505
466
|
build_client_tool_class(ts[:name].to_s, ts[:description].to_s, ts[:parameters] || ts[:input_schema])
|
|
506
467
|
end
|
|
507
468
|
|
|
508
|
-
|
|
509
|
-
all_tools = tool_declarations.dup
|
|
510
|
-
begin
|
|
511
|
-
mcp_tools = cached_mcp_tools
|
|
512
|
-
mcp_to_inject = Array(requested_tools).empty? ? mcp_tools[:always] : mcp_tools[:all]
|
|
513
|
-
all_tools.concat(mcp_to_inject) if mcp_to_inject&.any?
|
|
514
|
-
log.unknown "[llm][api] inference tools client=#{tool_declarations.size} mcp=#{mcp_to_inject&.size || 0} total=#{all_tools.size}"
|
|
515
|
-
rescue StandardError => e
|
|
516
|
-
handle_exception(e, level: :warn, operation: 'llm.routes.mcp_tool_injection', request_id: request_id)
|
|
517
|
-
end
|
|
469
|
+
log.unknown "[llm][api] inference tools client=#{tool_declarations.size}"
|
|
518
470
|
|
|
519
471
|
streaming = body[:stream] == true && request.preferred_type.to_s.include?('text/event-stream')
|
|
520
472
|
normalized_caller = caller_context.respond_to?(:transform_keys) ? caller_context.transform_keys(&:to_sym) : {}
|
|
@@ -541,7 +493,7 @@ module Legion
|
|
|
541
493
|
messages: messages,
|
|
542
494
|
system: body[:system],
|
|
543
495
|
routing: { provider: provider, model: model },
|
|
544
|
-
tools:
|
|
496
|
+
tools: tool_declarations,
|
|
545
497
|
caller: effective_caller,
|
|
546
498
|
conversation_id: conversation_id,
|
|
547
499
|
metadata: { requested_tools: requested_tools },
|
data/lib/legion/llm/version.rb
CHANGED
data/lib/legion/llm.rb
CHANGED
|
@@ -32,7 +32,6 @@ require_relative 'llm/scheduling'
|
|
|
32
32
|
require_relative 'llm/off_peak'
|
|
33
33
|
require_relative 'llm/cost_tracker'
|
|
34
34
|
require_relative 'llm/token_tracker'
|
|
35
|
-
require_relative 'llm/tool_registry'
|
|
36
35
|
require_relative 'llm/override_confidence'
|
|
37
36
|
require_relative 'llm/routes'
|
|
38
37
|
|
|
@@ -108,7 +107,7 @@ module Legion
|
|
|
108
107
|
# for automatic metering and fleet dispatch
|
|
109
108
|
def chat(model: nil, provider: nil, intent: nil, tier: nil, escalate: nil,
|
|
110
109
|
max_escalations: nil, quality_check: nil, message: nil, **kwargs, &)
|
|
111
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
110
|
+
started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
112
111
|
log_inference_request(
|
|
113
112
|
request_type: :chat,
|
|
114
113
|
requested_model: model,
|
|
@@ -154,7 +153,7 @@ module Legion
|
|
|
154
153
|
# Send a single message — daemon-first, falls through to direct on unavailability.
|
|
155
154
|
def ask(message:, model: nil, provider: nil, intent: nil, tier: nil,
|
|
156
155
|
context: {}, identity: nil, &)
|
|
157
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
156
|
+
started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
158
157
|
log_inference_request(
|
|
159
158
|
request_type: :ask,
|
|
160
159
|
requested_model: model,
|
|
@@ -367,7 +366,7 @@ module Legion
|
|
|
367
366
|
end
|
|
368
367
|
|
|
369
368
|
def elapsed_ms_since(started_at)
|
|
370
|
-
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
369
|
+
((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
371
370
|
end
|
|
372
371
|
|
|
373
372
|
def inference_input_payload(message:, messages:)
|
|
@@ -644,7 +643,7 @@ module Legion
|
|
|
644
643
|
|
|
645
644
|
def chat_single(model:, provider:, intent:, tier:, message: nil, **kwargs, &block)
|
|
646
645
|
explicit_tools = kwargs.delete(:tools)
|
|
647
|
-
tools = explicit_tools ||
|
|
646
|
+
tools = explicit_tools || adapted_registry_tools
|
|
648
647
|
tools = nil if tools.empty?
|
|
649
648
|
|
|
650
649
|
if (intent || tier) && Router.routing_enabled?
|
|
@@ -687,6 +686,24 @@ module Legion
|
|
|
687
686
|
response
|
|
688
687
|
end
|
|
689
688
|
|
|
689
|
+
def adapted_registry_tools
|
|
690
|
+
tool_classes = if defined?(::Legion::Tools::Registry)
|
|
691
|
+
::Legion::Tools::Registry.tools
|
|
692
|
+
else
|
|
693
|
+
return []
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
tool_classes.map do |tool_class|
|
|
697
|
+
Pipeline::ToolAdapter.new(tool_class)
|
|
698
|
+
rescue StandardError => e
|
|
699
|
+
handle_exception(e, level: :warn, operation: 'llm.adapted_registry_tools', tool_class: tool_class.to_s)
|
|
700
|
+
nil
|
|
701
|
+
end.compact
|
|
702
|
+
rescue StandardError => e
|
|
703
|
+
handle_exception(e, level: :warn, operation: 'llm.adapted_registry_tools')
|
|
704
|
+
[]
|
|
705
|
+
end
|
|
706
|
+
|
|
690
707
|
def try_defer(intent:, urgency:, model:, provider:, message:, **)
|
|
691
708
|
return nil unless Scheduling.enabled? && Scheduling.should_defer?(intent: intent || :normal, urgency: urgency)
|
|
692
709
|
return nil unless defined?(Batch) && Batch.enabled?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -287,7 +287,9 @@ files:
|
|
|
287
287
|
- lib/legion/llm/pipeline/steps/tier_assigner.rb
|
|
288
288
|
- lib/legion/llm/pipeline/steps/token_budget.rb
|
|
289
289
|
- lib/legion/llm/pipeline/steps/tool_calls.rb
|
|
290
|
+
- lib/legion/llm/pipeline/steps/tool_discovery.rb
|
|
290
291
|
- lib/legion/llm/pipeline/timeline.rb
|
|
292
|
+
- lib/legion/llm/pipeline/tool_adapter.rb
|
|
291
293
|
- lib/legion/llm/pipeline/tool_dispatcher.rb
|
|
292
294
|
- lib/legion/llm/pipeline/tracing.rb
|
|
293
295
|
- lib/legion/llm/provider_registry.rb
|
|
@@ -306,7 +308,6 @@ files:
|
|
|
306
308
|
- lib/legion/llm/shadow_eval.rb
|
|
307
309
|
- lib/legion/llm/structured_output.rb
|
|
308
310
|
- lib/legion/llm/token_tracker.rb
|
|
309
|
-
- lib/legion/llm/tool_registry.rb
|
|
310
311
|
- lib/legion/llm/transport/exchanges/audit.rb
|
|
311
312
|
- lib/legion/llm/transport/exchanges/escalation.rb
|
|
312
313
|
- lib/legion/llm/transport/messages/audit_event.rb
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'legion/logging/helper'
|
|
4
|
-
|
|
5
|
-
module Legion
|
|
6
|
-
module LLM
|
|
7
|
-
module ToolRegistry
|
|
8
|
-
extend Legion::Logging::Helper
|
|
9
|
-
|
|
10
|
-
@tools = []
|
|
11
|
-
@mutex = Mutex.new
|
|
12
|
-
|
|
13
|
-
class << self
|
|
14
|
-
def register(tool_class)
|
|
15
|
-
registered = @mutex.synchronize do
|
|
16
|
-
next false if @tools.include?(tool_class)
|
|
17
|
-
|
|
18
|
-
@tools << tool_class
|
|
19
|
-
true
|
|
20
|
-
end
|
|
21
|
-
if registered
|
|
22
|
-
log.info("[llm][tools] registered class=#{tool_class}")
|
|
23
|
-
else
|
|
24
|
-
log.debug("[llm][tools] already_registered class=#{tool_class}")
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def tools
|
|
29
|
-
@mutex.synchronize { @tools.dup }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def clear
|
|
33
|
-
count = @mutex.synchronize { @tools.size }
|
|
34
|
-
@mutex.synchronize { @tools.clear }
|
|
35
|
-
log.info("[llm][tools] registry_cleared count=#{count}")
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|