legion-llm 0.15.0 → 0.15.1

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: 2620e9731075a26249c5f8bad24610953eac37892f17212d1275a0a66b2497d8
4
- data.tar.gz: f13b135f8276958450683397e26defa62098c892ac50f40642593606e878960d
3
+ metadata.gz: c27dbb3d54472859916263b21e60e5150b8deb3b1138cfcb7c7d7e28d5ebbc1d
4
+ data.tar.gz: 3e4fb810c2ea38c895a98abd3cb432e3805a3faf35cdfecb5ad3e2347aa47046
5
5
  SHA512:
6
- metadata.gz: b3d6cd8d597e522fca27a92dc62e27ecbb4ca3237136110544a23bf5f94e2159290c24c9365252693674e4f5898d2bdf654f85a80eab9740bb6e72e010747ef2
7
- data.tar.gz: 2fa40ab507a7725517d27143e8d78f985e67ebebd2314e0811c673f8b83d2d004ed051b98c5aa1aedbc6da04eed0e34ec152292d80c16cc7ffe2e112a235f2f4
6
+ metadata.gz: c0f36cfb05fdf100e7c706dbfa254fd486afb1de6c7beb3644ca56f0504283168fe676fb8b5299222d06d4206db469af98b0498fe22d04adc1ec1ea8fc1212cb
7
+ data.tar.gz: f365e296fc5c77dba031187153c8f77cb454737b6637a199102e598a1e80beb3a26b68481888266bbd0c6a5943aa00eef6b5a11905ee60f6ada38f6861667290
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.15.1] - 2026-08-01
4
+
5
+ ### Fixed
6
+ - **`rag_enabled?` shadow-default made setting un-disableable.** The `rag_setting(key, default)` helper used `Legion::Settings.dig(:llm, :rag, key) || default`, which evaluates `false || true` → `true` for any boolean setting. Setting `llm.rag.enabled = false` was silently ignored. Deleted `rag_setting`, `rag_settings`, `settings_value`, and `trivial_patterns` wrapper methods entirely. All call sites now use direct bracket access (`Legion::Settings[:llm][:rag][:key]`) per legionio-standards.md §3. Defaults live exclusively in `rag_defaults` in `settings.rb`.
7
+ - **Circuit-breaker half-open starvation recovery.** When all providers tripped simultaneously, the half-open probe slot was never offered because the sweep only ran when a request arrived — but requests were already being rejected. Added a background sweep thread (`routing.health.circuit_breaker.sweep_interval_seconds`, default 30) that promotes one tripped provider to half-open regardless of inbound traffic.
8
+ - **Provider connections not closed on shutdown (CLOSE_WAIT accumulation).** `Legion::LLM.shutdown` now iterates all registered provider connections and calls `disconnect`/`close`, preventing socket leak under container orchestration restarts.
9
+ - **Client-side SSE write failures no longer trip the upstream provider's circuit breaker.** A dead client socket (`Puma::ConnectionError: Socket timeout writing data`, `Errno::EPIPE`/`ECONNRESET`, `StreamClosed`) mid- or end-stream was being counted as an upstream provider failure: it reported provider `:error`, tripped the vLLM lane's circuit, and escalated to `EscalationExhausted` — failing over because the *client* went away. Once both `gemma-4-31b` lanes tripped, every 31b request hit `NoLaneAvailable` until process restart, despite the upstream being healthy for weeks. Root cause: `Puma::ConnectionError` is a `RuntimeError`, not an `IOError`, so the `StreamAssembler`'s `rescue IOError, Errno::EPIPE` guards let it escape raw into the executor's escalation loop, whose failure path never consulted the existing `client_stream_error?` helper. The circuit breaker now answers only "is the upstream provider broken?": client-write/disconnect, SSE/canonical-parse/translation, and daemon/programming errors (`NoMethodError`/`ArgumentError`/`NotImplementedError`) never report provider health, trip a circuit, or escalate; a client disconnect converts to a clean cancellation that still emits its ledger event. Genuine provider errors (5xx, connection-refused-to-provider, provider 401) still trip and escalate exactly as before. Fix is provider-agnostic (no provider-name conditionals).
10
+
3
11
  ## [0.15.0] - 2026-07-24
4
12
 
5
13
  ### Changed
@@ -149,7 +149,9 @@ module Legion
149
149
  handle_text_delta(adapted.text) if adapted.text && !adapted.text.empty?
150
150
  rescue StreamClosed
151
151
  raise
152
- rescue IOError, Errno::EPIPE => e
152
+ rescue StandardError => e
153
+ raise unless client_write_error?(e)
154
+
153
155
  mark_closed!(e)
154
156
  raise StreamClosed, e.message
155
157
  end
@@ -182,7 +184,9 @@ module Legion
182
184
  guard { @emitter.on_done(stop_reason: stop_reason, usage: usage, model: resolved_model(final_response)) }
183
185
  emit_failover_trailers!
184
186
  @phase = :finished
185
- rescue IOError, Errno::EPIPE => e
187
+ rescue StandardError => e
188
+ raise unless client_write_error?(e)
189
+
186
190
  mark_closed!(e)
187
191
  end
188
192
 
@@ -195,7 +199,9 @@ module Legion
195
199
  start! unless @started
196
200
  guard { @emitter.on_error(message: error.message, type: type, status_code: status) }
197
201
  @phase = :finished
198
- rescue IOError, Errno::EPIPE => e
202
+ rescue StandardError => e
203
+ raise unless client_write_error?(e)
204
+
199
205
  mark_closed!(e)
200
206
  end
201
207
 
@@ -253,7 +259,9 @@ module Legion
253
259
  @full_thinking_signature = nil
254
260
  @phase = :before_first_byte
255
261
  @failover_chain << :failover_marker
256
- rescue IOError, Errno::EPIPE => e
262
+ rescue StandardError => e
263
+ raise unless client_write_error?(e)
264
+
257
265
  mark_closed!(e)
258
266
  end
259
267
 
@@ -287,7 +295,9 @@ module Legion
287
295
  return if @closed
288
296
 
289
297
  guard { @emitter.on_keep_alive }
290
- rescue IOError, Errno::EPIPE => e
298
+ rescue StandardError => e
299
+ raise unless client_write_error?(e)
300
+
291
301
  mark_closed!(e)
292
302
  end
293
303
 
@@ -325,11 +335,29 @@ module Legion
325
335
  def guard
326
336
  yield
327
337
  true
328
- rescue IOError, Errno::EPIPE => e
338
+ rescue StandardError => e
339
+ raise unless client_write_error?(e)
340
+
329
341
  mark_closed!(e)
330
342
  false
331
343
  end
332
344
 
345
+ # A failure writing the SSE response back to the HTTP client — the client socket
346
+ # died (disconnect / VPN bounce / timeout). NOT a provider failure. Puma::ConnectionError
347
+ # ("Socket timeout writing data") is a RuntimeError, NOT an IOError, so the historical
348
+ # `rescue IOError, Errno::EPIPE` guards let it escape into the executor, where it was
349
+ # misattributed to the upstream provider and tripped a healthy lane's circuit. Matched
350
+ # by class name so the assembler need not require puma. Everything else re-raises.
351
+ def client_write_error?(error)
352
+ name = error.class.name.to_s
353
+ name.include?('Puma::ConnectionError') ||
354
+ error.is_a?(Errno::EPIPE) ||
355
+ error.is_a?(Errno::ECONNRESET) ||
356
+ error.is_a?(Errno::ECONNABORTED) ||
357
+ error.is_a?(EOFError) ||
358
+ error.is_a?(IOError)
359
+ end
360
+
333
361
  def mark_closed!(error)
334
362
  return if @closed
335
363
 
@@ -113,6 +113,24 @@ module Legion
113
113
  @mutex.synchronize { @registry.dup.freeze }
114
114
  end
115
115
 
116
+ def disconnect_all!
117
+ @mutex.synchronize do
118
+ count = 0
119
+ @registry.each_value do |entries|
120
+ entries.each_value do |entry|
121
+ adapter = entry[:adapter]
122
+ next unless adapter.respond_to?(:provider) && adapter.provider.respond_to?(:disconnect)
123
+
124
+ adapter.provider.disconnect
125
+ count += 1
126
+ rescue StandardError => e
127
+ log.warn("[llm][registry] disconnect failed: #{e.message}")
128
+ end
129
+ end
130
+ log.info("[llm][registry] disconnect_all count=#{count}")
131
+ end
132
+ end
133
+
116
134
  def reset!
117
135
  @mutex.synchronize do
118
136
  count = @registry.values.sum(&:size)
@@ -98,6 +98,17 @@ module Legion
98
98
  log.error "[llm][escalation] action=request_payload_error provider=#{resolution.provider} " \
99
99
  "instance=#{resolution.instance || 'default'} model=#{resolution.model} " \
100
100
  "error=#{err.message.to_s[0, 500]} daemon_side_payload_bug=true provider_health=false"
101
+ elsif non_provider_failure?(err)
102
+ # The circuit breaker answers ONE question: "is the upstream LLM provider
103
+ # itself broken/down?" Client-side write/disconnect (the client socket died),
104
+ # SSE/canonical parse/translation (LegionIO's own bugs), and daemon/programming
105
+ # errors are NOT provider failures. Never report provider health or trip a
106
+ # circuit for them — doing so misattributes a dead client socket (or our own
107
+ # bug) to a healthy upstream and trips its lane. This is the dominant field
108
+ # failure this method previously caused.
109
+ log.warn "[llm][escalation] action=non_provider_error provider=#{resolution.provider} " \
110
+ "instance=#{resolution.instance || 'default'} model=#{resolution.model} " \
111
+ "error=#{err.class}: #{err.message.to_s[0, 300]} provider_health=untouched"
101
112
  elsif account_specific_error?(err)
102
113
  # Account-scoped failure (credit balance, payment, quota). It is
103
114
  # deterministic — it will fail every call until the operator tops up —
@@ -222,7 +233,11 @@ module Legion
222
233
  emit_error_audit(error, status: status, provider: provider, model: model)
223
234
  return if request_payload_error?(error)
224
235
  return if context_overflow_error?(error)
225
- return if client_stream_error?(error)
236
+ # Non-provider failures (client-write/disconnect, SSE/parse/translation, daemon
237
+ # programming errors) never reflect on provider health. The upstream is healthy;
238
+ # counting these as provider :error trips a live lane's circuit for a dead client
239
+ # socket or a LegionIO bug — the misattribution behind the field restart-cascade.
240
+ return if non_provider_failure?(error)
226
241
 
227
242
  if authentication_error?(error) || config_error?(error)
228
243
  Legion::LLM::Router.health_tracker.deny_model( # allowlist:write-side
@@ -400,10 +415,15 @@ module Legion
400
415
 
401
416
  # Detect client-side stream errors (disconnects, broken pipes, socket timeouts)
402
417
  # that originate from writing back to the HTTP client, not from the provider itself.
418
+ # Puma::ConnectionError is a RuntimeError (NOT an IOError), so it slips past the
419
+ # StreamAssembler's rescue IOError/EPIPE guards and reaches the executor raw — the
420
+ # exact class the production logs show tripping the vLLM circuit. StreamClosed is the
421
+ # assembler's own wrapper raised once the client socket is confirmed dead.
403
422
  def client_stream_error?(err)
404
423
  name = err.class.name.to_s
405
424
  msg = err.message.to_s
406
425
  name.include?('Puma::ConnectionError') ||
426
+ name.include?('StreamAssembler::StreamClosed') ||
407
427
  name.include?('Errno::EPIPE') ||
408
428
  (name.include?('IOError') && msg.include?('closed')) ||
409
429
  (name.include?('IOError') && msg.include?('already closed')) ||
@@ -416,7 +436,37 @@ module Legion
416
436
  # shared daemon code — retrying on a different lane guarantees the same crash. Classified as
417
437
  # terminal: raise immediately, never retry, never trip circuits, never push to tried_lanes.
418
438
  def internal_error?(err)
419
- err.is_a?(::NoMethodError) || err.is_a?(::ArgumentError)
439
+ err.is_a?(::NoMethodError) || err.is_a?(::ArgumentError) || err.is_a?(::NotImplementedError)
440
+ end
441
+
442
+ # SSE assembly / canonical parse / translation errors originate inside LegionIO's
443
+ # own stream-assembly and translation layer, not from the upstream provider. Like
444
+ # daemon/programming errors, they must never trip a provider circuit or escalate to
445
+ # another lane — the upstream is healthy; the bug is ours. Matched by class name so
446
+ # this stays provider-agnostic (N×N invariant) and does not couple to lex-llm gems.
447
+ def sse_translation_error?(err)
448
+ name = err.class.name.to_s
449
+ name.include?('JSON::ParseError') ||
450
+ name.include?('JSON::ParserError')
451
+ end
452
+
453
+ # The circuit breaker answers exactly one question: is the upstream LLM PROVIDER
454
+ # itself broken/down? These three families are NOT provider failures and must never
455
+ # trip a circuit, report provider health, or escalate to another lane:
456
+ # (1) client-side write/disconnect (client socket died) — client_stream_error?
457
+ # (2) SSE assembly / canonical parse / translation (LegionIO's own bugs)
458
+ # (3) daemon/programming errors (NoMethodError/ArgumentError) — internal_error?
459
+ # Provider-agnostic: matches on exception family, never on provider name.
460
+ def non_provider_failure?(err)
461
+ client_stream_error?(err) || sse_translation_error?(err) || internal_error?(err)
462
+ end
463
+
464
+ # A client-side write/disconnect is a clean cancellation, not a provider failure.
465
+ # Distinguished from the broader non_provider_failure? set so the streaming loop can
466
+ # route it to a clean disconnect exit that STILL emits the ledger/metering event
467
+ # (invariant #6) while leaving provider health untouched.
468
+ def client_disconnect_error?(err)
469
+ client_stream_error?(err)
420
470
  end
421
471
 
422
472
  def larger_context_lane_available?(lane:, payload:, **)
@@ -442,7 +492,13 @@ module Legion
442
492
  return :context_overflow if context_overflow_error?(error)
443
493
  return :payload_error if request_payload_error?(error)
444
494
  return :policy_denied if error.is_a?(Legion::LLM::ModelNotAllowed)
445
- return :internal_error if internal_error?(error) # terminal before account_specific
495
+ return :internal_error if internal_error?(error) # daemon bug; terminal before account_specific (G25)
496
+ # non_provider (client-write/disconnect, SSE/parse/translation) is terminal and
497
+ # must be classified BEFORE account_specific/transient: a dead client socket or a
498
+ # LegionIO parse bug must never trip a provider circuit or escalate to another lane
499
+ # (senseless — the upstream is healthy). Checked after :internal_error so daemon
500
+ # programming errors keep their pre-existing, more-specific label (both are terminal).
501
+ return :non_provider if non_provider_failure?(error)
446
502
  return :account_specific if authentication_error?(error) ||
447
503
  config_error?(error) ||
448
504
  account_specific_error?(error)
@@ -460,7 +516,7 @@ module Legion
460
516
 
461
517
  payload[:tried_lanes] << lane[:id]
462
518
 
463
- when :internal_error, :payload_error, :policy_denied
519
+ when :internal_error, :payload_error, :policy_denied, :non_provider
464
520
  raise error
465
521
  when :account_specific
466
522
  # Account/instance-scoped failure: trip the per-instance circuit.
@@ -479,6 +535,7 @@ module Legion
479
535
  rescue StandardError => e
480
536
  raise if request_payload_error?(e)
481
537
  raise if context_overflow_error?(e)
538
+ raise if non_provider_failure?(e) # client-write/disconnect + SSE/parse: terminal, never accumulate
482
539
 
483
540
  handle_exception(e, level: :warn, operation: 'llm.pipeline.classify_and_accumulate_exclusions',
484
541
  lane: lane[:id])
@@ -124,7 +124,7 @@ module Legion
124
124
  gaia_trigger = gaia_debate_trigger?(@enrichments)
125
125
  return true if gaia_trigger
126
126
 
127
- settings_value(:debate, :enabled) == true
127
+ Legion::Settings[:llm][:debate][:enabled] == true
128
128
  end
129
129
 
130
130
  def gaia_debate_trigger?(enrichments)
@@ -193,22 +193,14 @@ module Legion
193
193
 
194
194
  private
195
195
 
196
- def debate_settings
197
- @debate_settings ||= settings_value(:debate) || {}
198
- end
199
-
200
- def debate_setting(key, default = nil)
201
- Legion::Settings[:llm][:debate][key] || default
202
- end
203
-
204
- def settings_value(*keys, default: nil)
205
- Legion::Settings.dig(:llm, *keys) || default
196
+ def debate_setting(key)
197
+ Legion::Settings[:llm][:debate][key]
206
198
  end
207
199
 
208
200
  def resolve_debate_rounds(request)
209
201
  requested = request.extra.is_a?(Hash) ? request.extra[:debate_rounds] : nil
210
- default = debate_setting(:default_rounds, 1)
211
- max = debate_setting(:max_rounds, 3)
202
+ default = debate_setting(:default_rounds)
203
+ max = debate_setting(:max_rounds)
212
204
 
213
205
  rounds = requested ? requested.to_i : default.to_i
214
206
  rounds = 1 if rounds < 1
@@ -238,8 +230,8 @@ module Legion
238
230
  explicit_challenger = debate_setting(:challenger_model)
239
231
  explicit_judge = debate_setting(:judge_model)
240
232
 
241
- request_model = @resolved_model || (request.routing.is_a?(Hash) ? request.routing[:model] : nil) || settings_value(:default_model)
242
- request_provider = @resolved_provider || (request.routing.is_a?(Hash) ? request.routing[:provider] : nil) || settings_value(:default_provider)
233
+ request_model = @resolved_model || (request.routing.is_a?(Hash) ? request.routing[:model] : nil) || Legion::Settings[:llm][:default_model]
234
+ request_provider = @resolved_provider || (request.routing.is_a?(Hash) ? request.routing[:provider] : nil) || Legion::Settings[:llm][:default_provider]
243
235
 
244
236
  advocate_model = explicit_advocate || "#{request_provider}:#{request_model}"
245
237
 
@@ -46,20 +46,8 @@ module Legion
46
46
 
47
47
  private
48
48
 
49
- def rag_settings
50
- @rag_settings ||= settings_value(:rag, default: {})
51
- end
52
-
53
- def rag_setting(key, default = nil)
54
- Legion::Settings.dig(:llm, :rag, key) || default
55
- end
56
-
57
- def settings_value(*keys, default: nil)
58
- Legion::Settings.dig(:llm, *keys) || default
59
- end
60
-
61
49
  def rag_enabled?
62
- rag_setting(:enabled, true)
50
+ Legion::Settings[:llm][:rag][:enabled] == true
63
51
  end
64
52
 
65
53
  def substantive_query?
@@ -142,8 +130,8 @@ module Legion
142
130
  return explicit
143
131
  end
144
132
 
145
- skip_threshold = rag_setting(:utilization_skip_threshold, 0.9)
146
- compact_threshold = rag_setting(:utilization_compact_threshold, 0.7)
133
+ skip_threshold = Legion::Settings[:llm][:rag][:utilization_skip_threshold]
134
+ compact_threshold = Legion::Settings[:llm][:rag][:utilization_compact_threshold]
147
135
 
148
136
  strategy = if utilization >= skip_threshold
149
137
  :none
@@ -166,21 +154,16 @@ module Legion
166
154
 
167
155
  def trivial_query?(query)
168
156
  query = content_text(query)
169
- max_chars = rag_setting(:trivial_max_chars, 20)
170
- configured_patterns = rag_setting(:trivial_patterns)
157
+ max_chars = Legion::Settings[:llm][:rag][:trivial_max_chars]
158
+ patterns = Legion::Settings[:llm][:rag][:trivial_patterns]
171
159
 
172
160
  normalized = query.strip.downcase.gsub(/[^a-z0-9\s]/, '')
173
- patterns = configured_patterns || trivial_patterns
174
161
  return true if patterns.any? { |p| normalized == p }
175
- return true if configured_patterns.nil? && query.length <= max_chars && normalized.split.length <= 1
162
+ return true if query.length <= max_chars && normalized.split.length <= 1
176
163
 
177
164
  false
178
165
  end
179
166
 
180
- def trivial_patterns
181
- rag_setting(:trivial_patterns, %w[ping pong ding test foobar])
182
- end
183
-
184
167
  def apollo_available?
185
168
  return true if defined?(::Legion::Extensions::Apollo::Runners::Knowledge)
186
169
 
@@ -191,9 +174,9 @@ module Legion
191
174
  end
192
175
 
193
176
  def apollo_retrieve(query:, strategy:)
194
- full_limit = rag_setting(:full_limit, 5)
195
- compact_limit = rag_setting(:compact_limit, 3)
196
- confidence = rag_setting(:min_confidence, 0.92)
177
+ full_limit = Legion::Settings[:llm][:rag][:full_limit]
178
+ compact_limit = Legion::Settings[:llm][:rag][:compact_limit]
179
+ confidence = Legion::Settings[:llm][:rag][:min_confidence]
197
180
  limit = apply_gaia_context_limit(strategy == :rag_compact ? compact_limit : full_limit,
198
181
  strategy: strategy)
199
182
  log_step_debug(:rag_context, :apollo_query, strategy: strategy, limit: limit, min_confidence: confidence)
@@ -205,7 +188,7 @@ module Legion
205
188
  end
206
189
 
207
190
  def filter_excluded_source_agents(result)
208
- excluded = Array(rag_setting(:exclude_source_agents, []))
191
+ excluded = Array(Legion::Settings[:llm][:rag][:exclude_source_agents])
209
192
  return result if excluded.empty?
210
193
 
211
194
  entries = Array(result[:entries])
@@ -275,7 +258,7 @@ module Legion
275
258
  end
276
259
 
277
260
  def conversation_history_retrieval_enabled?
278
- rag_setting(:conversation_history_enabled, false) == true
261
+ Legion::Settings[:llm][:rag][:conversation_history_enabled] == true
279
262
  end
280
263
 
281
264
  def merge_apollo_results(*results)
@@ -50,7 +50,7 @@ module Legion
50
50
  defined?(Legion::LLM::Skills::Registry) &&
51
51
  defined?(Legion::LLM) &&
52
52
  Legion::LLM.respond_to?(:settings) &&
53
- settings_value(:skills, :enabled) != false
53
+ Legion::Settings[:llm][:skills][:enabled] != false
54
54
  end
55
55
 
56
56
  def resume_active_skill(conv_id, state)
@@ -131,7 +131,7 @@ module Legion
131
131
  log_step_debug(:skill_injector, :auto_skills_skipped, reason: :max_active_skills)
132
132
  return
133
133
  end
134
- if settings_value(:skills, :auto_inject) == false
134
+ if Legion::Settings[:llm][:skills][:auto_inject] == false
135
135
  log_step_debug(:skill_injector, :auto_skills_skipped, reason: :disabled)
136
136
  return
137
137
  end
@@ -196,14 +196,14 @@ module Legion
196
196
  end
197
197
 
198
198
  def at_max_active_skills?(conv_id)
199
- max = settings_value(:skills, :max_active_skills) || 1
199
+ max = Legion::Settings[:llm][:skills][:max_active_skills]
200
200
  active = Inference::Conversation.skill_state(conv_id) ? 1 : 0
201
201
  active >= max
202
202
  end
203
203
 
204
204
  def skill_disabled?(key)
205
- disabled = Array(settings_value(:skills, :disabled_skills) || [])
206
- enabled = Array(settings_value(:skills, :enabled_skills) || [])
205
+ disabled = Array(Legion::Settings[:llm][:skills][:disabled_skills])
206
+ enabled = Array(Legion::Settings[:llm][:skills][:enabled_skills])
207
207
  return true if disabled.include?(key)
208
208
  return false if enabled.empty?
209
209
 
@@ -224,10 +224,6 @@ module Legion
224
224
  intent: @request.extra&.dig(:intent)
225
225
  }
226
226
  end
227
-
228
- def settings_value(*keys, default: nil)
229
- Legion::Settings.dig(:llm, *keys) || default
230
- end
231
227
  end
232
228
  end
233
229
  end
@@ -205,19 +205,11 @@ module Legion
205
205
  end
206
206
 
207
207
  def trigger_scan_depth
208
- tool_trigger_setting(:scan_depth, 10)
208
+ Legion::Settings[:llm][:tool_trigger][:scan_depth]
209
209
  end
210
210
 
211
211
  def trigger_tool_limit
212
- tool_trigger_setting(:tool_limit, 25)
213
- end
214
-
215
- def tool_trigger_setting(key, default = nil)
216
- Legion::Settings.dig(:llm, :tool_trigger, key) || default
217
- end
218
-
219
- def settings_value(*keys, default: nil)
220
- Legion::Settings.dig(:llm, *keys) || default
212
+ Legion::Settings[:llm][:tool_trigger][:tool_limit]
221
213
  end
222
214
 
223
215
  def log_trigger_match(action, **fields)
@@ -11,16 +11,18 @@ module Legion
11
11
  LATENCY_THRESHOLD_MS = 5000
12
12
  LATENCY_PENALTY_STEP = -10
13
13
 
14
- def initialize(window_seconds: 300, failure_threshold: 3, cooldown_seconds: 60)
15
- @window_seconds = window_seconds
16
- @failure_threshold = failure_threshold
17
- @cooldown_seconds = cooldown_seconds
18
-
19
- @circuits = {}
20
- @latency_window = {}
21
- @handlers = {}
22
- @denied_models = {}
23
- @mutex = Monitor.new
14
+ def initialize(window_seconds: 300, failure_threshold: 3, cooldown_seconds: 60, sweep_interval_seconds: 5)
15
+ @window_seconds = window_seconds
16
+ @failure_threshold = failure_threshold
17
+ @cooldown_seconds = cooldown_seconds
18
+ @sweep_interval_seconds = sweep_interval_seconds
19
+
20
+ @circuits = {}
21
+ @latency_window = {}
22
+ @handlers = {}
23
+ @denied_models = {}
24
+ @last_sweep_at = Time.now
25
+ @mutex = Monitor.new
24
26
 
25
27
  register_default_handlers
26
28
  end
@@ -159,6 +161,35 @@ module Legion
159
161
  end
160
162
  end
161
163
 
164
+ # Advance any :open circuits past their cooldown to :half_open and write
165
+ # the updated health to Inventory lanes. This decouples the open→half_open
166
+ # transition from inbound traffic — without it, an excluded lane never
167
+ # receives reports so circuit_state_for_key never fires, and the circuit
168
+ # stays permanently :open (half-open probe starvation).
169
+ #
170
+ # Called by Router.request_lane on every selection so eligible circuits
171
+ # are already advanced before the soft filter runs. Throttled by
172
+ # sweep_interval_seconds to avoid per-request overhead under high load.
173
+ def sweep_circuits!
174
+ now = Time.now
175
+ return if (now - @last_sweep_at) < @sweep_interval_seconds
176
+
177
+ @last_sweep_at = now
178
+ @mutex.synchronize do
179
+ @circuits.each do |key, circuit|
180
+ next unless circuit[:state] == :open
181
+ next unless circuit[:opened_at]
182
+ next unless (now - circuit[:opened_at]) >= @cooldown_seconds
183
+
184
+ do_transition_circuit!(key: key, to_state: :half_open)
185
+ log.info("[llm][health_tracker] action=sweep_half_open provider=#{key} cooldown_elapsed_s=#{(now - circuit[:opened_at]).round}")
186
+ end
187
+ end
188
+ rescue StandardError => e
189
+ handle_exception(e, level: :warn, handled: true,
190
+ operation: 'health_tracker.sweep_circuits!')
191
+ end
192
+
162
193
  private
163
194
 
164
195
  # Build key for provider/instance pair: "provider/instance"
@@ -57,6 +57,10 @@ module Legion
57
57
  rng: default_rng,
58
58
  **
59
59
  )
60
+ # Advance open circuits past cooldown to half_open before selection
61
+ # so their lanes carry a positive weight and pass the soft filter.
62
+ health_tracker.sweep_circuits!
63
+
60
64
  # 0. GAIA Preferred Provider/Model (OP2)
61
65
  if type == :inference && (tiers.include?(:gaia) || tiers.include?('gaia'))
62
66
  pref_provider = Legion::Settings.dig(:llm, :gaia, :preferred_provider)
@@ -334,9 +338,10 @@ module Legion
334
338
  cb = health[:circuit_breaker] || {}
335
339
 
336
340
  HealthTracker.new(
337
- window_seconds: health.fetch(:window_seconds, 300),
338
- failure_threshold: cb.fetch(:failure_threshold, 3),
339
- cooldown_seconds: cb.fetch(:cooldown_seconds, 60)
341
+ window_seconds: health.fetch(:window_seconds, 300),
342
+ failure_threshold: cb.fetch(:failure_threshold, 3),
343
+ cooldown_seconds: cb.fetch(:cooldown_seconds, 60),
344
+ sweep_interval_seconds: cb.fetch(:sweep_interval_seconds, 5)
340
345
  )
341
346
  end
342
347
  end
@@ -272,7 +272,7 @@ module Legion
272
272
  },
273
273
  health: {
274
274
  window_seconds: 300,
275
- circuit_breaker: { failure_threshold: 3, cooldown_seconds: 60 },
275
+ circuit_breaker: { failure_threshold: 3, cooldown_seconds: 60, sweep_interval_seconds: 5 },
276
276
  latency_penalty_threshold_ms: 5000,
277
277
  budget: { daily_limit_usd: nil, monthly_limit_usd: nil }
278
278
  },
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.15.0'
5
+ VERSION = '0.15.1'
6
6
  end
7
7
  end
data/lib/legion/llm.rb CHANGED
@@ -117,6 +117,7 @@ module Legion
117
117
  Legion::Settings[:llm][:connected] = false
118
118
  @started = false
119
119
  Inventory::Discovery.reset!
120
+ Call::Registry.disconnect_all!
120
121
  Call::Registry.reset!
121
122
  # Clear LLM-level embedding ivars that may have been set via instance_variable_set for testing
122
123
  @can_embed = nil
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.15.0
4
+ version: 0.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity