legionio 1.9.34 → 1.9.37

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: e0fb2242e4fffe902c79cac15713bb57d8b2dd6388f122a9a6f14271faa3b3b2
4
- data.tar.gz: c70d462ff6369c61dd5ae9bd786b325e4e53789eddc78d10d97441793eec1360
3
+ metadata.gz: c5783df4062619770ed62ad40019ba12fd1a9d2eee8bd2ca184fa49c45dccf59
4
+ data.tar.gz: 275c32aa2d17855942601c525c4b7ac12850c033ee06ff87cc168ed8439791f8
5
5
  SHA512:
6
- metadata.gz: d7f43d17ba3a681ea09e82678831ba5694a50af90d508eb27acdcc6743d47c74bfec7da597b6d2fe6805e968d8ba247be571f6e26e60d1cfa86d6814974eb9ec
7
- data.tar.gz: 198d3a7b98bb187f23f0e5cfedacabd41983dd52b0ae238650197d2ce9a4888016d211123579cd287e5a2b05f496b0dba6fb627b6280124cf39ca92b11917a18
6
+ metadata.gz: 1d75b48306c1a33c286893958639b503a298c805f8aef441c974b0eadbf77c4e8147bb72d1b03ca09e6df1512a0ae4e816fe0cf3b344e65d6c475a2589369a49
7
+ data.tar.gz: 0e9f0c9dd9d1a654ce98fb2117690dff57e81c9d83d09d6685451c2b3f847806a5e3d5e5f55641b0cdabdf67499fc61808ee9d63c7e700773d6fea173ec37eb1
data/CHANGELOG.md CHANGED
@@ -1,6 +1,34 @@
1
1
  # Legion Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [1.9.37] - 2026-05-29
4
+
5
+ ### Added
6
+ - LLM: namespace API enabled by default — LegionIO now routes all `/v1/` and `/api/llm/` traffic
7
+ through `Namespaces::Registration` (Sinatra::Namespace, Phases 0-4 complete in legion-llm ≥ 0.8.50)
8
+ - CLI: `legion setup proxy-mode` (alias: `proxy`) writes `~/.codex/config.toml` and
9
+ `~/.claude/settings.json` env block so Codex CLI and Claude Code connect to LegionIO at
10
+ `http://localhost:4567` out of the box. Supports `--port`, `--host`, `--force`, `--json`.
11
+
12
+ ### Fixed
13
+ - LLM: Anthropic namespace message translation now properly converts `tool_use`/`tool_result` content blocks to OpenAI format for vLLM dispatch (requires legion-llm ≥ 0.10.1)
14
+ - LLM: streaming tool_use blocks emitted inline with guaranteed ordering before `message_stop`
15
+ - LLM: curator preserves recent turns — no longer curates tool results from the current/previous turn
16
+
17
+ ## [1.9.36] - 2026-05-22
18
+
19
+ ### Fixed
20
+ - Identity: preload identity provider gems and resolve process identity before LLM setup so `llm.registry` availability events include Legion identity headers.
21
+ - Identity: use the persisted `identity.json` value as a cached resolver fallback ahead of unverified system identity when fresh auth providers are unavailable.
22
+ - Bundler: load sibling Legion and LLM provider path dependencies outside the test group when those local checkouts exist, so local service boots can use the active workspace gems.
23
+
24
+ ## [1.9.35] - 2026-05-22
25
+
26
+ ### Added
27
+ - CLI: `legionio service start|stop|restart|status` subcommand for direct launchd control
28
+ - Logging transport forwarding now publishes structured log headers/properties, including identity and Legion version headers supplied by `legion-logging`.
29
+
30
+ ### Fixed
31
+ - CLI: `legionio bootstrap --start` now calls `launchctl kickstart` after brew services start to force immediate spawn on macOS 26+ (Tahoe defers `RunAtLoad` for mid-session bootstraps)
4
32
 
5
33
  ## [1.9.34] - 2026-05-18
6
34
 
data/Gemfile CHANGED
@@ -8,36 +8,42 @@ gem 'pg'
8
8
  gem 'kramdown', '>= 2.0'
9
9
  gem 'mysql2'
10
10
 
11
- group :test do
12
- gem 'legion-data', path: '../legion-data' if File.exist?(File.expand_path('../legion-data', __dir__))
13
- gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path('../legion-logging', __dir__))
14
- gem 'legion-settings', path: '../legion-settings' if File.exist?(File.expand_path('../legion-settings', __dir__))
15
-
16
- gem 'legion-apollo', path: '../legion-apollo' if File.exist?(File.expand_path('../legion-apollo', __dir__))
17
- gem 'legion-gaia', path: '../legion-gaia' if File.exist?(File.expand_path('../legion-gaia', __dir__))
18
- gem 'legion-llm', path: '../legion-llm' if File.exist?(File.expand_path('../legion-llm', __dir__))
19
- gem 'legion-mcp', path: '../legion-mcp' if File.exist?(File.expand_path('../legion-mcp', __dir__))
20
- gem 'legion-tty', path: '../legion-tty' if File.exist?(File.expand_path('../legion-tty', __dir__))
21
-
22
- gem 'lex-apollo', path: '../extensions/lex-apollo' if File.exist?(File.expand_path('../extensions/lex-apollo', __dir__))
23
- gem 'lex-llm', path: '../extensions-ai/lex-llm' if File.exist?(File.expand_path('../extensions-ai/lex-llm', __dir__))
24
- gem 'lex-llm-ledger', path: '../extensions-ai/lex-llm-ledger' if File.exist?(File.expand_path('../extensions-ai/lex-llm-ledger', __dir__))
25
-
26
- if File.exist?(File.expand_path('../extensions-identity/lex-identity-entra', __dir__))
27
- gem 'lex-identity-entra', path: '../extensions-identity/lex-identity-entra'
28
- end
29
- if File.exist?(File.expand_path('../extensions-identity/lex-identity-kerberos', __dir__))
30
- gem 'lex-identity-kerberos', path: '../extensions-identity/lex-identity-kerberos'
31
- end
32
- if File.exist?(File.expand_path('../extensions-identity/lex-identity-system', __dir__))
33
- gem 'lex-identity-system', path: '../extensions-identity/lex-identity-system'
34
- end
35
-
36
- %w[anthropic azure-foundry bedrock gemini mlx ollama openai vertex vllm].each do |provider|
37
- provider_path = "../extensions-ai/lex-llm-#{provider}"
38
- gem "lex-llm-#{provider}", path: provider_path if File.exist?(File.expand_path(provider_path, __dir__))
39
- end
11
+ gem 'legion-data', path: '../legion-data' if File.exist?(File.expand_path('../legion-data', __dir__))
12
+ gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path('../legion-logging', __dir__))
13
+ gem 'legion-settings', path: '../legion-settings' if File.exist?(File.expand_path('../legion-settings', __dir__))
14
+
15
+ gem 'legion-apollo', path: '../legion-apollo' if File.exist?(File.expand_path('../legion-apollo', __dir__))
16
+ gem 'legion-gaia', path: '../legion-gaia' if File.exist?(File.expand_path('../legion-gaia', __dir__))
17
+ gem 'legion-llm', path: '../legion-llm' if File.exist?(File.expand_path('../legion-llm', __dir__))
18
+ gem 'legion-mcp', path: '../legion-mcp' if File.exist?(File.expand_path('../legion-mcp', __dir__))
19
+ gem 'legion-tty', path: '../legion-tty' if File.exist?(File.expand_path('../legion-tty', __dir__))
20
+
21
+ gem 'lex-apollo', path: '../extensions/lex-apollo' if File.exist?(File.expand_path('../extensions/lex-apollo', __dir__))
22
+ gem 'lex-lex', path: '../extensions/lex-lex' if File.exist?(File.expand_path('../extensions/lex-lex', __dir__))
23
+ gem 'lex-llm', path: '../extensions-ai/lex-llm' if File.exist?(File.expand_path('../extensions-ai/lex-llm', __dir__))
24
+ gem 'lex-llm-ledger', path: '../extensions-ai/lex-llm-ledger' if File.exist?(File.expand_path('../extensions-ai/lex-llm-ledger', __dir__))
25
+ # gem 'lex-microsoft_teams', path: '../extensions/lex-microsoft_teams' if File.exist?(File.expand_path('../extensions/lex-microsoft_teams', __dir__))
26
+ # gem 'lex-lex', path: '../extensions/lex-lex' if File.exist?(File.expand_path('../extensions/lex-lex', __dir__))
27
+
28
+ if File.exist?(File.expand_path('../extensions-identity/lex-identity-entra', __dir__))
29
+ gem 'lex-identity-entra', path: '../extensions-identity/lex-identity-entra'
30
+ end
31
+ if File.exist?(File.expand_path('../extensions-identity/lex-identity-kerberos', __dir__))
32
+ gem 'lex-identity-kerberos', path: '../extensions-identity/lex-identity-kerberos'
33
+ end
40
34
 
35
+ gem 'lex-kerberos', path: '../extensions-identity/lex-kerberos' if File.exist?(File.expand_path('../extensions-identity/lex-kerberos', __dir__))
36
+
37
+ if File.exist?(File.expand_path('../extensions-identity/lex-identity-system', __dir__))
38
+ gem 'lex-identity-system', path: '../extensions-identity/lex-identity-system'
39
+ end
40
+
41
+ %w[anthropic azure-foundry bedrock gemini mlx ollama openai vertex vllm].each do |provider|
42
+ provider_path = "../extensions-ai/lex-llm-#{provider}"
43
+ gem "lex-llm-#{provider}", path: provider_path if File.exist?(File.expand_path(provider_path, __dir__))
44
+ end
45
+
46
+ group :test do
41
47
  gem 'faraday'
42
48
  gem 'faraday-net_http'
43
49
  gem 'graphql'
@@ -3,6 +3,7 @@
3
3
  require 'English'
4
4
  require 'json'
5
5
  require 'fileutils'
6
+ require 'open3'
6
7
  require 'rbconfig'
7
8
  require 'thor'
8
9
  require 'legion/cli/output'
@@ -292,7 +293,7 @@ module Legion
292
293
 
293
294
  def install_single_gem(name, gem_bin, out)
294
295
  puts " Installing #{name}..." unless options[:json]
295
- output, success = shell_capture("#{gem_bin} install #{name} --no-document")
296
+ output, success = shell_capture("#{gem_bin} install #{name} --no-document --clear-sources --source https://rubygems.org/")
296
297
  if success
297
298
  out.success(" #{name} installed") unless options[:json]
298
299
  { name: name, status: 'installed' }
@@ -391,18 +392,29 @@ module Legion
391
392
 
392
393
  def run_brew_service(service, out)
393
394
  output, success = shell_capture("brew services start #{service}")
394
- if success
395
- out.success("#{service} started") unless options[:json]
396
- true
397
- else
395
+ unless success
398
396
  out.warn("#{service} failed to start: #{output.strip.lines.last&.strip}") unless options[:json]
399
- false
397
+ return false
400
398
  end
399
+
400
+ out.success("#{service} started") unless options[:json]
401
+ kickstart_launchd_service("homebrew.mxcl.#{service}", out)
401
402
  rescue StandardError => e
402
403
  out.warn("brew services start #{service} raised: #{e.message}") unless options[:json]
403
404
  false
404
405
  end
405
406
 
407
+ def kickstart_launchd_service(label, out)
408
+ return true unless RbConfig::CONFIG['host_os'] =~ /darwin/
409
+
410
+ uid = ::Process.uid
411
+ _, status = Open3.capture2e('launchctl', 'kickstart', "gui/#{uid}/#{label}")
412
+ return true if status.success?
413
+
414
+ out.warn("launchctl kickstart #{label} failed (service may already be running)") unless options[:json]
415
+ false
416
+ end
417
+
406
418
  def poll_daemon_ready(out, port: 4567, timeout: 30)
407
419
  require 'net/http'
408
420
  deadline = ::Time.now + timeout
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'rbconfig'
5
+ require 'thor'
6
+ require 'legion/cli/output'
7
+
8
+ module Legion
9
+ module CLI
10
+ class ServiceCommand < Thor
11
+ namespace 'service'
12
+
13
+ def self.exit_on_failure?
14
+ true
15
+ end
16
+
17
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
18
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
19
+
20
+ SERVICE_LABEL = 'homebrew.mxcl.legionio'
21
+
22
+ desc 'start', 'Start the Legion launchd service'
23
+ long_desc <<~DESC
24
+ Starts the Legion background service via launchd. On macOS 26+ (Tahoe),
25
+ uses launchctl kickstart to ensure immediate process spawn after bootstrap.
26
+ DESC
27
+ def start
28
+ out = Output::Formatter.new(json: options[:json], color: !options[:no_color])
29
+ ensure_macos!(out)
30
+
31
+ plist = plist_path
32
+ unless File.exist?(plist)
33
+ out.error("Service plist not found at #{plist}")
34
+ out.info('Run: brew install legionio')
35
+ raise SystemExit, 1
36
+ end
37
+
38
+ uid = ::Process.uid
39
+ target = "gui/#{uid}"
40
+
41
+ if service_loaded?(target)
42
+ out.info('Service already loaded, kicking...')
43
+ else
44
+ _, status = Open3.capture2e('launchctl', 'bootstrap', target, plist)
45
+ out.warn('bootstrap failed (may already be loaded), attempting kickstart anyway') unless status.success?
46
+ end
47
+
48
+ _, status = Open3.capture2e('launchctl', 'kickstart', '-k', "#{target}/#{SERVICE_LABEL}")
49
+ if status.success?
50
+ out.success('Legion service started')
51
+ else
52
+ out.error('Failed to kickstart Legion service')
53
+ raise SystemExit, 1
54
+ end
55
+
56
+ poll_ready(out)
57
+ end
58
+
59
+ desc 'stop', 'Stop the Legion launchd service'
60
+ def stop
61
+ out = Output::Formatter.new(json: options[:json], color: !options[:no_color])
62
+ ensure_macos!(out)
63
+
64
+ uid = ::Process.uid
65
+ target = "gui/#{uid}"
66
+
67
+ _, status = Open3.capture2e('launchctl', 'bootout', "#{target}/#{SERVICE_LABEL}")
68
+ if status.success?
69
+ out.success('Legion service stopped')
70
+ else
71
+ out.warn('Service was not loaded (already stopped?)')
72
+ end
73
+ end
74
+
75
+ desc 'restart', 'Restart the Legion launchd service'
76
+ def restart
77
+ out = Output::Formatter.new(json: options[:json], color: !options[:no_color])
78
+ ensure_macos!(out)
79
+
80
+ uid = ::Process.uid
81
+ target = "gui/#{uid}"
82
+
83
+ Open3.capture2e('launchctl', 'bootout', "#{target}/#{SERVICE_LABEL}")
84
+ sleep 1
85
+
86
+ plist = plist_path
87
+ Open3.capture2e('launchctl', 'bootstrap', target, plist) if File.exist?(plist)
88
+
89
+ _, status = Open3.capture2e('launchctl', 'kickstart', '-k', "#{target}/#{SERVICE_LABEL}")
90
+ if status.success?
91
+ out.success('Legion service restarted')
92
+ else
93
+ out.error('Failed to restart Legion service')
94
+ raise SystemExit, 1
95
+ end
96
+
97
+ poll_ready(out)
98
+ end
99
+
100
+ desc 'status', 'Show Legion launchd service status'
101
+ def status
102
+ out = Output::Formatter.new(json: options[:json], color: !options[:no_color])
103
+ ensure_macos!(out)
104
+
105
+ uid = ::Process.uid
106
+ target = "gui/#{uid}"
107
+ output, status = Open3.capture2e('launchctl', 'print', "#{target}/#{SERVICE_LABEL}")
108
+
109
+ unless status.success?
110
+ out.info('Service is not loaded')
111
+ return
112
+ end
113
+
114
+ state = output[/state = (.+)/, 1] || 'unknown'
115
+ pid = output[/pid = (\d+)/, 1]
116
+ runs = output[/runs = (\d+)/, 1]
117
+
118
+ if options[:json]
119
+ puts Legion::JSON.dump({ state: state, pid: pid&.to_i, runs: runs&.to_i })
120
+ else
121
+ out.info("State: #{state}")
122
+ out.info("PID: #{pid}") if pid
123
+ out.info("Runs: #{runs}") if runs
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def ensure_macos!(out)
130
+ return if RbConfig::CONFIG['host_os'] =~ /darwin/
131
+
132
+ out.error('The service command is only available on macOS (uses launchd)')
133
+ raise SystemExit, 1
134
+ end
135
+
136
+ def plist_path
137
+ File.expand_path("~/Library/LaunchAgents/#{SERVICE_LABEL}.plist")
138
+ end
139
+
140
+ def service_loaded?(target)
141
+ _, status = Open3.capture2e('launchctl', 'print', "#{target}/#{SERVICE_LABEL}")
142
+ status.success?
143
+ end
144
+
145
+ def poll_ready(out, port: 4567, timeout: 15)
146
+ require 'net/http'
147
+ deadline = ::Time.now + timeout
148
+ until ::Time.now > deadline
149
+ begin
150
+ resp = Net::HTTP.get_response(URI("http://localhost:#{port}/api/ready"))
151
+ if resp.is_a?(Net::HTTPSuccess)
152
+ out.success("Daemon ready on port #{port}")
153
+ return
154
+ end
155
+ rescue StandardError
156
+ # not ready yet
157
+ end
158
+ sleep 1
159
+ end
160
+ out.info('Service started but not yet ready (boot in progress)')
161
+ end
162
+ end
163
+ end
164
+ end
@@ -157,6 +157,34 @@ module Legion
157
157
  end
158
158
  end
159
159
 
160
+ desc 'proxy-mode', 'Configure Codex CLI and Claude Code to use LegionIO as a local API proxy'
161
+ option :port, type: :numeric, default: 4567, desc: 'LegionIO API port'
162
+ option :host, type: :string, default: 'localhost', desc: 'LegionIO API host'
163
+ def proxy_mode
164
+ out = formatter
165
+ base_url = "http://#{options[:host]}:#{options[:port]}/v1"
166
+ written = []
167
+ skipped = []
168
+
169
+ write_codex_config(base_url, written, skipped)
170
+ write_claude_code_proxy_config(base_url, written, skipped)
171
+
172
+ if options[:json]
173
+ out.json(written: written, skipped: skipped, base_url: base_url)
174
+ else
175
+ out.spacer
176
+ out.success("LegionIO proxy mode configured (#{written.size} written, #{skipped.size} skipped)")
177
+ written.each { |f| puts " Written: #{f}" }
178
+ skipped.each { |f| puts " Skipped (already exists, use --force to overwrite): #{f}" }
179
+ out.spacer
180
+ puts " LegionIO API: #{base_url.sub('/v1', '')}"
181
+ puts ' Codex CLI: legion llm proxy (uses ~/.codex/config.toml)'
182
+ puts ' Claude Code: set ANTHROPIC_BASE_URL in your shell or ~/.claude/settings.json'
183
+ out.spacer
184
+ end
185
+ end
186
+ map 'proxy' => :proxy_mode
187
+
160
188
  desc 'agentic', 'Install full cognitive stack (GAIA + LLM + Apollo + all agentic extensions)'
161
189
  option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing'
162
190
  def agentic
@@ -473,7 +501,7 @@ module Legion
473
501
 
474
502
  def install_gem(name, gem_bin, out)
475
503
  puts " Installing #{name}..." unless options[:json]
476
- output = `#{gem_bin} install #{name} --no-document 2>&1`
504
+ output = `#{gem_bin} install #{name} --no-document --clear-sources --source https://rubygems.org/ 2>&1`
477
505
  if $CHILD_STATUS.success?
478
506
  out.success(" #{name} installed") unless options[:json]
479
507
  { name: name, status: 'installed' }
@@ -712,6 +740,74 @@ module Legion
712
740
  end
713
741
  { name: 'VS Code', path: path, configured: configured }
714
742
  end
743
+
744
+ def write_codex_config(base_url, written, skipped)
745
+ codex_dir = File.expand_path('~/.codex')
746
+ codex_path = File.join(codex_dir, 'config.toml')
747
+
748
+ if File.exist?(codex_path) && !options[:force]
749
+ skipped << codex_path
750
+ return
751
+ end
752
+
753
+ FileUtils.mkdir_p(codex_dir)
754
+
755
+ content = <<~TOML
756
+ model = "legionio"
757
+ model_provider = "legion"
758
+
759
+ [model_providers.legion]
760
+ name = "LegionIO"
761
+ env_key = "LEGION_API_KEY"
762
+ base_url = "#{base_url}"
763
+ wire_api = "responses"
764
+ TOML
765
+
766
+ File.write(codex_path, content)
767
+ written << codex_path
768
+ rescue StandardError => e
769
+ raise Thor::Error, "Failed to write #{codex_path}: #{e.message}"
770
+ end
771
+
772
+ def write_claude_code_proxy_config(base_url, written, skipped)
773
+ claude_dir = File.expand_path('~/.claude')
774
+ claude_path = File.join(claude_dir, 'settings.json')
775
+
776
+ existing = if File.exist?(claude_path)
777
+ begin
778
+ ::JSON.parse(File.read(claude_path))
779
+ rescue ::JSON::ParserError
780
+ {}
781
+ end
782
+ else
783
+ {}
784
+ end
785
+
786
+ proxy_env = {
787
+ 'ANTHROPIC_BASE_URL' => base_url.sub(%r{/v1$}, ''),
788
+ 'ANTHROPIC_API_KEY' => 'legion',
789
+ 'ANTHROPIC_AUTH_TOKEN' => 'legion',
790
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'legionio',
791
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'legionio',
792
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'legionio'
793
+ }
794
+
795
+ current_env = existing['env'] || {}
796
+
797
+ already_set = proxy_env.all? { |k, v| current_env[k] == v }
798
+ if already_set && !options[:force]
799
+ skipped << claude_path
800
+ return
801
+ end
802
+
803
+ merged = existing.merge('env' => current_env.merge(proxy_env))
804
+
805
+ FileUtils.mkdir_p(claude_dir)
806
+ File.write(claude_path, ::JSON.pretty_generate(merged))
807
+ written << claude_path
808
+ rescue StandardError => e
809
+ raise Thor::Error, "Failed to write #{claude_path}: #{e.message}"
810
+ end
715
811
  end
716
812
  end
717
813
  end
data/lib/legion/cli.rb CHANGED
@@ -69,6 +69,7 @@ module Legion
69
69
  autoload :Debug, 'legion/cli/debug_command'
70
70
  autoload :CodegenCommand, 'legion/cli/codegen_command'
71
71
  autoload :Bootstrap, 'legion/cli/bootstrap_command'
72
+ autoload :ServiceCommand, 'legion/cli/service_command'
72
73
  autoload :Broker, 'legion/cli/broker_command'
73
74
  autoload :AdminCommand, 'legion/cli/admin_command'
74
75
  autoload :Workflow, 'legion/cli/workflow_command'
@@ -258,6 +259,9 @@ module Legion
258
259
  desc 'setup SUBCOMMAND', 'Install feature packs and configure IDE integrations'
259
260
  subcommand 'setup', Legion::CLI::Setup
260
261
 
262
+ desc 'service SUBCOMMAND', 'Manage the Legion launchd background service'
263
+ subcommand 'service', Legion::CLI::ServiceCommand
264
+
261
265
  desc 'bootstrap SOURCE', 'One-command setup: fetch config, scaffold, and install packs'
262
266
  subcommand 'bootstrap', Legion::CLI::Bootstrap
263
267
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ alter_table(:tbi_patterns) do
6
+ add_index :pattern_type, name: :idx_tbi_patterns_type
7
+ add_index :tier, name: :idx_tbi_patterns_tier
8
+ end
9
+ end
10
+
11
+ down do
12
+ alter_table(:tbi_patterns) do
13
+ drop_index :tier, name: :idx_tbi_patterns_tier
14
+ drop_index :pattern_type, name: :idx_tbi_patterns_type
15
+ end
16
+ end
17
+ end
@@ -65,8 +65,10 @@ module Legion
65
65
  end
66
66
 
67
67
  def prepare # rubocop:disable Metrics/AbcSize
68
+ @dedicated_channel = create_dedicated_channel
68
69
  @queue = queue.new
69
- @queue.channel.prefetch(prefetch) if defined? prefetch
70
+ reassign_queue_channel(@queue, @dedicated_channel)
71
+ @dedicated_channel.prefetch(prefetch) if defined? prefetch
70
72
  consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{SecureRandom.uuid}"
71
73
  @consumer = Bunny::Consumer.new(@queue.channel, @queue, consumer_tag, false, false)
72
74
  @consumer.on_delivery do |delivery_info, metadata, payload|
@@ -298,6 +300,21 @@ module Legion
298
300
  end
299
301
  end
300
302
 
303
+ def create_dedicated_channel
304
+ s = Legion::Transport::Connection.session
305
+ raise IOError, 'transport session unavailable' unless s&.open?
306
+
307
+ settings = Legion::Transport::Connection.settings
308
+ s.create_channel(nil, settings[:channel][:default_worker_pool_size], false, 10)
309
+ end
310
+
311
+ def reassign_queue_channel(queue_instance, new_channel)
312
+ old_channel = queue_instance.channel
313
+ old_channel.deregister_queue(queue_instance) if old_channel.respond_to?(:deregister_queue)
314
+ queue_instance.instance_variable_set(:@channel, new_channel)
315
+ new_channel.register_queue(queue_instance) if new_channel.respond_to?(:register_queue)
316
+ end
317
+
301
318
  def republish_with_retry_count(_delivery_info, metadata, payload, new_count)
302
319
  headers = (metadata&.headers || {}).dup
303
320
  headers[RetryPolicy::RETRY_COUNT_HEADER] = new_count
@@ -132,6 +132,24 @@ module Legion
132
132
  Legion::Logging.info "[Extensions] flushed #{count} pending registrations" if defined?(Legion::Logging)
133
133
  end
134
134
 
135
+ def require_identity_extensions
136
+ find_extensions.select { |entry| entry[:category] == :identity }.each do |entry|
137
+ gem_name = entry[:gem_name]
138
+ ext_settings = extension_settings_for_entry(entry)
139
+
140
+ if ext_settings.is_a?(Hash) && ext_settings.key?(:enabled) && !ext_settings[:enabled]
141
+ Legion::Logging.info "Skipping #{gem_name} identity preload because it's disabled"
142
+ next
143
+ end
144
+
145
+ Catalog.register(gem_name)
146
+ register_extension_handle(gem_name, state: :registered,
147
+ latest_installed_version: latest_installed_version(gem_name))
148
+ ensure_namespace(entry[:const_path]) if entry[:segments].length > 1
149
+ gem_load(entry)
150
+ end
151
+ end
152
+
135
153
  def pause_actors
136
154
  @running_instances&.each do |inst|
137
155
  timer = inst.instance_variable_get(:@timer)
@@ -7,7 +7,10 @@ module Legion
7
7
  module EmbeddingSimilarity
8
8
  class << self
9
9
  def check(input, safe_embeddings:, threshold: 0.3)
10
- return { safe: true, reason: 'no embeddings service' } unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:embed)
10
+ unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:embed) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
11
+ return { safe: true,
12
+ reason: 'no embeddings service' }
13
+ end
11
14
 
12
15
  input_vec = Legion::LLM.embed(input)
13
16
  return { safe: true, reason: 'embedding failed' } unless input_vec
@@ -18,6 +21,8 @@ module Legion
18
21
  Legion::Logging.warn "[Guardrails] EmbeddingSimilarity rejected input: distance=#{min_dist.round(4)} threshold=#{threshold}"
19
22
  end
20
23
  { safe: safe, distance: min_dist.round(4), threshold: threshold }
24
+ rescue StandardError
25
+ { safe: true, reason: 'embedding failed' }
21
26
  end
22
27
 
23
28
  def cosine_distance(vec_a, vec_b)
@@ -21,6 +21,10 @@ module Legion
21
21
  token
22
22
  end
23
23
 
24
+ def credential_for(provider_name, qualifier: nil, for_context: nil, purpose: nil, context: nil)
25
+ token_for(provider_name, qualifier: qualifier, for_context: for_context, purpose: purpose, context: context)
26
+ end
27
+
24
28
  def lease_for(provider_name, qualifier: nil)
25
29
  name = provider_name.to_sym
26
30
  resolved = qualifier || default_qualifier_for(name)
@@ -33,6 +33,12 @@ module Legion
33
33
 
34
34
  winning_provider, winning_result, provider_results = resolve_auth(auth_providers, timeout: timeout)
35
35
 
36
+ if winning_provider.nil?
37
+ log.debug('resolve!: no auth winner, trying cached identity')
38
+ winning_provider, winning_result, cached_results = resolve_cached_identity
39
+ provider_results.merge!(cached_results) if cached_results
40
+ end
41
+
36
42
  if winning_provider.nil?
37
43
  log.debug('resolve!: no auth winner, trying fallback providers')
38
44
  winning_provider, winning_result, fallback_results = resolve_auth(fallback_providers, timeout: timeout)
@@ -229,6 +235,67 @@ module Legion
229
235
  end
230
236
  end
231
237
 
238
+ def resolve_cached_identity
239
+ cached = read_cached_identity
240
+ return [nil, nil, {}] unless cached
241
+
242
+ provider = cached_identity_provider
243
+ result = {
244
+ canonical_name: cached[:canonical_name],
245
+ kind: cached[:kind] || :human,
246
+ source: :identity_json,
247
+ persistent: true
248
+ }
249
+
250
+ [
251
+ provider,
252
+ result,
253
+ {
254
+ provider.provider_name => {
255
+ status: :resolved,
256
+ trust: provider.trust_level,
257
+ resolved_at: Time.now,
258
+ provider: provider,
259
+ result: result
260
+ }
261
+ }
262
+ ]
263
+ end
264
+
265
+ def read_cached_identity
266
+ path = File.expand_path('~/.legionio/settings/identity.json')
267
+ return nil unless File.file?(path)
268
+
269
+ data = if defined?(Legion::JSON)
270
+ Legion::JSON.load(File.read(path))
271
+ else
272
+ require 'json'
273
+ ::JSON.parse(File.read(path), symbolize_names: true)
274
+ end
275
+ canonical = data[:canonical_name] || data['canonical_name']
276
+ return nil if canonical.to_s.strip.empty?
277
+
278
+ {
279
+ canonical_name: canonical.to_s,
280
+ kind: (data[:kind] || data['kind'] || :human).to_sym
281
+ }
282
+ rescue StandardError => e
283
+ log.warn("identity.json read failed: #{e.message}")
284
+ nil
285
+ end
286
+
287
+ def cached_identity_provider
288
+ @cached_identity_provider ||= Module.new do
289
+ module_function
290
+
291
+ def provider_name = :identity_cache
292
+ def provider_type = :auth
293
+ def priority = -100
294
+ def trust_weight = 150
295
+ def trust_level = :cached
296
+ end
297
+ end
298
+
232
299
  def auth_future_status(future, result)
233
300
  if future.rejected?
234
301
  :failed
@@ -112,6 +112,8 @@ module Legion
112
112
  end
113
113
  setup_cluster if data
114
114
 
115
+ setup_identity_before_llm(extensions: extensions, transport: transport)
116
+
115
117
  if llm
116
118
  begin
117
119
  setup_llm
@@ -161,15 +163,14 @@ module Legion
161
163
  setup_safety_metrics
162
164
  setup_supervision if supervision
163
165
 
164
- require_relative 'identity' if File.exist?(File.expand_path('identity.rb', __dir__))
165
-
166
166
  if extensions
167
167
  load_extensions
168
168
  Legion::Readiness.mark_ready(:extensions)
169
169
  setup_generated_functions
170
170
  end
171
171
 
172
- # Identity resolution after extensions so lex-identity-* providers are loaded
172
+ # Re-run identity after full extension load so any providers with autobuild-time
173
+ # registration can upgrade the pre-LLM identity.
173
174
  db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
174
175
  setup_identity if transport || db_available
175
176
  register_credential_providers if extensions && (transport || db_available)
@@ -451,6 +452,7 @@ module Legion
451
452
  log.info 'Setting up Legion::LLM'
452
453
  require 'legion/llm'
453
454
  Legion::Settings.merge_settings('llm', Legion::LLM::Settings.default)
455
+ Legion::Settings.loader.settings[:llm][:api][:use_namespaces] = true
454
456
  preload_llm_providers
455
457
  Legion::LLM.start
456
458
  log.info 'Legion::LLM started'
@@ -589,7 +591,7 @@ module Legion
589
591
  end
590
592
  end
591
593
 
592
- def setup_logging_transport # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
594
+ def setup_logging_transport # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
593
595
  return unless defined?(Legion::Transport::Connection)
594
596
  return unless Legion::Transport::Connection.session_open?
595
597
 
@@ -612,11 +614,16 @@ module Legion
612
614
  exchange = log_channel.topic('legion.logging', durable: true)
613
615
 
614
616
  if forward_logs
615
- Legion::Logging.log_writer = lambda { |event, routing_key:|
617
+ Legion::Logging.log_writer = lambda { |event, routing_key:, headers: {}, properties: {}|
616
618
  begin
617
619
  next unless log_channel&.open?
618
620
 
619
- exchange.publish(Legion::JSON.dump(event), routing_key: routing_key)
621
+ exchange.publish(
622
+ Legion::JSON.dump(event),
623
+ routing_key: routing_key,
624
+ headers: headers,
625
+ **properties
626
+ )
620
627
  rescue StandardError
621
628
  nil
622
629
  end
@@ -918,6 +925,8 @@ module Legion
918
925
  Legion::Readiness.mark_skipped(:rbac)
919
926
  end
920
927
 
928
+ setup_identity_before_llm(extensions: true, transport: true)
929
+
921
930
  if defined?(Legion::LLM)
922
931
  setup_llm
923
932
  else
@@ -945,7 +954,6 @@ module Legion
945
954
  # Phase 5: re-run identity resolution after extensions are loaded so that
946
955
  # any identity providers registered by lex-identity-* extensions are
947
956
  # available to the resolver (mirrors the boot-time ordering).
948
- Legion::Identity::Resolver.reset! if defined?(Legion::Identity::Resolver)
949
957
  setup_identity
950
958
 
951
959
  db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
@@ -976,6 +984,18 @@ module Legion
976
984
  Legion::Extensions.hook_extensions
977
985
  end
978
986
 
987
+ def setup_identity_before_llm(extensions:, transport:)
988
+ require_relative 'identity' if File.exist?(File.expand_path('identity.rb', __dir__))
989
+ Legion::Extensions.require_identity_extensions if extensions &&
990
+ defined?(Legion::Extensions) &&
991
+ Legion::Extensions.respond_to?(:require_identity_extensions)
992
+
993
+ db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
994
+ setup_identity if transport || db_available
995
+ rescue StandardError => e
996
+ handle_exception(e, level: :warn, operation: 'service.setup_identity_before_llm')
997
+ end
998
+
979
999
  def register_core_tools
980
1000
  require 'legion/tools'
981
1001
  Legion::Tools.register_all
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ alter_table(:tool_embedding_cache) do
6
+ add_index :tool_name, name: :idx_tool_embedding_cache_tool_name
7
+ end
8
+ end
9
+
10
+ down do
11
+ alter_table(:tool_embedding_cache) do
12
+ drop_index :tool_name, name: :idx_tool_embedding_cache_tool_name
13
+ end
14
+ end
15
+ end
@@ -72,6 +72,9 @@ module Legion
72
72
  )
73
73
  Legion::Logging.error "[TraceSearch] LLM filter generation failed for query: #{query.inspect}" if !result[:valid] && defined?(Legion::Logging)
74
74
  result[:data] if result[:valid]
75
+ rescue Legion::LLM::LLMError => e
76
+ handle_exception(e, level: :debug, handled: true, operation: 'trace_search.generate_filter') if respond_to?(:handle_exception)
77
+ nil
75
78
  end
76
79
 
77
80
  def schema_context
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.9.34'
4
+ VERSION = '1.9.37'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.34
4
+ version: 1.9.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -750,6 +750,7 @@ files:
750
750
  - lib/legion/cli/relationship.rb
751
751
  - lib/legion/cli/review_command.rb
752
752
  - lib/legion/cli/schedule_command.rb
753
+ - lib/legion/cli/service_command.rb
753
754
  - lib/legion/cli/setup_command.rb
754
755
  - lib/legion/cli/skill_command.rb
755
756
  - lib/legion/cli/start.rb
@@ -779,6 +780,7 @@ files:
779
780
  - lib/legion/data/local_migrations/20250601000001_create_tbi_patterns.rb
780
781
  - lib/legion/data/local_migrations/20260319000001_create_extension_catalog.rb
781
782
  - lib/legion/data/local_migrations/20260319000002_create_extension_permissions.rb
783
+ - lib/legion/data/local_migrations/20260528000001_add_tbi_patterns_indexes.rb
782
784
  - lib/legion/data/models/tbi_pattern.rb
783
785
  - lib/legion/digital_worker.rb
784
786
  - lib/legion/digital_worker/airb.rb
@@ -913,6 +915,7 @@ files:
913
915
  - lib/legion/tools/do.rb
914
916
  - lib/legion/tools/embedding_cache.rb
915
917
  - lib/legion/tools/embedding_cache/migrations/001_create_tool_embedding_cache.rb
918
+ - lib/legion/tools/embedding_cache/migrations/002_add_tool_name_index.rb
916
919
  - lib/legion/tools/registry.rb
917
920
  - lib/legion/tools/status.rb
918
921
  - lib/legion/tools/trigger_index.rb