legion-llm 0.15.1 → 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 +9 -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/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/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 +1 -1
- data/lib/legion/llm/inference/steps/rag_context.rb +1 -1
- 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 -3
- data/lib/legion/llm/inventory.rb +1 -3
- data/lib/legion/llm/settings/tools.rb +76 -0
- data/lib/legion/llm/settings.rb +43 -67
- 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
- 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,14 @@
|
|
|
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
|
+
|
|
3
12
|
## [0.15.1] - 2026-08-01
|
|
4
13
|
|
|
5
14
|
### Fixed
|
|
@@ -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}"
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -286,7 +286,7 @@ module Legion
|
|
|
286
286
|
|
|
287
287
|
def extract_query
|
|
288
288
|
@request.messages.select { |m| m[:role].to_s == 'user' }
|
|
289
|
-
|
|
289
|
+
.then { |messages| content_text(message_content(messages.last)) }
|
|
290
290
|
end
|
|
291
291
|
|
|
292
292
|
def message_content(message)
|
|
@@ -12,37 +12,31 @@ module Legion
|
|
|
12
12
|
private
|
|
13
13
|
|
|
14
14
|
def sticky_enabled?
|
|
15
|
-
sticky_setting(:enabled
|
|
15
|
+
sticky_setting(:enabled) != false
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def trigger_sticky_turns
|
|
19
|
-
sticky_setting(:trigger_turns
|
|
19
|
+
sticky_setting(:trigger_turns)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def execution_sticky_tool_calls
|
|
23
|
-
sticky_setting(:execution_tool_calls
|
|
23
|
+
sticky_setting(:execution_tool_calls)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def max_history_entries
|
|
27
|
-
sticky_setting(:max_history_entries
|
|
27
|
+
sticky_setting(:max_history_entries)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def max_result_length
|
|
31
|
-
sticky_setting(:max_result_length
|
|
31
|
+
sticky_setting(:max_result_length)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def max_args_length
|
|
35
|
-
sticky_setting(:max_args_length
|
|
35
|
+
sticky_setting(:max_args_length)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def sticky_setting(key
|
|
39
|
-
|
|
40
|
-
value.nil? ? default : value
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def settings_value(*keys, default: nil)
|
|
44
|
-
value = Legion::Settings.dig(:llm, *keys)
|
|
45
|
-
value.nil? ? default : value
|
|
38
|
+
def sticky_setting(key)
|
|
39
|
+
Legion::Settings[:llm][:tools][:sticky][key]
|
|
46
40
|
end
|
|
47
41
|
end
|
|
48
42
|
end
|
|
@@ -94,7 +94,7 @@ module Legion
|
|
|
94
94
|
@timeline.record(
|
|
95
95
|
category: :tool, key: "tool:result:#{tc[:name] || tc['name']}",
|
|
96
96
|
exchange_id: tool_exchange_id, direction: :inbound,
|
|
97
|
-
detail: result[:result].to_s[0
|
|
97
|
+
detail: result[:result].to_s[0, Legion::Settings[:llm][:tools][:result_detail_chars]].to_s,
|
|
98
98
|
from: "tool:#{tc[:name] || tc['name']}", to: 'pipeline',
|
|
99
99
|
data: {
|
|
100
100
|
tool_call_id: tool_call_id,
|
|
@@ -325,7 +325,7 @@ module Legion
|
|
|
325
325
|
counts.map { |key, count| "#{key}:#{count}" }.join(',')
|
|
326
326
|
end
|
|
327
327
|
|
|
328
|
-
def format_tool_names(names, limit =
|
|
328
|
+
def format_tool_names(names, limit = Legion::Settings[:llm][:tools][:name_log_limit])
|
|
329
329
|
names = Array(names).map(&:to_s).reject(&:empty?)
|
|
330
330
|
return 'none' if names.empty?
|
|
331
331
|
|
|
@@ -57,13 +57,14 @@ module Legion
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def summarize_result(result_str, error)
|
|
60
|
-
|
|
60
|
+
history = Legion::Settings[:llm][:tools][:history]
|
|
61
|
+
return "error: #{result_str.to_s[0, history[:error_summary_chars]]}" if error
|
|
61
62
|
|
|
62
63
|
str = result_str.to_s
|
|
63
64
|
# If result is JSON but too long/truncated, avoid trying to parse incomplete JSON.
|
|
64
65
|
# Large tool results (e.g. legion_list_special_tools) can be 2KB+ and get
|
|
65
66
|
# truncated, causing parse errors that add noise to the logs.
|
|
66
|
-
if str.length >
|
|
67
|
+
if str.length > history[:large_json_threshold_chars] && str.start_with?('{')
|
|
67
68
|
# Return a summary for large JSON results without parsing
|
|
68
69
|
keys = extract_json_top_keys(str)
|
|
69
70
|
return keys.empty? ? "large JSON result (#{str.length} chars)" : "JSON with keys: #{keys.join(', ')}"
|
|
@@ -72,7 +73,7 @@ module Legion
|
|
|
72
73
|
begin
|
|
73
74
|
parsed = Legion::JSON.load(str)
|
|
74
75
|
rescue StandardError
|
|
75
|
-
return str[0,
|
|
76
|
+
return str[0, history[:summary_chars]]
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
if parsed.is_a?(Array)
|
|
@@ -85,10 +86,10 @@ module Legion
|
|
|
85
86
|
elsif parsed[:result].is_a?(Hash) && parsed[:result][:number]
|
|
86
87
|
"##{parsed[:result][:number]} at #{parsed[:result][:html_url]}"
|
|
87
88
|
else
|
|
88
|
-
str[0,
|
|
89
|
+
str[0, history[:summary_chars]]
|
|
89
90
|
end
|
|
90
91
|
else
|
|
91
|
-
str[0,
|
|
92
|
+
str[0, history[:summary_chars]]
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
|
|
@@ -102,7 +103,8 @@ module Legion
|
|
|
102
103
|
bracket_depth = 0
|
|
103
104
|
in_string = false
|
|
104
105
|
escaped = false
|
|
105
|
-
|
|
106
|
+
history = Legion::Settings[:llm][:tools][:history]
|
|
107
|
+
char_limit = [str.length, history[:json_scan_chars]].min
|
|
106
108
|
|
|
107
109
|
(0...char_limit).each do |i|
|
|
108
110
|
ch = str[i]
|
|
@@ -124,7 +126,7 @@ module Legion
|
|
|
124
126
|
in_key = false
|
|
125
127
|
keys << buffer unless buffer.empty?
|
|
126
128
|
buffer = +''
|
|
127
|
-
break if keys.size >=
|
|
129
|
+
break if keys.size >= history[:json_key_limit]
|
|
128
130
|
else
|
|
129
131
|
buffer << ch
|
|
130
132
|
end
|
|
@@ -205,11 +205,11 @@ module Legion
|
|
|
205
205
|
end
|
|
206
206
|
|
|
207
207
|
def trigger_scan_depth
|
|
208
|
-
Legion::Settings[:llm][:
|
|
208
|
+
Legion::Settings[:llm][:tools][:trigger][:scan_depth]
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
def trigger_tool_limit
|
|
212
|
-
Legion::Settings[:llm][:
|
|
212
|
+
Legion::Settings[:llm][:tools][:trigger][:tool_limit]
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
def log_trigger_match(action, **fields)
|
|
@@ -225,7 +225,7 @@ module Legion
|
|
|
225
225
|
def format_trigger_log_value(value)
|
|
226
226
|
case value
|
|
227
227
|
when Array
|
|
228
|
-
value.map(&:to_s).first(
|
|
228
|
+
value.map(&:to_s).first(Legion::Settings[:llm][:tools][:trigger][:log_name_limit]).join(',')
|
|
229
229
|
else
|
|
230
230
|
value
|
|
231
231
|
end
|
data/lib/legion/llm/inventory.rb
CHANGED
|
@@ -227,8 +227,7 @@ module Legion
|
|
|
227
227
|
sets = policy_sets_for(provider: provider)
|
|
228
228
|
|
|
229
229
|
# Whitelist takes precedence over blacklist (M2). Substring match —
|
|
230
|
-
# a model is denied if NO whitelist pattern is contained in its name.
|
|
231
|
-
# rubocop:disable Style/ArrayIntersect -- substring checks (patterns
|
|
230
|
+
# a model is denied if NO whitelist pattern is contained in its name. # -- substring checks (patterns
|
|
232
231
|
# `include?`d in the model name), NOT array intersection; `intersect?`
|
|
233
232
|
# raises TypeError on the String arg.
|
|
234
233
|
if sets[:whitelist]
|
|
@@ -238,7 +237,6 @@ module Legion
|
|
|
238
237
|
end
|
|
239
238
|
|
|
240
239
|
sets[:blacklist]&.any? { |p| model.include?(p) } || false
|
|
241
|
-
# rubocop:enable Style/ArrayIntersect
|
|
242
240
|
end
|
|
243
241
|
|
|
244
242
|
# Specificity cascade — same precedence as lex-llm Provider#model_whitelist:
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Settings
|
|
6
|
+
module Tools
|
|
7
|
+
extend Legion::Logging::Helper
|
|
8
|
+
|
|
9
|
+
def self.defaults
|
|
10
|
+
{
|
|
11
|
+
max_rounds: 200,
|
|
12
|
+
max_calls_per_turn: 100,
|
|
13
|
+
consecutive_failure_limit: 2,
|
|
14
|
+
explicit_choice_max_chars: 500,
|
|
15
|
+
error_log_chars: 500,
|
|
16
|
+
result_detail_chars: 100,
|
|
17
|
+
result_max_dispatch_chars: 5_000,
|
|
18
|
+
name_log_limit: 30,
|
|
19
|
+
command_log_chars: 120,
|
|
20
|
+
thinking_log_chars: 200,
|
|
21
|
+
python_venv_dir: '~/.legionio/python',
|
|
22
|
+
timeouts: {
|
|
23
|
+
default: 1_000,
|
|
24
|
+
max: 10_000,
|
|
25
|
+
terminate_grace: 1_000
|
|
26
|
+
},
|
|
27
|
+
trigger: {
|
|
28
|
+
scan_depth: 10,
|
|
29
|
+
tool_limit: 25,
|
|
30
|
+
local_tool_limit: 50,
|
|
31
|
+
log_name_limit: 20,
|
|
32
|
+
client_tool_passthrough: true,
|
|
33
|
+
client_tool_passthrough_whitelist: [],
|
|
34
|
+
client_tool_passthrough_blacklist: [
|
|
35
|
+
'sudo', 'visudo', 'su', 'legion', 'legionio', 'legionio do', 'legionio/legion',
|
|
36
|
+
'computer_use_session', 'computer_use_control', 'computer_use_session_info',
|
|
37
|
+
'computer_use_session_message', 'plugin__aithena__recall', 'plugin__aithena__remember',
|
|
38
|
+
'plugin__aithena__skill_search', 'plugin__aithena__skill_feedback', 'plugin__aithena__memory_stats',
|
|
39
|
+
'plugin__cron__create', 'plugin__cron__list', 'plugin__cron__get', 'plugin__cron__update',
|
|
40
|
+
'plugin__cron__delete', 'plugin__cron__get_history', 'plugin__cron__run_now', 'plugin__cron__stop'
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
history: {
|
|
44
|
+
error_summary_chars: 100,
|
|
45
|
+
large_json_threshold_chars: 2_000,
|
|
46
|
+
summary_chars: 200,
|
|
47
|
+
json_scan_chars: 500,
|
|
48
|
+
json_key_limit: 3
|
|
49
|
+
},
|
|
50
|
+
context_compaction: {
|
|
51
|
+
threshold_chars: 500,
|
|
52
|
+
result_chars: 200
|
|
53
|
+
},
|
|
54
|
+
sticky: {
|
|
55
|
+
enabled: true,
|
|
56
|
+
trigger_turns: 2,
|
|
57
|
+
execution_tool_calls: 5,
|
|
58
|
+
max_history_entries: 50,
|
|
59
|
+
max_result_length: 2_000,
|
|
60
|
+
max_args_length: 500
|
|
61
|
+
},
|
|
62
|
+
confidence: {
|
|
63
|
+
override_threshold: 0.8,
|
|
64
|
+
shadow_threshold: 0.5,
|
|
65
|
+
success_delta: 0.05,
|
|
66
|
+
failure_delta: -0.1,
|
|
67
|
+
apollo_limit: 100,
|
|
68
|
+
apollo_confidence_multiplier: 0.8,
|
|
69
|
+
cache_ttl_seconds: 3_600
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/legion/llm/settings.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'legion/logging/helper'
|
|
4
|
+
require 'legion/llm/settings/tools'
|
|
4
5
|
require 'legion/settings'
|
|
5
6
|
|
|
6
7
|
module Legion
|
|
@@ -8,65 +9,51 @@ module Legion
|
|
|
8
9
|
module Settings
|
|
9
10
|
extend Legion::Logging::Helper
|
|
10
11
|
|
|
11
|
-
CLIENT_TOOL_PASSTHROUGH_BLACKLIST_DEFAULT = [
|
|
12
|
-
'sudo', 'visudo', 'su', 'legion', 'legionio', 'legionio do', 'legionio/legion',
|
|
13
|
-
'computer_use_session', 'computer_use_control', 'computer_use_session_info',
|
|
14
|
-
'computer_use_session_message', 'plugin__aithena__recall', 'plugin__aithena__remember',
|
|
15
|
-
'plugin__aithena__skill_search', 'plugin__aithena__skill_feedback', 'plugin__aithena__memory_stats',
|
|
16
|
-
'plugin__cron__create', 'plugin__cron__list', 'plugin__cron__get', 'plugin__cron__update',
|
|
17
|
-
'plugin__cron__delete', 'plugin__cron__get_history', 'plugin__cron__run_now', 'plugin__cron__stop'
|
|
18
|
-
].freeze
|
|
19
|
-
CLIENT_TOOL_PASSTHROUGH_WHITELIST_DEFAULT = [].freeze
|
|
20
|
-
|
|
21
12
|
def self.default
|
|
22
13
|
model_override = ENV.fetch('ANTHROPIC_MODEL', nil)
|
|
23
14
|
{
|
|
24
|
-
enabled:
|
|
25
|
-
connected:
|
|
26
|
-
pipeline_enabled:
|
|
27
|
-
pipeline_async_post_steps:
|
|
28
|
-
context_window:
|
|
29
|
-
max_output_tokens:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
skills: skills_defaults,
|
|
67
|
-
claude_cli: claude_cli_defaults,
|
|
68
|
-
fallback: fallback_defaults,
|
|
69
|
-
structured_output: structured_output_defaults
|
|
15
|
+
enabled: true,
|
|
16
|
+
connected: false,
|
|
17
|
+
pipeline_enabled: true,
|
|
18
|
+
pipeline_async_post_steps: true,
|
|
19
|
+
context_window: 250_000,
|
|
20
|
+
max_output_tokens: 16_384,
|
|
21
|
+
default_model: model_override,
|
|
22
|
+
default_temperature: 0.9,
|
|
23
|
+
default_provider: nil,
|
|
24
|
+
providers: {},
|
|
25
|
+
tier_order: nil,
|
|
26
|
+
system_baseline: system_baseline_default,
|
|
27
|
+
fleet: fleet_defaults,
|
|
28
|
+
routing: routing_defaults,
|
|
29
|
+
budget: budget_defaults,
|
|
30
|
+
confidence: confidence_defaults,
|
|
31
|
+
discovery: discovery_defaults,
|
|
32
|
+
daemon: daemon_defaults,
|
|
33
|
+
prompt_caching: prompt_caching_defaults,
|
|
34
|
+
arbitrage: arbitrage_defaults,
|
|
35
|
+
batch: batch_defaults,
|
|
36
|
+
scheduling: scheduling_defaults,
|
|
37
|
+
rag: rag_defaults,
|
|
38
|
+
rag_guard: rag_guard_defaults,
|
|
39
|
+
gaia: gaia_defaults,
|
|
40
|
+
knowledge_capture: knowledge_capture_defaults,
|
|
41
|
+
embedding: embedding_defaults,
|
|
42
|
+
conversation: conversation_defaults,
|
|
43
|
+
telemetry: telemetry_defaults,
|
|
44
|
+
pricing: {},
|
|
45
|
+
metering: metering_defaults,
|
|
46
|
+
context_curation: context_curation_defaults,
|
|
47
|
+
debate: debate_defaults,
|
|
48
|
+
provider_layer: provider_layer_defaults,
|
|
49
|
+
tools: Legion::LLM::Settings::Tools.defaults,
|
|
50
|
+
api: api_defaults,
|
|
51
|
+
streaming: streaming_defaults,
|
|
52
|
+
compliance: compliance_defaults,
|
|
53
|
+
skills: skills_defaults,
|
|
54
|
+
claude_cli: claude_cli_defaults,
|
|
55
|
+
fallback: fallback_defaults,
|
|
56
|
+
structured_output: structured_output_defaults
|
|
70
57
|
}
|
|
71
58
|
end
|
|
72
59
|
|
|
@@ -483,17 +470,6 @@ module Legion
|
|
|
483
470
|
}
|
|
484
471
|
end
|
|
485
472
|
|
|
486
|
-
def self.tool_trigger_defaults
|
|
487
|
-
{
|
|
488
|
-
scan_depth: 10,
|
|
489
|
-
tool_limit: 25,
|
|
490
|
-
local_tool_limit: 50,
|
|
491
|
-
client_tool_passthrough: true,
|
|
492
|
-
client_tool_passthrough_whitelist: CLIENT_TOOL_PASSTHROUGH_WHITELIST_DEFAULT.dup,
|
|
493
|
-
client_tool_passthrough_blacklist: CLIENT_TOOL_PASSTHROUGH_BLACKLIST_DEFAULT.dup
|
|
494
|
-
}
|
|
495
|
-
end
|
|
496
|
-
|
|
497
473
|
def self.debate_defaults
|
|
498
474
|
{
|
|
499
475
|
enabled: false,
|
|
@@ -9,11 +9,6 @@ module Legion
|
|
|
9
9
|
extend Legion::Logging::Helper
|
|
10
10
|
extend ::Legion::Cache::Helper
|
|
11
11
|
|
|
12
|
-
OVERRIDE_THRESHOLD = 0.8
|
|
13
|
-
SHADOW_THRESHOLD = 0.5
|
|
14
|
-
SUCCESS_DELTA = 0.05
|
|
15
|
-
FAILURE_DELTA = -0.1
|
|
16
|
-
|
|
17
12
|
@overrides_l0 = {}
|
|
18
13
|
@mutex = Mutex.new
|
|
19
14
|
|
|
@@ -36,7 +31,7 @@ module Legion
|
|
|
36
31
|
entry = @overrides_l0[tool]
|
|
37
32
|
return unless entry
|
|
38
33
|
|
|
39
|
-
entry[:confidence] = (entry[:confidence] +
|
|
34
|
+
entry[:confidence] = (entry[:confidence] + confidence_settings[:success_delta]).clamp(0.0, 1.0)
|
|
40
35
|
entry[:hit_count] += 1
|
|
41
36
|
entry[:updated_at] = Time.now
|
|
42
37
|
end
|
|
@@ -48,7 +43,7 @@ module Legion
|
|
|
48
43
|
entry = @overrides_l0[tool]
|
|
49
44
|
return unless entry
|
|
50
45
|
|
|
51
|
-
entry[:confidence] = (entry[:confidence] +
|
|
46
|
+
entry[:confidence] = (entry[:confidence] + confidence_settings[:failure_delta]).clamp(0.0, 1.0)
|
|
52
47
|
entry[:miss_count] += 1
|
|
53
48
|
entry[:updated_at] = Time.now
|
|
54
49
|
end
|
|
@@ -63,12 +58,13 @@ module Legion
|
|
|
63
58
|
|
|
64
59
|
def should_override?(tool)
|
|
65
60
|
entry = lookup(tool)
|
|
66
|
-
entry.is_a?(Hash) && entry[:confidence] >=
|
|
61
|
+
entry.is_a?(Hash) && entry[:confidence] >= confidence_settings[:override_threshold]
|
|
67
62
|
end
|
|
68
63
|
|
|
69
64
|
def should_shadow?(tool)
|
|
70
65
|
entry = lookup(tool)
|
|
71
|
-
entry.is_a?(Hash) && entry[:confidence] >=
|
|
66
|
+
entry.is_a?(Hash) && entry[:confidence] >= confidence_settings[:shadow_threshold] &&
|
|
67
|
+
entry[:confidence] < confidence_settings[:override_threshold]
|
|
72
68
|
end
|
|
73
69
|
|
|
74
70
|
def all_overrides
|
|
@@ -98,7 +94,7 @@ module Legion
|
|
|
98
94
|
results = Legion::Extensions::Apollo::Runners::Knowledge.handle_retrieve(
|
|
99
95
|
tags: %w[override mesh_confirmed],
|
|
100
96
|
knowledge_domain: 'system',
|
|
101
|
-
limit:
|
|
97
|
+
limit: confidence_settings[:apollo_limit]
|
|
102
98
|
)
|
|
103
99
|
return unless results.is_a?(Array)
|
|
104
100
|
|
|
@@ -115,7 +111,8 @@ module Legion
|
|
|
115
111
|
@overrides_l0[tool] = {
|
|
116
112
|
tool: tool,
|
|
117
113
|
lex: ctx[:lex] || ctx['lex'],
|
|
118
|
-
confidence: ((ctx[:confidence] || ctx['confidence']).to_f *
|
|
114
|
+
confidence: ((ctx[:confidence] || ctx['confidence']).to_f *
|
|
115
|
+
confidence_settings[:apollo_confidence_multiplier]).clamp(0.0, 1.0),
|
|
119
116
|
hit_count: 0, miss_count: 0,
|
|
120
117
|
created_at: Time.now, updated_at: Time.now
|
|
121
118
|
}
|
|
@@ -140,7 +137,7 @@ module Legion
|
|
|
140
137
|
entry = @mutex.synchronize { @overrides_l0[tool] }
|
|
141
138
|
return unless entry
|
|
142
139
|
|
|
143
|
-
l1_cache_set("override:#{tool}", Legion::JSON.dump(entry), ttl:
|
|
140
|
+
l1_cache_set("override:#{tool}", Legion::JSON.dump(entry), ttl: confidence_settings[:cache_ttl_seconds])
|
|
144
141
|
rescue StandardError => e
|
|
145
142
|
handle_exception(e, level: :warn, handled: true, operation: 'llm.tools.confidence.sync_l1', tool: tool)
|
|
146
143
|
nil
|
|
@@ -197,6 +194,10 @@ module Legion
|
|
|
197
194
|
handle_exception(e, level: :warn, handled: true, operation: 'llm.tools.confidence.lookup_l2')
|
|
198
195
|
nil
|
|
199
196
|
end
|
|
197
|
+
|
|
198
|
+
def confidence_settings
|
|
199
|
+
Legion::Settings[:llm][:tools][:confidence]
|
|
200
|
+
end
|
|
200
201
|
end
|
|
201
202
|
end
|
|
202
203
|
end
|
|
@@ -217,10 +217,7 @@ module Legion
|
|
|
217
217
|
end
|
|
218
218
|
|
|
219
219
|
def tool_error_log_chars
|
|
220
|
-
|
|
221
|
-
configured.positive? ? configured : 500
|
|
222
|
-
rescue StandardError
|
|
223
|
-
500
|
|
220
|
+
Legion::Settings[:llm][:tools][:error_log_chars]
|
|
224
221
|
end
|
|
225
222
|
end
|
|
226
223
|
end
|
|
@@ -7,8 +7,6 @@ module Legion
|
|
|
7
7
|
module Tools
|
|
8
8
|
module Interceptors
|
|
9
9
|
module PythonVenv
|
|
10
|
-
VENV_DIR = (ENV['LEGION_PYTHON_VENV'] || File.expand_path('~/.legionio/python')).freeze
|
|
11
|
-
|
|
12
10
|
TOOL_PATTERN = /\A(python3?|pip3?)\z/i
|
|
13
11
|
|
|
14
12
|
module_function
|
|
@@ -24,7 +22,7 @@ module Legion
|
|
|
24
22
|
end
|
|
25
23
|
|
|
26
24
|
def venv_available?
|
|
27
|
-
Special.python_available? || File.exist?("#{
|
|
25
|
+
Special.python_available? || File.exist?("#{venv_dir}/pyvenv.cfg")
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
def rewrite(**args)
|
|
@@ -43,11 +41,16 @@ module Legion
|
|
|
43
41
|
end
|
|
44
42
|
|
|
45
43
|
def python_path
|
|
46
|
-
Special.python_path || "#{
|
|
44
|
+
Special.python_path || "#{venv_dir}/bin/python3"
|
|
47
45
|
end
|
|
48
46
|
|
|
49
47
|
def pip_path
|
|
50
|
-
Special.pip_path || "#{
|
|
48
|
+
Special.pip_path || "#{venv_dir}/bin/pip3"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def venv_dir
|
|
52
|
+
configured = Legion::Settings[:llm][:tools][:python_venv_dir]
|
|
53
|
+
File.expand_path(ENV['LEGION_PYTHON_VENV'] || configured)
|
|
51
54
|
end
|
|
52
55
|
end
|
|
53
56
|
end
|
|
@@ -17,8 +17,6 @@ module Legion
|
|
|
17
17
|
|
|
18
18
|
LIST_SPECIAL_TOOLS_NAME = 'legion_list_special_tools'
|
|
19
19
|
LIST_ALL_TOOLS_NAME = 'legion_list_all_tools'
|
|
20
|
-
DEFAULT_TIMEOUT_MS = 120_000
|
|
21
|
-
MAX_TIMEOUT_MS = 600_000
|
|
22
20
|
TOOL_ALIASES = {
|
|
23
21
|
'python' => %w[python python3],
|
|
24
22
|
'pip' => %w[pip pip3]
|
|
@@ -138,7 +136,8 @@ module Legion
|
|
|
138
136
|
end
|
|
139
137
|
|
|
140
138
|
def python_venv_dir
|
|
141
|
-
|
|
139
|
+
configured = Legion::Settings[:llm][:tools][:python_venv_dir]
|
|
140
|
+
ENV['LEGION_PYTHON_VENV'] || File.expand_path(configured)
|
|
142
141
|
end
|
|
143
142
|
|
|
144
143
|
def special_tools_definition
|
|
@@ -326,11 +325,51 @@ module Legion
|
|
|
326
325
|
end
|
|
327
326
|
|
|
328
327
|
def run_process(executable, argv, **args)
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
Open3.popen2e(executable, *argv, chdir: process_cwd(args), pgroup: true) do |stdin, output, wait_thread|
|
|
329
|
+
output_reader = Thread.new { output.read }
|
|
330
|
+
output_reader.report_on_exception = false
|
|
331
|
+
stdin_writer = Thread.new do
|
|
332
|
+
stdin.write(process_stdin(args))
|
|
333
|
+
rescue Errno::EPIPE, IOError
|
|
334
|
+
nil
|
|
335
|
+
ensure
|
|
336
|
+
stdin.close unless stdin.closed?
|
|
337
|
+
end
|
|
338
|
+
stdin_writer.report_on_exception = false
|
|
339
|
+
|
|
340
|
+
unless wait_thread.join(timeout_ms(args) / 1000.0)
|
|
341
|
+
terminate_process_group(wait_thread)
|
|
342
|
+
stdin_writer.join
|
|
343
|
+
output_reader.join
|
|
344
|
+
raise Timeout::Error
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
stdin_writer.join
|
|
348
|
+
[output_reader.value, wait_thread.value]
|
|
331
349
|
end
|
|
332
350
|
end
|
|
333
351
|
|
|
352
|
+
def terminate_process_group(wait_thread)
|
|
353
|
+
process_group_id = wait_thread.pid
|
|
354
|
+
signal_process_group('TERM', process_group_id)
|
|
355
|
+
wait_thread.join(terminate_grace_ms / 1000.0)
|
|
356
|
+
signal_process_group('KILL', process_group_id) if process_group_alive?(process_group_id)
|
|
357
|
+
wait_thread.join
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def signal_process_group(signal, process_group_id)
|
|
361
|
+
::Process.kill(signal, -process_group_id)
|
|
362
|
+
rescue Errno::ESRCH
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def process_group_alive?(process_group_id)
|
|
367
|
+
::Process.kill(0, -process_group_id)
|
|
368
|
+
true
|
|
369
|
+
rescue Errno::ESRCH
|
|
370
|
+
false
|
|
371
|
+
end
|
|
372
|
+
|
|
334
373
|
def process_cwd(args)
|
|
335
374
|
cwd = args[:cwd] || args['cwd']
|
|
336
375
|
cwd.to_s.empty? ? Dir.pwd : cwd.to_s
|
|
@@ -342,10 +381,15 @@ module Legion
|
|
|
342
381
|
end
|
|
343
382
|
|
|
344
383
|
def timeout_ms(args)
|
|
345
|
-
|
|
346
|
-
|
|
384
|
+
timeouts = Legion::Settings[:llm][:tools][:timeouts]
|
|
385
|
+
requested = (args[:timeout] || args['timeout'] || timeouts[:default]).to_i
|
|
386
|
+
return timeouts[:default] unless requested.positive?
|
|
387
|
+
|
|
388
|
+
[requested, timeouts[:max]].min
|
|
389
|
+
end
|
|
347
390
|
|
|
348
|
-
|
|
391
|
+
def terminate_grace_ms
|
|
392
|
+
Legion::Settings[:llm][:tools][:timeouts][:terminate_grace]
|
|
349
393
|
end
|
|
350
394
|
|
|
351
395
|
def pip_candidates_for(bin_dir)
|
|
@@ -410,10 +454,7 @@ module Legion
|
|
|
410
454
|
end
|
|
411
455
|
|
|
412
456
|
def tool_error_log_chars
|
|
413
|
-
|
|
414
|
-
configured.positive? ? configured : 500
|
|
415
|
-
rescue StandardError
|
|
416
|
-
500
|
|
457
|
+
Legion::Settings[:llm][:tools][:error_log_chars]
|
|
417
458
|
end
|
|
418
459
|
end
|
|
419
460
|
end
|
data/lib/legion/llm/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.15.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -411,6 +411,7 @@ files:
|
|
|
411
411
|
- lib/legion/llm/scheduling/batch.rb
|
|
412
412
|
- lib/legion/llm/scheduling/off_peak.rb
|
|
413
413
|
- lib/legion/llm/settings.rb
|
|
414
|
+
- lib/legion/llm/settings/tools.rb
|
|
414
415
|
- lib/legion/llm/skills.rb
|
|
415
416
|
- lib/legion/llm/skills/base.rb
|
|
416
417
|
- lib/legion/llm/skills/disk_loader.rb
|