legion-llm 0.6.26 → 0.6.28
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 +13 -0
- data/CLAUDE.md +2 -2
- data/lib/legion/llm/pipeline/tool_adapter.rb +4 -76
- data/lib/legion/llm/pipeline/tool_dispatcher.rb +4 -124
- data/lib/legion/llm/providers.rb +61 -7
- data/lib/legion/llm/tools/adapter.rb +79 -0
- data/lib/legion/llm/tools/dispatcher.rb +130 -0
- data/lib/legion/llm/tools/interceptor.rb +47 -0
- data/lib/legion/llm/tools/interceptors/python_venv.rb +48 -0
- data/lib/legion/llm/version.rb +1 -1
- data/lib/legion/llm.rb +8 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 012c2dfa0bbf9488deb34c1207b52bf7a44a60e79f9bffcdde266b00fe342488
|
|
4
|
+
data.tar.gz: c685676026b1ab8fff5ad83dca76a01f156396f2619d9627c69c13427ea4f013
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf27f45ca8703a69b7b18f774298b23071ca588666aa1d5aadcb9811966203be14bbce211b1f8d26a03ae3827a1dee05883003987317f5af58e70afd6e1e6ed4
|
|
7
|
+
data.tar.gz: 6d02e8c9b4ceba43bd78611abd7e8969b2c154dd820a981ff9a464b99e43935915060419b2d0ed9400e18629dc4e126f0fe760aaa7ba10592a5b2adab4183bea
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Legion LLM Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Broker soft consumer in Providers module — tries Identity::Broker before Settings for all provider credentials (Phase 8 Wave 2)
|
|
7
|
+
|
|
8
|
+
## [0.6.28] - 2026-04-09
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `Legion::LLM::Tools::Interceptor` — extensible tool call interception registry
|
|
12
|
+
- `Legion::LLM::Tools::Interceptors::PythonVenv` — rewrites `python3`/`pip3` commands to Legion-managed venv when available
|
|
13
|
+
- ToolAdapter#execute calls Interceptor.intercept before dispatching to tool class
|
|
14
|
+
- Interceptors loaded automatically during `Legion::LLM.start`
|
|
15
|
+
|
|
3
16
|
## [0.6.26] - 2026-04-09
|
|
4
17
|
|
|
5
18
|
### Changed
|
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.6.
|
|
11
|
+
**Version**: 0.6.27
|
|
12
12
|
**License**: Apache-2.0
|
|
13
13
|
|
|
14
14
|
## Architecture
|
|
@@ -504,7 +504,7 @@ The legacy `vault_path` per-provider setting was removed in v0.3.1.
|
|
|
504
504
|
Tests run without the full LegionIO stack. `spec/spec_helper.rb` stubs `Legion::Logging` and `Legion::Settings` with in-memory implementations. Each test resets settings to defaults via `before(:each)`.
|
|
505
505
|
|
|
506
506
|
```bash
|
|
507
|
-
bundle exec rspec #
|
|
507
|
+
bundle exec rspec # 1661 examples, 0 failures
|
|
508
508
|
bundle exec rubocop # 0 offenses
|
|
509
509
|
```
|
|
510
510
|
|
|
@@ -1,85 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
# Backwards-compatibility shim — canonical location is legion/llm/tools/adapter.rb
|
|
4
|
+
require_relative '../tools/adapter'
|
|
5
5
|
|
|
6
6
|
module Legion
|
|
7
7
|
module LLM
|
|
8
8
|
module Pipeline
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
9
|
+
ToolAdapter = Tools::Adapter
|
|
10
|
+
McpToolAdapter = Tools::Adapter
|
|
83
11
|
end
|
|
84
12
|
end
|
|
85
13
|
end
|
|
@@ -1,132 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# Backwards-compatibility shim — canonical location is legion/llm/tools/dispatcher.rb
|
|
4
|
+
require_relative '../tools/dispatcher'
|
|
5
|
+
|
|
4
6
|
module Legion
|
|
5
7
|
module LLM
|
|
6
8
|
module Pipeline
|
|
7
|
-
|
|
8
|
-
extend Legion::Logging::Helper
|
|
9
|
-
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
|
-
def dispatch(tool_call:, source:, exchange_id: nil)
|
|
13
|
-
start_time = Time.now
|
|
14
|
-
|
|
15
|
-
# Check for settings override (LEX replaces MCP)
|
|
16
|
-
if source[:type] == :mcp
|
|
17
|
-
override = check_override(tool_call[:name])
|
|
18
|
-
if override
|
|
19
|
-
overridden_source = source
|
|
20
|
-
source = override.merge(overridden_from: overridden_source)
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
result = case source[:type]
|
|
25
|
-
when :mcp
|
|
26
|
-
mcp_result = dispatch_mcp(tool_call, source)
|
|
27
|
-
run_shadow(tool_call, source, mcp_result)
|
|
28
|
-
mcp_result
|
|
29
|
-
when :extension
|
|
30
|
-
dispatch_extension(tool_call, source)
|
|
31
|
-
when :builtin
|
|
32
|
-
dispatch_builtin(tool_call, source)
|
|
33
|
-
else
|
|
34
|
-
{ status: :error, error: "Unknown tool source type: #{source[:type]}" }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
result.merge(
|
|
38
|
-
source: source,
|
|
39
|
-
exchange_id: exchange_id,
|
|
40
|
-
duration_ms: ((Time.now - start_time) * 1000).to_i
|
|
41
|
-
)
|
|
42
|
-
rescue StandardError => e
|
|
43
|
-
handle_exception(e, level: :warn, operation: 'llm.pipeline.tool_dispatcher.dispatch_tool_call', tool_name: tool_name)
|
|
44
|
-
{ status: :error, error: e.message, source: source, exchange_id: exchange_id }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def check_override(tool_name)
|
|
48
|
-
# 1. Explicit settings override
|
|
49
|
-
settings_override = check_settings_override(tool_name)
|
|
50
|
-
return settings_override if settings_override
|
|
51
|
-
|
|
52
|
-
# 2. Catalog + OverrideConfidence auto-override
|
|
53
|
-
check_catalog_override(tool_name)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def check_settings_override(tool_name)
|
|
57
|
-
overrides = Legion::Settings.dig(:mcp, :overrides) rescue nil # rubocop:disable Style/RescueModifier
|
|
58
|
-
return nil unless overrides.is_a?(Hash)
|
|
59
|
-
|
|
60
|
-
override = overrides[tool_name]
|
|
61
|
-
return nil unless override
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
type: :extension,
|
|
65
|
-
lex: override[:lex] || override['lex'],
|
|
66
|
-
runner: override[:runner] || override['runner'],
|
|
67
|
-
function: override[:function] || override['function']
|
|
68
|
-
}
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def check_catalog_override(tool_name)
|
|
72
|
-
return nil unless defined?(Legion::Extensions::Catalog::Registry)
|
|
73
|
-
return nil unless Legion::LLM::OverrideConfidence.should_override?(tool_name)
|
|
74
|
-
|
|
75
|
-
cap = Legion::Extensions::Catalog::Registry.for_override(tool_name)
|
|
76
|
-
return nil unless cap
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
type: :extension,
|
|
80
|
-
lex: cap.extension,
|
|
81
|
-
runner: cap.runner,
|
|
82
|
-
function: cap.function
|
|
83
|
-
}
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def dispatch_mcp(tool_call, source)
|
|
87
|
-
conn = ::Legion::MCP::Client::Pool.connection_for(source[:server])
|
|
88
|
-
raise "No connection for MCP server: #{source[:server]}" unless conn
|
|
89
|
-
|
|
90
|
-
raw = conn.call_tool(name: tool_call[:name], arguments: tool_call[:arguments] || {})
|
|
91
|
-
content = raw[:content]&.map { |c| c[:text] || c['text'] }&.join("\n")
|
|
92
|
-
{ status: raw[:error] ? :error : :success, result: content }
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def dispatch_extension(tool_call, source)
|
|
96
|
-
segments = (source[:lex] || '').delete_prefix('lex-').split('-')
|
|
97
|
-
runner_path = (%w[Legion Extensions] + segments.map(&:capitalize) + ['Runners', source[:runner]]).join('::')
|
|
98
|
-
|
|
99
|
-
runner = Kernel.const_get(runner_path)
|
|
100
|
-
fn = source[:function].to_sym
|
|
101
|
-
result = runner.send(fn, **(tool_call[:arguments] || {}))
|
|
102
|
-
{ status: :success, result: result }
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def dispatch_builtin(_tool_call, _source)
|
|
106
|
-
{ status: :passthrough, result: nil }
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def run_shadow(tool_call, _source, mcp_result)
|
|
110
|
-
tool_name = tool_call[:name]
|
|
111
|
-
return unless Legion::LLM::OverrideConfidence.should_shadow?(tool_name)
|
|
112
|
-
return unless defined?(Legion::Extensions::Catalog::Registry)
|
|
113
|
-
|
|
114
|
-
cap = Legion::Extensions::Catalog::Registry.for_override(tool_name)
|
|
115
|
-
return unless cap
|
|
116
|
-
|
|
117
|
-
shadow_source = { type: :extension, lex: cap.extension, runner: cap.runner, function: cap.function }
|
|
118
|
-
shadow_result = dispatch_extension(tool_call, shadow_source)
|
|
119
|
-
|
|
120
|
-
if shadow_result[:status] == :success && mcp_result[:status] == :success
|
|
121
|
-
Legion::LLM::OverrideConfidence.record_success(tool_name)
|
|
122
|
-
else
|
|
123
|
-
Legion::LLM::OverrideConfidence.record_failure(tool_name)
|
|
124
|
-
end
|
|
125
|
-
rescue StandardError => e
|
|
126
|
-
Legion::LLM::OverrideConfidence.record_failure(tool_name) if tool_name
|
|
127
|
-
handle_exception(e, level: :debug, operation: 'llm.pipeline.tool_dispatcher.shadow_execution', tool_name: tool_name)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
9
|
+
ToolDispatcher = Tools::Dispatcher
|
|
130
10
|
end
|
|
131
11
|
end
|
|
132
12
|
end
|
data/lib/legion/llm/providers.rb
CHANGED
|
@@ -28,6 +28,9 @@ module Legion
|
|
|
28
28
|
else
|
|
29
29
|
config[:api_key]
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
has_creds ||= broker_has_credential?(provider) unless has_creds
|
|
33
|
+
|
|
31
34
|
next unless has_creds
|
|
32
35
|
|
|
33
36
|
config[:enabled] = true
|
|
@@ -70,6 +73,19 @@ module Legion
|
|
|
70
73
|
def configure_bedrock(config)
|
|
71
74
|
has_sigv4 = config[:api_key] && config[:secret_key]
|
|
72
75
|
has_bearer = config[:bearer_token]
|
|
76
|
+
|
|
77
|
+
unless has_sigv4 || has_bearer
|
|
78
|
+
broker_creds = resolve_broker_aws_credentials
|
|
79
|
+
if broker_creds
|
|
80
|
+
has_sigv4 = true
|
|
81
|
+
config = config.merge(
|
|
82
|
+
api_key: broker_creds.access_key_id,
|
|
83
|
+
secret_key: broker_creds.secret_access_key,
|
|
84
|
+
session_token: (broker_creds.session_token if broker_creds.respond_to?(:session_token))
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
return unless has_sigv4 || has_bearer
|
|
74
90
|
|
|
75
91
|
require 'legion/llm/bedrock_bearer_auth' if has_bearer
|
|
@@ -90,35 +106,38 @@ module Legion
|
|
|
90
106
|
end
|
|
91
107
|
|
|
92
108
|
def configure_anthropic(config)
|
|
93
|
-
|
|
109
|
+
api_key = resolve_broker_credential(:anthropic) || config[:api_key]
|
|
110
|
+
return unless api_key
|
|
94
111
|
|
|
95
112
|
RubyLLM.configure do |c|
|
|
96
|
-
c.anthropic_api_key =
|
|
113
|
+
c.anthropic_api_key = api_key
|
|
97
114
|
end
|
|
98
115
|
log.info 'Configured Anthropic provider'
|
|
99
116
|
end
|
|
100
117
|
|
|
101
118
|
def configure_openai(config)
|
|
102
|
-
|
|
119
|
+
api_key = resolve_broker_credential(:openai) || config[:api_key]
|
|
120
|
+
return unless api_key
|
|
103
121
|
|
|
104
122
|
RubyLLM.configure do |c|
|
|
105
|
-
c.openai_api_key =
|
|
123
|
+
c.openai_api_key = api_key
|
|
106
124
|
end
|
|
107
125
|
log.info 'Configured OpenAI provider'
|
|
108
126
|
end
|
|
109
127
|
|
|
110
128
|
def configure_gemini(config)
|
|
111
|
-
|
|
129
|
+
api_key = resolve_broker_credential(:gemini) || config[:api_key]
|
|
130
|
+
return unless api_key
|
|
112
131
|
|
|
113
132
|
RubyLLM.configure do |c|
|
|
114
|
-
c.gemini_api_key =
|
|
133
|
+
c.gemini_api_key = api_key
|
|
115
134
|
end
|
|
116
135
|
log.info 'Configured Gemini provider'
|
|
117
136
|
end
|
|
118
137
|
|
|
119
138
|
def configure_azure(config)
|
|
120
139
|
api_base = config[:api_base]
|
|
121
|
-
api_key = config[:api_key]
|
|
140
|
+
api_key = resolve_broker_credential(:azure) || config[:api_key]
|
|
122
141
|
auth_token = config[:auth_token]
|
|
123
142
|
return unless api_base && (api_key || auth_token)
|
|
124
143
|
|
|
@@ -197,6 +216,41 @@ module Legion
|
|
|
197
216
|
handle_exception(e, level: :debug, operation: 'llm.providers.verify_single_provider', provider: provider, model: model)
|
|
198
217
|
# config[:enabled] = false
|
|
199
218
|
end
|
|
219
|
+
|
|
220
|
+
def resolve_broker_credential(provider_name)
|
|
221
|
+
return nil unless defined?(Legion::Identity::Broker)
|
|
222
|
+
|
|
223
|
+
Legion::Identity::Broker.token_for(provider_name)
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
handle_exception(e, level: :debug, operation: "llm.providers.broker_resolve.#{provider_name}")
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def resolve_broker_aws_credentials
|
|
230
|
+
return nil unless defined?(Legion::Identity::Broker)
|
|
231
|
+
|
|
232
|
+
renewer = Legion::Identity::Broker.renewer_for(:aws)
|
|
233
|
+
return renewer.provider.current_credentials if renewer&.provider.respond_to?(:current_credentials)
|
|
234
|
+
|
|
235
|
+
nil
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
handle_exception(e, level: :debug, operation: 'llm.providers.broker_resolve.aws')
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def broker_has_credential?(provider)
|
|
242
|
+
return false unless defined?(Legion::Identity::Broker)
|
|
243
|
+
|
|
244
|
+
case provider
|
|
245
|
+
when :bedrock
|
|
246
|
+
renewer = Legion::Identity::Broker.renewer_for(:aws)
|
|
247
|
+
renewer&.provider.respond_to?(:current_credentials) && !renewer.provider.current_credentials.nil?
|
|
248
|
+
else
|
|
249
|
+
!Legion::Identity::Broker.token_for(provider).nil?
|
|
250
|
+
end
|
|
251
|
+
rescue StandardError
|
|
252
|
+
false
|
|
253
|
+
end
|
|
200
254
|
end
|
|
201
255
|
end
|
|
202
256
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
require 'legion/logging/helper'
|
|
5
|
+
require 'legion/llm/tools/interceptor'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module LLM
|
|
9
|
+
module Tools
|
|
10
|
+
class Adapter < RubyLLM::Tool
|
|
11
|
+
include Legion::Logging::Helper
|
|
12
|
+
|
|
13
|
+
MAX_TOOL_NAME_LENGTH = 64
|
|
14
|
+
|
|
15
|
+
def initialize(tool_class)
|
|
16
|
+
@tool_class = tool_class
|
|
17
|
+
raw_name = tool_class.respond_to?(:tool_name) ? tool_class.tool_name : tool_class.name.to_s
|
|
18
|
+
@tool_name = sanitize_tool_name(raw_name)
|
|
19
|
+
@tool_desc = tool_class.respond_to?(:description) ? tool_class.description.to_s : ''
|
|
20
|
+
@tool_schema = tool_class.respond_to?(:input_schema) ? tool_class.input_schema : nil
|
|
21
|
+
super()
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def name
|
|
25
|
+
@tool_name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def description
|
|
29
|
+
@tool_desc
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def params_schema
|
|
33
|
+
return @params_schema if defined?(@params_schema)
|
|
34
|
+
|
|
35
|
+
@params_schema = (RubyLLM::Utils.deep_stringify_keys(@tool_schema) if @tool_schema.is_a?(Hash))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def execute(**args)
|
|
39
|
+
args = Interceptor.intercept(@tool_name, **args)
|
|
40
|
+
log.info("[llm][tools] adapter.execute name=#{@tool_name} arguments=#{summarize_payload(args)}")
|
|
41
|
+
result = @tool_class.call(**args)
|
|
42
|
+
content = extract_content(result)
|
|
43
|
+
log.info("[llm][tools] adapter.result name=#{@tool_name} output=#{summarize_payload(content)}")
|
|
44
|
+
content
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
handle_exception(e, level: :warn, operation: 'llm.tools.adapter.execute', tool_name: @tool_name)
|
|
47
|
+
"Tool error: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def extract_content(result)
|
|
53
|
+
if result.respond_to?(:content) && result.content.is_a?(Array)
|
|
54
|
+
result.content.filter_map { |c| c[:text] || c['text'] || c.to_s }.join("\n")
|
|
55
|
+
elsif result.is_a?(Hash) && result[:content].is_a?(Array)
|
|
56
|
+
result[:content].filter_map { |c| c[:text] || c['text'] }.join("\n")
|
|
57
|
+
elsif result.is_a?(Hash)
|
|
58
|
+
Legion::JSON.dump(result)
|
|
59
|
+
elsif result.is_a?(String)
|
|
60
|
+
result
|
|
61
|
+
else
|
|
62
|
+
result.to_s
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def summarize_payload(payload)
|
|
67
|
+
payload.to_s[0, 200].inspect
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sanitize_tool_name(raw)
|
|
71
|
+
name = raw.tr('.', '_')
|
|
72
|
+
name = name.gsub(/[^a-zA-Z0-9_-]/, '')
|
|
73
|
+
name = name[0, MAX_TOOL_NAME_LENGTH] if name.length > MAX_TOOL_NAME_LENGTH
|
|
74
|
+
name.empty? ? "tool_#{@tool_class.object_id}" : name
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Tools
|
|
8
|
+
module Dispatcher
|
|
9
|
+
extend Legion::Logging::Helper
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def dispatch(tool_call:, source:, exchange_id: nil)
|
|
14
|
+
start_time = Time.now
|
|
15
|
+
|
|
16
|
+
if source[:type] == :mcp
|
|
17
|
+
override = check_override(tool_call[:name])
|
|
18
|
+
if override
|
|
19
|
+
overridden_source = source
|
|
20
|
+
source = override.merge(overridden_from: overridden_source)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
result = case source[:type]
|
|
25
|
+
when :mcp
|
|
26
|
+
mcp_result = dispatch_mcp(tool_call, source)
|
|
27
|
+
run_shadow(tool_call, source, mcp_result)
|
|
28
|
+
mcp_result
|
|
29
|
+
when :extension
|
|
30
|
+
dispatch_extension(tool_call, source)
|
|
31
|
+
when :builtin
|
|
32
|
+
dispatch_builtin(tool_call, source)
|
|
33
|
+
else
|
|
34
|
+
{ status: :error, error: "Unknown tool source type: #{source[:type]}" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result.merge(
|
|
38
|
+
source: source,
|
|
39
|
+
exchange_id: exchange_id,
|
|
40
|
+
duration_ms: ((Time.now - start_time) * 1000).to_i
|
|
41
|
+
)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
handle_exception(e, level: :warn, operation: 'llm.tools.dispatcher.dispatch_tool_call', tool_name: tool_call[:name])
|
|
44
|
+
{ status: :error, error: e.message, source: source, exchange_id: exchange_id }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_override(tool_name)
|
|
48
|
+
settings_override = check_settings_override(tool_name)
|
|
49
|
+
return settings_override if settings_override
|
|
50
|
+
|
|
51
|
+
check_catalog_override(tool_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_settings_override(tool_name)
|
|
55
|
+
overrides = Legion::Settings.dig(:mcp, :overrides) rescue nil # rubocop:disable Style/RescueModifier
|
|
56
|
+
return nil unless overrides.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
override = overrides[tool_name]
|
|
59
|
+
return nil unless override
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
type: :extension,
|
|
63
|
+
lex: override[:lex] || override['lex'],
|
|
64
|
+
runner: override[:runner] || override['runner'],
|
|
65
|
+
function: override[:function] || override['function']
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_catalog_override(tool_name)
|
|
70
|
+
return nil unless defined?(Legion::Extensions::Catalog::Registry)
|
|
71
|
+
return nil unless Legion::LLM::OverrideConfidence.should_override?(tool_name)
|
|
72
|
+
|
|
73
|
+
cap = Legion::Extensions::Catalog::Registry.for_override(tool_name)
|
|
74
|
+
return nil unless cap
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
type: :extension,
|
|
78
|
+
lex: cap.extension,
|
|
79
|
+
runner: cap.runner,
|
|
80
|
+
function: cap.function
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dispatch_mcp(tool_call, source)
|
|
85
|
+
conn = ::Legion::MCP::Client::Pool.connection_for(source[:server])
|
|
86
|
+
raise "No connection for MCP server: #{source[:server]}" unless conn
|
|
87
|
+
|
|
88
|
+
raw = conn.call_tool(name: tool_call[:name], arguments: tool_call[:arguments] || {})
|
|
89
|
+
content = raw[:content]&.map { |c| c[:text] || c['text'] }&.join("\n")
|
|
90
|
+
{ status: raw[:error] ? :error : :success, result: content }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dispatch_extension(tool_call, source)
|
|
94
|
+
segments = (source[:lex] || '').delete_prefix('lex-').split('-')
|
|
95
|
+
runner_path = (%w[Legion Extensions] + segments.map(&:capitalize) + ['Runners', source[:runner]]).join('::')
|
|
96
|
+
|
|
97
|
+
runner = Kernel.const_get(runner_path)
|
|
98
|
+
fn = source[:function].to_sym
|
|
99
|
+
result = runner.send(fn, **(tool_call[:arguments] || {}))
|
|
100
|
+
{ status: :success, result: result }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def dispatch_builtin(_tool_call, _source)
|
|
104
|
+
{ status: :passthrough, result: nil }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_shadow(tool_call, _source, mcp_result)
|
|
108
|
+
tool_name = tool_call[:name]
|
|
109
|
+
return unless Legion::LLM::OverrideConfidence.should_shadow?(tool_name)
|
|
110
|
+
return unless defined?(Legion::Extensions::Catalog::Registry)
|
|
111
|
+
|
|
112
|
+
cap = Legion::Extensions::Catalog::Registry.for_override(tool_name)
|
|
113
|
+
return unless cap
|
|
114
|
+
|
|
115
|
+
shadow_source = { type: :extension, lex: cap.extension, runner: cap.runner, function: cap.function }
|
|
116
|
+
shadow_result = dispatch_extension(tool_call, shadow_source)
|
|
117
|
+
|
|
118
|
+
if shadow_result[:status] == :success && mcp_result[:status] == :success
|
|
119
|
+
Legion::LLM::OverrideConfidence.record_success(tool_name)
|
|
120
|
+
else
|
|
121
|
+
Legion::LLM::OverrideConfidence.record_failure(tool_name)
|
|
122
|
+
end
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
Legion::LLM::OverrideConfidence.record_failure(tool_name) if tool_name
|
|
125
|
+
handle_exception(e, level: :debug, operation: 'llm.tools.dispatcher.shadow_execution', tool_name: tool_name)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Tools
|
|
6
|
+
module Interceptor
|
|
7
|
+
@registry = {}
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def register(name, matcher:, &block)
|
|
13
|
+
@mutex.synchronize { @registry = @registry.merge(name.to_sym => { matcher: matcher, rewrite: block }) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def intercept(tool_name, **args)
|
|
17
|
+
snapshot = @mutex.synchronize { @registry.dup }
|
|
18
|
+
snapshot.each do |name, entry|
|
|
19
|
+
next unless entry[:matcher].call(tool_name)
|
|
20
|
+
|
|
21
|
+
rewritten_args = entry[:rewrite].call(tool_name, **args)
|
|
22
|
+
unless rewritten_args.is_a?(Hash)
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
"interceptor #{name.inspect} must return a Hash, got #{rewritten_args.class}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
args = rewritten_args
|
|
28
|
+
end
|
|
29
|
+
args
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registered
|
|
33
|
+
@mutex.synchronize { @registry.keys }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset!
|
|
37
|
+
@mutex.synchronize { @registry = {} }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load_defaults
|
|
41
|
+
require_relative 'interceptors/python_venv'
|
|
42
|
+
Interceptors::PythonVenv.register!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Tools
|
|
6
|
+
module Interceptors
|
|
7
|
+
module PythonVenv
|
|
8
|
+
VENV_DIR = File.expand_path('~/.legionio/python').freeze
|
|
9
|
+
PYTHON = "#{VENV_DIR}/bin/python3".freeze
|
|
10
|
+
PIP = "#{VENV_DIR}/bin/pip3".freeze
|
|
11
|
+
|
|
12
|
+
TOOL_PATTERN = /\A(python3?|pip3?)\z/i
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def register!
|
|
17
|
+
Interceptor.register(:python_venv, matcher: method(:match?)) do |_tool_name, **args|
|
|
18
|
+
rewrite(**args)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def match?(tool_name)
|
|
23
|
+
TOOL_PATTERN.match?(tool_name.to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def venv_available?
|
|
27
|
+
File.exist?("#{VENV_DIR}/pyvenv.cfg")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rewrite(**args)
|
|
31
|
+
return args unless venv_available?
|
|
32
|
+
|
|
33
|
+
command = args[:command]
|
|
34
|
+
return args unless command.is_a?(String)
|
|
35
|
+
|
|
36
|
+
args.merge(command: rewrite_command(command))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rewrite_command(command)
|
|
40
|
+
command
|
|
41
|
+
.sub(/\Apython3(\s|\z)/, "#{PYTHON}\\1")
|
|
42
|
+
.sub(/\Apip3(\s|\z)/, "#{PIP}\\1")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/legion/llm/version.rb
CHANGED
data/lib/legion/llm.rb
CHANGED
|
@@ -70,6 +70,7 @@ module Legion
|
|
|
70
70
|
auto_register_providers
|
|
71
71
|
|
|
72
72
|
install_hooks
|
|
73
|
+
load_tool_interceptors
|
|
73
74
|
|
|
74
75
|
@started = true
|
|
75
76
|
Legion::Settings[:llm][:connected] = true
|
|
@@ -924,6 +925,13 @@ module Legion
|
|
|
924
925
|
handle_exception(e, level: :debug, operation: 'llm.install_hooks')
|
|
925
926
|
end
|
|
926
927
|
|
|
928
|
+
def load_tool_interceptors
|
|
929
|
+
require 'legion/llm/tools/interceptor'
|
|
930
|
+
Legion::LLM::Tools::Interceptor.load_defaults
|
|
931
|
+
rescue StandardError => e
|
|
932
|
+
handle_exception(e, level: :debug, operation: 'llm.load_tool_interceptors')
|
|
933
|
+
end
|
|
934
|
+
|
|
927
935
|
def set_defaults
|
|
928
936
|
default_model = settings[:default_model]
|
|
929
937
|
default_provider = settings[:default_provider]
|
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.28
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -320,6 +320,10 @@ files:
|
|
|
320
320
|
- lib/legion/llm/shadow_eval.rb
|
|
321
321
|
- lib/legion/llm/structured_output.rb
|
|
322
322
|
- lib/legion/llm/token_tracker.rb
|
|
323
|
+
- lib/legion/llm/tools/adapter.rb
|
|
324
|
+
- lib/legion/llm/tools/dispatcher.rb
|
|
325
|
+
- lib/legion/llm/tools/interceptor.rb
|
|
326
|
+
- lib/legion/llm/tools/interceptors/python_venv.rb
|
|
323
327
|
- lib/legion/llm/transport/exchanges/audit.rb
|
|
324
328
|
- lib/legion/llm/transport/exchanges/escalation.rb
|
|
325
329
|
- lib/legion/llm/transport/exchanges/metering.rb
|