legion-llm 0.6.17 → 0.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85bd29db06078f900c983a6f722cda55d6c3c5c6a72c6e4fdb8c4295c3debddb
4
- data.tar.gz: 855d18648dbe9d8e02e886011c51e35dd5f7d0828a25529428fb6f865c9b5f62
3
+ metadata.gz: 83b1bf0eb338e47eb37627eb7004afb30b3fba91627577034554827edd797bcf
4
+ data.tar.gz: 54ba4b787dfb7ebbbe6ef10f363cb237075f5b37f74467c9408db723d9a36c48
5
5
  SHA512:
6
- metadata.gz: 063beb13698b8645a8c365ccc1bde00d599b7653df47304d311b4c829b7de277e13e9443c5d693fe22668ee5855d3c2d33187eb8ed4af71dc0f744a59cdceaed
7
- data.tar.gz: 4244f8d0af82a82c3a45cc483c9939fe492ef7350e43cdbf2f4d5b933210b6154150e038f02931f52943c5ad8918d6b6247d4c8debc29b083ea55336c5daf570
6
+ metadata.gz: 1a546dca02b403bc025621c1a980cf081114c383f97bd3ce3eef245a7b3314cdaacc7328cbbbfd7879cbf95dd53069e48b9af8f2401099e68966e0ee4e0b624f
7
+ data.tar.gz: ddb6aabeca04cdbdacfd2412ee2713dfaecc4b17911b55246ec680538874df7725eabef8768fe69c8202952d6a3f14d0a0ae27696cd64ebf2727679f7947cd61
data/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+ - fix Process namespace collision by using ::Process::CLOCK_MONOTONIC prefix inside Legion namespace
7
+
5
8
  ### Added
6
9
  - `Legion::LLM::Pipeline::ToolAdapter` - wraps Tools::Base for RubyLLM sessions
7
10
 
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.5.15
11
+ **Version**: 0.6.18
12
12
  **License**: Apache-2.0
13
13
 
14
14
  ## Architecture
@@ -61,8 +61,12 @@ Legion::LLM (lib/legion/llm.rb)
61
61
  │ ├── Timeline # Ordered event recording with participant tracking
62
62
  │ ├── Executor # 18-step pipeline skeleton with profile-aware execution
63
63
  │ ├── Steps/
64
- │ │ └── Metering # Metering event builder (absorbed from lex-llm-gateway)
64
+ │ │ ├── Metering # Metering event builder (absorbed from lex-llm-gateway)
65
+ │ │ └── ToolDiscovery # Step 9 — formerly McpDiscovery; renamed to ToolDiscovery (McpDiscovery kept as backwards alias)
65
66
  │ └── Executor#call_stream # Streaming variant: pre-provider steps, yield chunks, post-provider steps
67
+
68
+ │ Note: Legion::LLM::ToolRegistry was removed. Tool registration now lives in Legion::Tools::Registry (LegionIO gem).
69
+ │ McpToolAdapter renamed to ToolAdapter; McpToolAdapter kept as a backwards-compatible alias.
66
70
  ├── CostEstimator # Model cost estimation with fuzzy pricing (absorbed from lex-llm-gateway)
67
71
  ├── Fleet # Fleet RPC dispatch (absorbed from lex-llm-gateway)
68
72
  │ ├── Dispatcher # Fleet dispatch with timeout and availability checks
@@ -45,29 +45,6 @@ module Legion
45
45
 
46
46
  ASYNC_SAFE_STEPS = %i[post_response knowledge_capture response_return].freeze
47
47
 
48
- ALWAYS_LOADED_MCP_TOOLS = %w[
49
- legion_do
50
- legion_get_status
51
- legion_run_task
52
- legion_describe_runner
53
- legion_list_extensions
54
- legion_get_extension
55
- legion_list_tasks
56
- legion_get_task
57
- legion_get_task_logs
58
- legion_query_knowledge
59
- legion_knowledge_health
60
- legion_knowledge_context
61
- legion_list_workers
62
- legion_show_worker
63
- legion_mesh_status
64
- legion_list_peers
65
- legion_tools
66
- legion_search_sessions
67
- ].freeze
68
-
69
- private_constant :ALWAYS_LOADED_MCP_TOOLS
70
-
71
48
  ASYNC_THREAD_POOL = Concurrent::FixedThreadPool.new(4, fallback_policy: :caller_runs)
72
49
 
73
50
  def initialize(request)
@@ -109,24 +86,39 @@ module Legion
109
86
  def inject_registry_tools(session)
110
87
  return unless defined?(::Legion::Tools::Registry)
111
88
 
112
- requested = requested_deferred_tool_names
113
- always_loaded = always_loaded_tool_names
114
89
  injected_names = []
115
90
 
91
+ # Always-loaded tools — inject all unconditionally
116
92
  ::Legion::Tools::Registry.tools.each do |tool_class|
117
93
  adapter = ToolAdapter.new(tool_class)
118
- next unless always_loaded.include?(adapter.name) || requested.include?(adapter.name)
119
-
120
94
  session.with_tool(adapter)
121
95
  injected_names << adapter.name
122
96
  rescue StandardError => e
123
- @warnings << "Failed to inject tool: #{e.message}"
124
- handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_tool')
97
+ @warnings << "Failed to inject always tool: #{e.message}"
98
+ handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_always_tool')
99
+ end
100
+
101
+ # Requested deferred tools — inject only if explicitly requested
102
+ deferred = ::Legion::Tools::Registry.respond_to?(:deferred_tools) ? ::Legion::Tools::Registry.deferred_tools : []
103
+ requested = requested_deferred_tool_names
104
+ if requested.any?
105
+ deferred.each do |tool_class|
106
+ adapter = ToolAdapter.new(tool_class)
107
+ next unless requested.include?(adapter.name)
108
+
109
+ session.with_tool(adapter)
110
+ injected_names << adapter.name
111
+ rescue StandardError => e
112
+ @warnings << "Failed to inject deferred tool: #{e.message}"
113
+ handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_deferred_tool')
114
+ end
125
115
  end
126
116
 
127
117
  log.info(
128
118
  "[llm][tools] inject request_id=#{@request.id} " \
129
- "always_loaded=#{always_loaded.size} requested_deferred=#{requested.size} " \
119
+ "always=#{::Legion::Tools::Registry.tools.size} " \
120
+ "deferred_available=#{deferred.size} " \
121
+ "requested_deferred=#{requested.size} " \
130
122
  "injected=#{injected_names.size} names=#{injected_names.first(25).join(',')}"
131
123
  )
132
124
  rescue StandardError => e
@@ -137,15 +129,6 @@ module Legion
137
129
  # Backwards compatibility alias
138
130
  alias inject_discovered_tools inject_registry_tools
139
131
 
140
- def always_loaded_tool_names
141
- return ALWAYS_LOADED_MCP_TOOLS unless defined?(::Legion::Tools::Registry)
142
-
143
- names = ::Legion::Tools::Registry.always_loaded_names.map { |name| name.to_s.tr('.', '_') }
144
- names.any? ? names : ALWAYS_LOADED_MCP_TOOLS
145
- rescue StandardError
146
- ALWAYS_LOADED_MCP_TOOLS
147
- end
148
-
149
132
  def execute_steps
150
133
  executed = 0
151
134
  skipped = 0
@@ -1,69 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ruby_llm'
4
- require 'legion/logging/helper'
5
-
6
- module Legion
7
- module LLM
8
- module Pipeline
9
- class McpToolAdapter < RubyLLM::Tool
10
- include Legion::Logging::Helper
11
-
12
- def initialize(mcp_tool_class)
13
- @mcp_tool_class = mcp_tool_class
14
- raw_name = mcp_tool_class.respond_to?(:tool_name) ? mcp_tool_class.tool_name : mcp_tool_class.name.to_s
15
- @tool_name = raw_name.tr('.', '_')
16
- @tool_desc = mcp_tool_class.respond_to?(:description) ? mcp_tool_class.description.to_s : ''
17
- @tool_schema = mcp_tool_class.respond_to?(:input_schema) ? mcp_tool_class.input_schema : nil
18
- super()
19
- end
20
-
21
- def name
22
- @tool_name
23
- end
24
-
25
- def description
26
- @tool_desc
27
- end
28
-
29
- def params_schema
30
- return @params_schema if defined?(@params_schema)
31
-
32
- @params_schema = (RubyLLM::Utils.deep_stringify_keys(@tool_schema) if @tool_schema.is_a?(Hash))
33
- end
34
-
35
- def execute(**args)
36
- log.info("[llm][tools] adapter.execute name=#{@tool_name} arguments=#{summarize_payload(args)}")
37
- result = @mcp_tool_class.call(**args)
38
- content = extract_content(result)
39
- log.info("[llm][tools] adapter.result name=#{@tool_name} output=#{summarize_payload(content)}")
40
- content
41
- rescue StandardError => e
42
- handle_exception(e, level: :warn, operation: 'llm.pipeline.mcp_tool_adapter.execute', tool_name: @tool_name)
43
- "Tool error: #{e.message}"
44
- end
45
-
46
- private
47
-
48
- def extract_content(result)
49
- # MCP::Tool::Response — has .content array of {type: 'text', text: '...'}
50
- if result.respond_to?(:content) && result.content.is_a?(Array)
51
- result.content.filter_map { |c| c[:text] || c['text'] || c.to_s }.join("\n")
52
- elsif result.is_a?(Hash) && result[:content].is_a?(Array)
53
- result[:content].filter_map { |c| c[:text] || c['text'] }.join("\n")
54
- elsif result.is_a?(Hash)
55
- Legion::JSON.dump(result)
56
- elsif result.is_a?(String)
57
- result
58
- else
59
- result.to_s
60
- end
61
- end
62
-
63
- def summarize_payload(payload)
64
- payload.to_s[0, 200].inspect
65
- end
66
- end
67
- end
68
- end
69
- end
3
+ # Backwards-compatibility shim — the implementation moved to tool_adapter.rb.
4
+ # Callers that require this path directly will still find McpToolAdapter via the alias.
5
+ require_relative 'tool_adapter'
@@ -9,10 +9,12 @@ module Legion
9
9
  class ToolAdapter < RubyLLM::Tool
10
10
  include Legion::Logging::Helper
11
11
 
12
+ MAX_TOOL_NAME_LENGTH = 64
13
+
12
14
  def initialize(tool_class)
13
15
  @tool_class = tool_class
14
16
  raw_name = tool_class.respond_to?(:tool_name) ? tool_class.tool_name : tool_class.name.to_s
15
- @tool_name = raw_name.tr('.', '_')
17
+ @tool_name = sanitize_tool_name(raw_name)
16
18
  @tool_desc = tool_class.respond_to?(:description) ? tool_class.description.to_s : ''
17
19
  @tool_schema = tool_class.respond_to?(:input_schema) ? tool_class.input_schema : nil
18
20
  super()
@@ -63,6 +65,17 @@ module Legion
63
65
  def summarize_payload(payload)
64
66
  payload.to_s[0, 200].inspect
65
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
66
79
  end
67
80
 
68
81
  # Backwards compatibility alias
@@ -12,17 +12,6 @@ require 'open3'
12
12
  require 'time'
13
13
  require 'legion/logging/helper'
14
14
 
15
- begin
16
- require 'legion/cli/chat/tools/search_traces'
17
- if defined?(Legion::LLM::ToolRegistry) && defined?(Legion::CLI::Chat::Tools::SearchTraces)
18
- Legion::LLM::ToolRegistry.register(Legion::CLI::Chat::Tools::SearchTraces)
19
- end
20
- rescue LoadError => e
21
- if defined?(Legion::Logging) && Legion::Logging.respond_to?(:log_exception)
22
- Legion::Logging.log_exception(e, payload_summary: 'SearchTraces not available for API', component_type: :api)
23
- end
24
- end
25
-
26
15
  module Legion
27
16
  module LLM
28
17
  module Routes
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.6.17'
5
+ VERSION = '0.6.18'
6
6
  end
7
7
  end
data/lib/legion/llm.rb CHANGED
@@ -32,7 +32,6 @@ require_relative 'llm/scheduling'
32
32
  require_relative 'llm/off_peak'
33
33
  require_relative 'llm/cost_tracker'
34
34
  require_relative 'llm/token_tracker'
35
- require_relative 'llm/tool_registry'
36
35
  require_relative 'llm/override_confidence'
37
36
  require_relative 'llm/routes'
38
37
 
@@ -108,7 +107,7 @@ module Legion
108
107
  # for automatic metering and fleet dispatch
109
108
  def chat(model: nil, provider: nil, intent: nil, tier: nil, escalate: nil,
110
109
  max_escalations: nil, quality_check: nil, message: nil, **kwargs, &)
111
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
+ started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
112
111
  log_inference_request(
113
112
  request_type: :chat,
114
113
  requested_model: model,
@@ -154,7 +153,7 @@ module Legion
154
153
  # Send a single message — daemon-first, falls through to direct on unavailability.
155
154
  def ask(message:, model: nil, provider: nil, intent: nil, tier: nil,
156
155
  context: {}, identity: nil, &)
157
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
+ started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
158
157
  log_inference_request(
159
158
  request_type: :ask,
160
159
  requested_model: model,
@@ -367,7 +366,7 @@ module Legion
367
366
  end
368
367
 
369
368
  def elapsed_ms_since(started_at)
370
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
369
+ ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started_at) * 1000).round
371
370
  end
372
371
 
373
372
  def inference_input_payload(message:, messages:)
@@ -690,8 +689,6 @@ module Legion
690
689
  def adapted_registry_tools
691
690
  tool_classes = if defined?(::Legion::Tools::Registry)
692
691
  ::Legion::Tools::Registry.tools
693
- elsif defined?(::Legion::LLM::ToolRegistry)
694
- ::Legion::LLM::ToolRegistry.tools
695
692
  else
696
693
  return []
697
694
  end
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.17
4
+ version: 0.6.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -308,7 +308,6 @@ files:
308
308
  - lib/legion/llm/shadow_eval.rb
309
309
  - lib/legion/llm/structured_output.rb
310
310
  - lib/legion/llm/token_tracker.rb
311
- - lib/legion/llm/tool_registry.rb
312
311
  - lib/legion/llm/transport/exchanges/audit.rb
313
312
  - lib/legion/llm/transport/exchanges/escalation.rb
314
313
  - lib/legion/llm/transport/messages/audit_event.rb
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'legion/logging/helper'
4
-
5
- module Legion
6
- module LLM
7
- module ToolRegistry
8
- extend Legion::Logging::Helper
9
-
10
- @tools = []
11
- @mutex = Mutex.new
12
-
13
- class << self
14
- def register(tool_class)
15
- registered = @mutex.synchronize do
16
- next false if @tools.include?(tool_class)
17
-
18
- @tools << tool_class
19
- true
20
- end
21
- if registered
22
- log.info("[llm][tools] registered class=#{tool_class}")
23
- else
24
- log.debug("[llm][tools] already_registered class=#{tool_class}")
25
- end
26
- end
27
-
28
- def tools
29
- @mutex.synchronize { @tools.dup }
30
- end
31
-
32
- def clear
33
- count = @mutex.synchronize { @tools.size }
34
- @mutex.synchronize { @tools.clear }
35
- log.info("[llm][tools] registry_cleared count=#{count}")
36
- end
37
- end
38
- end
39
- end
40
- end