legion-llm 0.15.0 → 0.15.2

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/lib/legion/llm/api/client_translators/openai_responses.rb +33 -4
  4. data/lib/legion/llm/api/namespaces/openai/responses.rb +22 -4
  5. data/lib/legion/llm/api/native/helpers.rb +1 -1
  6. data/lib/legion/llm/api/native/inference.rb +3 -2
  7. data/lib/legion/llm/api/stream_assembler.rb +34 -6
  8. data/lib/legion/llm/call/registry.rb +18 -0
  9. data/lib/legion/llm/call/structured_output.rb +1 -2
  10. data/lib/legion/llm/context/curator.rb +1 -3
  11. data/lib/legion/llm/inference/executor/context_window.rb +6 -4
  12. data/lib/legion/llm/inference/executor/escalation.rb +61 -4
  13. data/lib/legion/llm/inference/executor/tool_injection.rb +2 -2
  14. data/lib/legion/llm/inference/executor.rb +1 -1
  15. data/lib/legion/llm/inference/native_tool_loop.rb +8 -10
  16. data/lib/legion/llm/inference/steps/classification.rb +1 -2
  17. data/lib/legion/llm/inference/steps/debate.rb +8 -16
  18. data/lib/legion/llm/inference/steps/rag_context.rb +12 -29
  19. data/lib/legion/llm/inference/steps/skill_injector.rb +5 -9
  20. data/lib/legion/llm/inference/steps/sticky_helpers.rb +8 -14
  21. data/lib/legion/llm/inference/steps/tool_calls.rb +2 -2
  22. data/lib/legion/llm/inference/steps/tool_history.rb +9 -7
  23. data/lib/legion/llm/inference/steps/trigger_match.rb +3 -11
  24. data/lib/legion/llm/inventory.rb +1 -3
  25. data/lib/legion/llm/router/health_tracker.rb +41 -10
  26. data/lib/legion/llm/router.rb +8 -3
  27. data/lib/legion/llm/settings/tools.rb +76 -0
  28. data/lib/legion/llm/settings.rb +44 -68
  29. data/lib/legion/llm/tools/confidence.rb +13 -12
  30. data/lib/legion/llm/tools/dispatcher.rb +1 -4
  31. data/lib/legion/llm/tools/interceptors/python_venv.rb +8 -5
  32. data/lib/legion/llm/tools/special.rb +53 -12
  33. data/lib/legion/llm/version.rb +1 -1
  34. data/lib/legion/llm.rb +1 -0
  35. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2620e9731075a26249c5f8bad24610953eac37892f17212d1275a0a66b2497d8
4
- data.tar.gz: f13b135f8276958450683397e26defa62098c892ac50f40642593606e878960d
3
+ metadata.gz: 63f3720eceeff21e7c4d1ca9c13858256b93bd2744d0ae3fc1bd0c7452b40d1a
4
+ data.tar.gz: 686e8646720ba92310f58ab7e675d380003e89e465a7b02711b916b38ee89953
5
5
  SHA512:
6
- metadata.gz: b3d6cd8d597e522fca27a92dc62e27ecbb4ca3237136110544a23bf5f94e2159290c24c9365252693674e4f5898d2bdf654f85a80eab9740bb6e72e010747ef2
7
- data.tar.gz: 2fa40ab507a7725517d27143e8d78f985e67ebebd2314e0811c673f8b83d2d004ed051b98c5aa1aedbc6da04eed0e34ec152292d80c16cc7ffe2e112a235f2f4
6
+ metadata.gz: cf42fe3fc6d6128ca111f2d92488496079496357d03ddf5c8fdf45d1372d245fb19f997a941caff59b13a5c8c4ddf52ac1dcc28ad2aace7f5f5accd1fdb6c48d
7
+ data.tar.gz: be0e5fbd569a12eb65a0952c58ee91a42f06955858a073c52e83ae1e2165fec4f595dd2677b3287ffc1b5cf99fb53ed8f8905849876e93cbb21b100ea4f47d02
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.15.2] - 2026-08-04
4
+
5
+ ### Fixed
6
+ - **Runtime tool deadlines now terminate the process tree.** Replaced `Timeout.timeout` around `Open3.capture2e`, which raised a timeout but then blocked while Open3 joined the still-running child. Runtime tools now launch in a dedicated process group, send `TERM` at the deadline, wait the configured grace period, send `KILL` when needed, and reap the process before returning the timeout result.
7
+ - **Codex Responses tool continuations preserve one assistant turn.** Codex orders a turn as function calls, assistant narration, then function-call outputs. The Responses normalizers previously split that into consecutive assistant messages and wedged narration between each call and its result, causing thinking-enabled providers to narrate and stop instead of issuing the next tool call. Assistant text and pending calls now stay on one canonical assistant message with adjacent tool results.
8
+
9
+ ### Changed
10
+ - **Tool policy now has one settings subtree.** Added `Legion::LLM::Settings::Tools.defaults` at `llm.tools`, with runtime timeouts defaulting to 1 second, capped at 10 seconds, and a 1 second termination grace. Consolidated the existing tool loop, dispatch, trigger, sticky, confidence, logging, history, compaction, and Python environment policy under the same subtree, removing inline shadow defaults and operational constants from tool paths.
11
+
12
+ ## [0.15.1] - 2026-08-01
13
+
14
+ ### Fixed
15
+ - **`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`.
16
+ - **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.
17
+ - **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.
18
+ - **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).
19
+
3
20
  ## [0.15.0] - 2026-07-24
4
21
 
5
22
  ### Changed
@@ -592,8 +592,27 @@ module Legion
592
592
  flush_pending_tool_calls(messages, pending_tool_calls)
593
593
  messages << { role: 'tool', tool_call_id: item[:call_id], content: item[:output].to_s }
594
594
  else
595
- flush_pending_tool_calls(messages, pending_tool_calls)
596
595
  role = item[:role]&.to_s
596
+
597
+ # SSOT: an assistant `message` item that arrives while tool calls
598
+ # are still pending is the SAME assistant turn as those calls
599
+ # (Codex/Responses orders: function_call(s) → assistant message →
600
+ # function_call_output(s)). Merge the text as the assistant
601
+ # message's content and flush ONE combined assistant message
602
+ # (content + tool_calls), so the tool results that follow stay
603
+ # adjacent to their tool_calls. Emitting a separate assistant text
604
+ # message here splits one turn into two and wedges narration
605
+ # between a tool_call and its result — a malformed chat/completions
606
+ # history that makes thinking-enabled models narrate instead of
607
+ # calling the next tool (the "dead stop").
608
+ if role == 'assistant' && !pending_tool_calls.empty?
609
+ content = item[:content]
610
+ content = content.to_s if content && !content.is_a?(Array)
611
+ flush_pending_tool_calls(messages, pending_tool_calls, assistant_content: content)
612
+ next
613
+ end
614
+
615
+ flush_pending_tool_calls(messages, pending_tool_calls)
597
616
  next unless role
598
617
 
599
618
  role = 'system' if role == 'developer'
@@ -614,8 +633,13 @@ module Legion
614
633
  # ToolCall(name: nil) — provider translators then drop the call,
615
634
  # leaving an orphan tool_result that Bedrock rejects with
616
635
  # "unexpected tool_use_id in tool_result".
617
- def flush_pending_tool_calls(messages, pending)
618
- return if pending.empty?
636
+ def flush_pending_tool_calls(messages, pending, assistant_content: nil)
637
+ if pending.empty?
638
+ # No pending calls but an assistant text turn wants flushing — emit it
639
+ # so a trailing/standalone assistant message is never dropped.
640
+ messages << { role: 'assistant', content: assistant_content } if assistant_content
641
+ return
642
+ end
619
643
 
620
644
  tool_calls = pending.map do |tc|
621
645
  args = tc[:arguments]
@@ -630,8 +654,13 @@ module Legion
630
654
  last = messages.last
631
655
  if last && last[:role] == 'assistant' && !last.key?(:tool_calls)
632
656
  last[:tool_calls] = tool_calls
657
+ # Text arriving AFTER the calls (Codex order) belongs to this same turn.
658
+ last[:content] = assistant_content if assistant_content && last[:content].to_s.empty?
633
659
  else
634
- messages << { role: 'assistant', content: '', tool_calls: tool_calls }
660
+ # assistant_content carries text that arrived AFTER the calls in the
661
+ # same turn — keep it ON the tool_calls message so the turn stays one
662
+ # message and the following tool results remain adjacent.
663
+ messages << { role: 'assistant', content: assistant_content.to_s, tool_calls: tool_calls }
635
664
  end
636
665
  pending.clear
637
666
  end
@@ -214,8 +214,23 @@ module Legion
214
214
  flush_pending(messages, pending)
215
215
  messages << { role: 'tool', tool_call_id: item[:call_id], content: item[:output].to_s }
216
216
  else
217
- flush_pending(messages, pending)
218
217
  role = item[:role]&.to_s
218
+
219
+ # SSOT: an assistant `message` arriving while calls are pending is
220
+ # the SAME turn as those calls (Codex order: function_call(s) →
221
+ # assistant message → function_call_output(s)). Merge its text onto
222
+ # the flushed assistant tool_calls message so the turn stays ONE
223
+ # message and the tool results that follow stay adjacent to their
224
+ # calls. Splitting it out wedges narration between a tool_call and
225
+ # its result — the malformed history behind the dead stop.
226
+ if role == 'assistant' && !pending.empty?
227
+ content = item[:content]
228
+ content = content.to_s if content && !content.is_a?(Array)
229
+ flush_pending(messages, pending, assistant_content: content)
230
+ next
231
+ end
232
+
233
+ flush_pending(messages, pending)
219
234
  next unless role
220
235
 
221
236
  role = 'system' if role == 'developer'
@@ -229,12 +244,15 @@ module Legion
229
244
  messages
230
245
  end
231
246
 
232
- def self.flush_pending(messages, pending)
233
- return if pending.empty?
247
+ def self.flush_pending(messages, pending, assistant_content: nil)
248
+ if pending.empty?
249
+ messages << { role: 'assistant', content: assistant_content } if assistant_content
250
+ return
251
+ end
234
252
 
235
253
  messages << {
236
254
  role: 'assistant',
237
- content: '',
255
+ content: assistant_content.to_s,
238
256
  tool_calls: pending.map do |tc|
239
257
  { id: tc[:id], type: 'function', function: { name: tc[:name], arguments: tc[:arguments] } }
240
258
  end
@@ -67,7 +67,7 @@ module Legion
67
67
  case ref
68
68
  when 'sh'
69
69
  cmd = kwargs[:command] || kwargs[:cmd] || kwargs.values.first.to_s
70
- log.warn("[llm][native] client_tool=sh command=#{cmd[0, 120]}")
70
+ log.warn("[llm][native] client_tool=sh command=#{cmd[0, Legion::Settings[:llm][:tools][:command_log_chars]]}")
71
71
  output, status = ::Open3.capture2e(cmd, chdir: Dir.pwd)
72
72
  "exit=#{status.exitstatus}\n#{output}"
73
73
  when 'file_read'
@@ -80,8 +80,9 @@ module Legion
80
80
  end
81
81
 
82
82
  client_tool_names = tool_declarations.map(&:name)
83
- client_tool_summary = client_tool_names.empty? ? 'none' : client_tool_names.first(30).join(',')
84
- client_tool_summary = "#{client_tool_summary},+#{client_tool_names.size - 30}more" if client_tool_names.size > 30
83
+ name_limit = Legion::Settings[:llm][:tools][:name_log_limit]
84
+ client_tool_summary = client_tool_names.empty? ? 'none' : client_tool_names.first(name_limit).join(',')
85
+ client_tool_summary = "#{client_tool_summary},+#{client_tool_names.size - name_limit}more" if client_tool_names.size > name_limit
85
86
  log.info(
86
87
  "[llm][api][tools] action=client_tools_built request_id=#{request_id} " \
87
88
  "conversation_id=#{conversation_id || 'none'} count=#{tool_declarations.size} names=#{client_tool_summary}"
@@ -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)
@@ -125,12 +125,11 @@ module Legion
125
125
  .strip
126
126
  end
127
127
 
128
+ # -- substring check (each model
128
129
  def supports_response_format?(model)
129
- # rubocop:disable Style/ArrayIntersect -- substring check (each model
130
130
  # fragment `include?`d in the model name), NOT array intersection.
131
131
  # `intersect?` raises TypeError on the String arg.
132
132
  SCHEMA_CAPABLE_MODELS.any? { |m| model.to_s.include?(m) }
133
- # rubocop:enable Style/ArrayIntersect
134
133
  end
135
134
 
136
135
  def retry_enabled?
@@ -619,13 +619,11 @@ module Legion
619
619
  clarification_signals = ['clarif', 'what do you mean', 'i see', 'understood', 'got it', 'correct', 'exactly', 'yes', 'right', 'agree']
620
620
  conclusion_signals = ['in summary', 'to summarize', 'in conclusion', 'therefore', 'so to answer', 'the answer is']
621
621
 
622
- # rubocop:disable Style/ArrayIntersect -- these are substring checks
622
+ # -- these are substring checks
623
623
  # (signal `include?` against a String), NOT array intersection. The
624
624
  # cop's `intersect?` suggestion raises TypeError on a String arg.
625
625
  has_clarification = contents.any? { |c| clarification_signals.any? { |s| c.include?(s) } }
626
626
  has_conclusion = contents.last.length < 500 || conclusion_signals.any? { |s| contents.last.include?(s) }
627
- # rubocop:enable Style/ArrayIntersect
628
-
629
627
  has_clarification && has_conclusion
630
628
  end
631
629
 
@@ -76,14 +76,16 @@ module Legion
76
76
 
77
77
  filtered = messages.reject do |msg|
78
78
  role = (msg[:role] || msg['role']).to_s
79
- role == 'tool' && (msg[:content] || msg['content']).to_s.length > 500
79
+ role == 'tool' && (msg[:content] || msg['content']).to_s.length >
80
+ Legion::Settings[:llm][:tools][:context_compaction][:threshold_chars]
80
81
  end
81
82
  messages = filtered.map do |msg|
82
83
  role = (msg[:role] || msg['role']).to_s
83
84
  next msg unless role == 'tool'
84
85
 
85
86
  content = (msg[:content] || msg['content']).to_s
86
- content.length > 200 ? msg.merge(content: "#{content[0, 200]}\n[compacted]") : msg
87
+ result_chars = Legion::Settings[:llm][:tools][:context_compaction][:result_chars]
88
+ content.length > result_chars ? msg.merge(content: "#{content[0, result_chars]}\n[compacted]") : msg
87
89
  end
88
90
 
89
91
  return messages if estimate_message_tokens(messages) <= target_tokens
@@ -183,7 +185,7 @@ module Legion
183
185
  # Pure oversized-tool-result trim. Shared by trim_oversized_tool_results
184
186
  # (which adds logging) and reduce_messages_for_dispatch.
185
187
  def trim_oversized_tool_results_pure(messages)
186
- max_chars = Legion::Settings[:llm][:tool_result_max_dispatch_chars].to_i
188
+ max_chars = Legion::Settings[:llm][:tools][:result_max_dispatch_chars]
187
189
  return messages unless max_chars.positive?
188
190
 
189
191
  preserve_after = last_user_message_index(messages)
@@ -205,7 +207,7 @@ module Legion
205
207
  trimmed_count = messages.zip(result).count { |before, after| before != after }
206
208
  if trimmed_count.positive?
207
209
  log.info "[llm][executor] action=trim_tool_results request_id=#{@request.id} trimmed=#{trimmed_count} " \
208
- "max_chars=#{Legion::Settings[:llm][:tool_result_max_dispatch_chars].to_i}"
210
+ "max_chars=#{Legion::Settings[:llm][:tools][:result_max_dispatch_chars]}"
209
211
  end
210
212
  result
211
213
  end
@@ -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])
@@ -96,7 +96,7 @@ module Legion
96
96
  return value if [true, false].include?(value)
97
97
  end
98
98
 
99
- Legion::Settings.dig(:llm, :tool_trigger, :client_tool_passthrough) == true
99
+ Legion::Settings.dig(:llm, :tools, :trigger, :client_tool_passthrough) == true
100
100
  end
101
101
 
102
102
  def client_tool_passthrough_allowed?(definition)
@@ -111,7 +111,7 @@ module Legion
111
111
  end
112
112
 
113
113
  def client_tool_passthrough_list(key)
114
- Array(Legion::Settings.dig(:llm, :tool_trigger, key)).flat_map do |entry|
114
+ Array(Legion::Settings.dig(:llm, :tools, :trigger, key)).flat_map do |entry|
115
115
  client_tool_policy_variants(entry)
116
116
  end.uniq
117
117
  end
@@ -216,7 +216,7 @@ module Legion
216
216
  def registry_tool_limit
217
217
  return nil unless local_provider?
218
218
 
219
- raw_limit = Legion::Settings.dig(:llm, :tool_trigger, :local_tool_limit)
219
+ raw_limit = Legion::Settings.dig(:llm, :tools, :trigger, :local_tool_limit)
220
220
  limit = raw_limit.to_i
221
221
  limit.positive? ? limit : nil
222
222
  end
@@ -38,8 +38,7 @@ module Legion
38
38
 
39
39
  def execute_native_tool_loop # rubocop:disable Metrics/AbcSize
40
40
  messages = native_dispatch_messages.dup
41
- max_rounds = Legion::Settings[:llm][:max_tool_rounds].to_i
42
- max_rounds = 200 unless max_rounds.positive?
41
+ max_rounds = Legion::Settings[:llm][:tools][:max_rounds]
43
42
  round = 0
44
43
  # Track which (tool_name, args) pairs LegionIO executed,
45
44
  # and how many consecutive rounds ended in all Legion-tool failures.
@@ -69,7 +68,7 @@ module Legion
69
68
  "result_text_length=#{result_text.to_s.length} " \
70
69
  "result_text=#{result_text.to_s.inspect} " \
71
70
  "thinking_length=#{result_thinking.to_s.length} " \
72
- "thinking_first_200=#{result_thinking.to_s[0, 200].inspect} " \
71
+ "thinking_preview=#{result_thinking.to_s[0, Legion::Settings[:llm][:tools][:thinking_log_chars]].inspect} " \
73
72
  "stop_reason=#{result.respond_to?(:stop_reason) ? result.stop_reason : 'n/a'}"
74
73
  log.debug "[llm][executor] action=native_tool_loop.complete rounds=#{round} reason=no_tool_calls"
75
74
  @last_tool_loop_messages = messages
@@ -135,7 +134,7 @@ module Legion
135
134
  failed_names = round_results.map { |e| e[:tool_call][:name] }.join(',')
136
135
  log.warn "[llm][native_tool_loop] action=all_legion_executed_tools_failed round=#{round} " \
137
136
  "consecutive_failures=#{consecutive_failures} tools=#{failed_names}"
138
- if consecutive_failures >= 2
137
+ if consecutive_failures >= Legion::Settings[:llm][:tools][:consecutive_failure_limit]
139
138
  log.warn "[llm][native_tool_loop] action=legion_tool_failure_loop_broken consecutive_failures=#{consecutive_failures}"
140
139
  return client_passthrough_tool_loop_result(result, client_calls, round)
141
140
  end
@@ -156,8 +155,7 @@ module Legion
156
155
 
157
156
  def execute_native_streaming_tool_loop(&block) # rubocop:disable Metrics/AbcSize
158
157
  messages = native_dispatch_messages.dup
159
- max_rounds = Legion::Settings[:llm][:max_tool_rounds].to_i
160
- max_rounds = 200 unless max_rounds.positive?
158
+ max_rounds = Legion::Settings[:llm][:tools][:max_rounds]
161
159
  round = 0
162
160
  executed_calls = {}
163
161
  consecutive_failures = 0
@@ -187,7 +185,7 @@ module Legion
187
185
  "result_text_length=#{result_text.to_s.length} " \
188
186
  "result_text=#{result_text.to_s.inspect} " \
189
187
  "thinking_length=#{result_thinking.to_s.length} " \
190
- "thinking_first_200=#{result_thinking.to_s[0, 200].inspect} " \
188
+ "thinking_preview=#{result_thinking.to_s[0, Legion::Settings[:llm][:tools][:thinking_log_chars]].inspect} " \
191
189
  "stop_reason=#{result.respond_to?(:stop_reason) ? result.stop_reason : 'n/a'}"
192
190
  log.debug "[llm][executor] action=native_streaming_tool_loop.complete rounds=#{round} reason=no_tool_calls"
193
191
  @last_tool_loop_messages = messages
@@ -244,7 +242,7 @@ module Legion
244
242
  failed_names = round_results.map { |e| e[:tool_call][:name] }.join(',')
245
243
  log.warn "[llm][native_tool_loop] action=all_legion_executed_tools_failed round=#{round} " \
246
244
  "consecutive_failures=#{consecutive_failures} tools=#{failed_names}"
247
- if consecutive_failures >= 2
245
+ if consecutive_failures >= Legion::Settings[:llm][:tools][:consecutive_failure_limit]
248
246
  log.warn "[llm][native_tool_loop] action=legion_tool_failure_loop_broken consecutive_failures=#{consecutive_failures}"
249
247
  return client_passthrough_tool_loop_result(result, client_calls, round)
250
248
  end
@@ -267,7 +265,7 @@ module Legion
267
265
  # the provider adapter handles the wire format internally.
268
266
 
269
267
  def split_tool_calls_by_cap(tool_calls, round)
270
- max_per_turn = Legion::Settings[:llm][:max_tool_calls_per_turn].to_i
268
+ max_per_turn = Legion::Settings[:llm][:tools][:max_calls_per_turn]
271
269
  return [tool_calls, []] unless max_per_turn.positive? && tool_calls.size > max_per_turn
272
270
 
273
271
  log.warn "[llm][native_tool_loop] action=cap_per_turn round=#{round} " \
@@ -326,7 +324,7 @@ module Legion
326
324
  ext.translator.capabilities[:forced_tool_choice]
327
325
 
328
326
  text = latest_user_text.to_s.downcase
329
- return if text.empty? || text.length > 500
327
+ return if text.empty? || text.length > Legion::Settings[:llm][:tools][:explicit_choice_max_chars]
330
328
 
331
329
  match = native_dispatch_tools.keys.map(&:to_s).sort_by { |tool_name| -tool_name.length }.find do |tool_name|
332
330
  explicit_tool_name_mentioned?(text, tool_name)
@@ -110,11 +110,10 @@ module Legion
110
110
  patterns << name if text.match?(regex)
111
111
  end
112
112
 
113
- # rubocop:disable Style/ArrayIntersect -- substring check (each keyword
113
+ # -- substring check (each keyword
114
114
  # `include?`d in the text), NOT array intersection. `intersect?` raises
115
115
  # TypeError on the String arg.
116
116
  phi_found = PHI_KEYWORDS.any? { |kw| text.downcase.include?(kw) }
117
- # rubocop:enable Style/ArrayIntersect
118
117
  patterns << :phi_keyword if phi_found
119
118
  if text.match?(EMAIL_PATTERN) && (standalone_email_pii? || phi_found || patterns.any?)
120
119
  patterns.delete(:email)
@@ -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
@@ -217,7 +209,7 @@ module Legion
217
209
 
218
210
  def extract_question(request)
219
211
  request.messages.select { |m| m[:role].to_s == 'user' }
220
- .last&.dig(:content) || ''
212
+ .last&.dig(:content) || ''
221
213
  end
222
214
 
223
215
  def extract_content(response)
@@ -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