legion-llm 0.6.27 → 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 +8 -0
- data/lib/legion/llm/pipeline/tool_adapter.rb +4 -76
- data/lib/legion/llm/pipeline/tool_dispatcher.rb +4 -124
- 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
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
### Added
|
|
6
6
|
- Broker soft consumer in Providers module — tries Identity::Broker before Settings for all provider credentials (Phase 8 Wave 2)
|
|
7
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
|
+
|
|
8
16
|
## [0.6.26] - 2026-04-09
|
|
9
17
|
|
|
10
18
|
### Changed
|
|
@@ -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
|
|
@@ -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
|