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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af7fcbce72c505c3b3e917b9d35722c1f4ff52aeb501cf2d2cfb405696396182
4
- data.tar.gz: e7b4218911866adc8bddad3bff9fdddbef37f28c17b97c9efeccb80479a678f8
3
+ metadata.gz: 012c2dfa0bbf9488deb34c1207b52bf7a44a60e79f9bffcdde266b00fe342488
4
+ data.tar.gz: c685676026b1ab8fff5ad83dca76a01f156396f2619d9627c69c13427ea4f013
5
5
  SHA512:
6
- metadata.gz: d709a447c48dba8b0e4de936d13d5ddb9b632bbd887aec1c18e4ec3182b2348ad4ae381f1b637ab02eca2ea67210e51e2b5c1ba6a7891cddc4c4aaeb2baeb37b
7
- data.tar.gz: '090830b799d8ba677c44d5bcfd1e665da3a27dbd3e2a995abbdcc12db0fc36efa93663edb67e167d4273f00b4e6bb2af32c6025a6a3c07359e0efb62a62757a2'
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.25
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 # 882 examples, 0 failures
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
- require 'ruby_llm'
4
- require 'legion/logging/helper'
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
- 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
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
- require 'legion/logging/helper'
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
- module ToolDispatcher
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
@@ -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
- return unless config[:api_key]
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 = config[: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
- return unless config[:api_key]
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 = config[: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
- return unless config[:api_key]
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 = config[: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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.6.26'
5
+ VERSION = '0.6.28'
6
6
  end
7
7
  end
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.26
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