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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/lib/legion/llm/api/client_translators/openai_responses.rb +33 -4
- data/lib/legion/llm/api/namespaces/openai/responses.rb +22 -4
- data/lib/legion/llm/api/native/helpers.rb +1 -1
- data/lib/legion/llm/api/native/inference.rb +3 -2
- data/lib/legion/llm/api/stream_assembler.rb +34 -6
- data/lib/legion/llm/call/registry.rb +18 -0
- data/lib/legion/llm/call/structured_output.rb +1 -2
- data/lib/legion/llm/context/curator.rb +1 -3
- data/lib/legion/llm/inference/executor/context_window.rb +6 -4
- data/lib/legion/llm/inference/executor/escalation.rb +61 -4
- data/lib/legion/llm/inference/executor/tool_injection.rb +2 -2
- data/lib/legion/llm/inference/executor.rb +1 -1
- data/lib/legion/llm/inference/native_tool_loop.rb +8 -10
- data/lib/legion/llm/inference/steps/classification.rb +1 -2
- data/lib/legion/llm/inference/steps/debate.rb +8 -16
- data/lib/legion/llm/inference/steps/rag_context.rb +12 -29
- data/lib/legion/llm/inference/steps/skill_injector.rb +5 -9
- data/lib/legion/llm/inference/steps/sticky_helpers.rb +8 -14
- data/lib/legion/llm/inference/steps/tool_calls.rb +2 -2
- data/lib/legion/llm/inference/steps/tool_history.rb +9 -7
- data/lib/legion/llm/inference/steps/trigger_match.rb +3 -11
- data/lib/legion/llm/inventory.rb +1 -3
- data/lib/legion/llm/router/health_tracker.rb +41 -10
- data/lib/legion/llm/router.rb +8 -3
- data/lib/legion/llm/settings/tools.rb +76 -0
- data/lib/legion/llm/settings.rb +44 -68
- data/lib/legion/llm/tools/confidence.rb +13 -12
- data/lib/legion/llm/tools/dispatcher.rb +1 -4
- data/lib/legion/llm/tools/interceptors/python_venv.rb +8 -5
- data/lib/legion/llm/tools/special.rb +53 -12
- data/lib/legion/llm/version.rb +1 -1
- data/lib/legion/llm.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 63f3720eceeff21e7c4d1ca9c13858256b93bd2744d0ae3fc1bd0c7452b40d1a
|
|
4
|
+
data.tar.gz: 686e8646720ba92310f58ab7e675d380003e89e465a7b02711b916b38ee89953
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
84
|
-
client_tool_summary =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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 >
|
|
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
|
-
|
|
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][:
|
|
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][:
|
|
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
|
-
|
|
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, :
|
|
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, :
|
|
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, :
|
|
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][:
|
|
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
|
-
"
|
|
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 >=
|
|
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][:
|
|
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
|
-
"
|
|
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 >=
|
|
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][:
|
|
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 >
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
197
|
-
|
|
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
|
|
211
|
-
max = debate_setting(:max_rounds
|
|
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
|
-
|
|
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) ||
|
|
242
|
-
request_provider = @resolved_provider || (request.routing.is_a?(Hash) ? request.routing[:provider] : nil) ||
|
|
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
|
|