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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b85e3ef4ae750eb9aa512fcc0ce3b2de89fc13828dcdcef7ef558859a422e8f1
4
- data.tar.gz: 4617e27eec5e4292146338dc4a593e4e0762838b6c1c24c925fedca472775aae
3
+ metadata.gz: 012c2dfa0bbf9488deb34c1207b52bf7a44a60e79f9bffcdde266b00fe342488
4
+ data.tar.gz: c685676026b1ab8fff5ad83dca76a01f156396f2619d9627c69c13427ea4f013
5
5
  SHA512:
6
- metadata.gz: c93930484d5c716d2429490ca192bd9e758fd291aa81942a43309e8ab72c9accb1e472241a47255c24e590320748b54f8e71b678b4dc204e78ac975a1b0b7188
7
- data.tar.gz: 62453e4f583641b9c2e4e714f120def689548795baa7609844c8d8069f5b577ee644025ac18b324c2ff029a7163b82bde78426996c5bb6e062573aaa0d74c170
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
- 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
@@ -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.27'
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.27
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