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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c27dbb3d54472859916263b21e60e5150b8deb3b1138cfcb7c7d7e28d5ebbc1d
4
- data.tar.gz: 3e4fb810c2ea38c895a98abd3cb432e3805a3faf35cdfecb5ad3e2347aa47046
3
+ metadata.gz: 63f3720eceeff21e7c4d1ca9c13858256b93bd2744d0ae3fc1bd0c7452b40d1a
4
+ data.tar.gz: 686e8646720ba92310f58ab7e675d380003e89e465a7b02711b916b38ee89953
5
5
  SHA512:
6
- metadata.gz: c0f36cfb05fdf100e7c706dbfa254fd486afb1de6c7beb3644ca56f0504283168fe676fb8b5299222d06d4206db469af98b0498fe22d04adc1ec1ea8fc1212cb
7
- data.tar.gz: f365e296fc5c77dba031187153c8f77cb454737b6637a199102e598a1e80beb3a26b68481888266bbd0c6a5943aa00eef6b5a11905ee60f6ada38f6861667290
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
- 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}"
@@ -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
@@ -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)
@@ -209,7 +209,7 @@ module Legion
209
209
 
210
210
  def extract_question(request)
211
211
  request.messages.select { |m| m[:role].to_s == 'user' }
212
- .last&.dig(:content) || ''
212
+ .last&.dig(:content) || ''
213
213
  end
214
214
 
215
215
  def extract_content(response)
@@ -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
- .then { |messages| content_text(message_content(messages.last)) }
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, true) != false
15
+ sticky_setting(:enabled) != false
16
16
  end
17
17
 
18
18
  def trigger_sticky_turns
19
- sticky_setting(:trigger_turns, 2)
19
+ sticky_setting(:trigger_turns)
20
20
  end
21
21
 
22
22
  def execution_sticky_tool_calls
23
- sticky_setting(:execution_tool_calls, 5)
23
+ sticky_setting(:execution_tool_calls)
24
24
  end
25
25
 
26
26
  def max_history_entries
27
- sticky_setting(:max_history_entries, 50)
27
+ sticky_setting(:max_history_entries)
28
28
  end
29
29
 
30
30
  def max_result_length
31
- sticky_setting(:max_result_length, 2000)
31
+ sticky_setting(:max_result_length)
32
32
  end
33
33
 
34
34
  def max_args_length
35
- sticky_setting(:max_args_length, 500)
35
+ sticky_setting(:max_args_length)
36
36
  end
37
37
 
38
- def sticky_setting(key, default = nil)
39
- value = Legion::Settings.dig(:llm, :tool_sticky, key)
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..100].to_s,
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 = 30)
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
- return "error: #{result_str.to_s[0, 100]}" if error
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 > 2000 && str.start_with?('{')
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, 200]
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, 200]
89
+ str[0, history[:summary_chars]]
89
90
  end
90
91
  else
91
- str[0, 200]
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
- char_limit = [str.length, 500].min # Only scan first 500 chars
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 >= 3 # Only need first 3 keys
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][:tool_trigger][:scan_depth]
208
+ Legion::Settings[:llm][:tools][:trigger][:scan_depth]
209
209
  end
210
210
 
211
211
  def trigger_tool_limit
212
- Legion::Settings[:llm][:tool_trigger][:tool_limit]
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(20).join(',')
228
+ value.map(&:to_s).first(Legion::Settings[:llm][:tools][:trigger][:log_name_limit]).join(',')
229
229
  else
230
230
  value
231
231
  end
@@ -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
@@ -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: true,
25
- connected: false,
26
- pipeline_enabled: true,
27
- pipeline_async_post_steps: true,
28
- context_window: 250_000,
29
- max_output_tokens: 16_384,
30
- max_tool_rounds: 200,
31
- max_tool_calls_per_turn: 100,
32
- tool_error_log_chars: 500,
33
- tool_result_max_dispatch_chars: 5_000,
34
- default_model: model_override,
35
- default_temperature: 0.9,
36
- default_provider: nil,
37
- providers: {},
38
- tier_order: nil,
39
- system_baseline: system_baseline_default,
40
- fleet: fleet_defaults,
41
- routing: routing_defaults,
42
- budget: budget_defaults,
43
- confidence: confidence_defaults,
44
- discovery: discovery_defaults,
45
- daemon: daemon_defaults,
46
- prompt_caching: prompt_caching_defaults,
47
- arbitrage: arbitrage_defaults,
48
- batch: batch_defaults,
49
- scheduling: scheduling_defaults,
50
- rag: rag_defaults,
51
- rag_guard: rag_guard_defaults,
52
- gaia: gaia_defaults,
53
- knowledge_capture: knowledge_capture_defaults,
54
- embedding: embedding_defaults,
55
- conversation: conversation_defaults,
56
- telemetry: telemetry_defaults,
57
- pricing: {},
58
- metering: metering_defaults,
59
- context_curation: context_curation_defaults,
60
- debate: debate_defaults,
61
- provider_layer: provider_layer_defaults,
62
- tool_trigger: tool_trigger_defaults,
63
- api: api_defaults,
64
- streaming: streaming_defaults,
65
- compliance: compliance_defaults,
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] + SUCCESS_DELTA).clamp(0.0, 1.0)
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] + FAILURE_DELTA).clamp(0.0, 1.0)
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] >= OVERRIDE_THRESHOLD
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] >= SHADOW_THRESHOLD && entry[:confidence] < OVERRIDE_THRESHOLD
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: 100
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 * 0.8).clamp(0.0, 1.0),
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: 3600)
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
- configured = Legion::Settings[:llm][:tool_error_log_chars].to_i
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?("#{VENV_DIR}/pyvenv.cfg")
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 || "#{VENV_DIR}/bin/python3"
44
+ Special.python_path || "#{venv_dir}/bin/python3"
47
45
  end
48
46
 
49
47
  def pip_path
50
- Special.pip_path || "#{VENV_DIR}/bin/pip3"
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
- ENV['LEGION_PYTHON_VENV'] || File.expand_path('~/.legionio/python')
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
- Timeout.timeout(timeout_ms(args) / 1000.0) do
330
- Open3.capture2e(executable, *argv, chdir: process_cwd(args), stdin_data: process_stdin(args))
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
- requested = (args[:timeout] || args['timeout'] || DEFAULT_TIMEOUT_MS).to_i
346
- return DEFAULT_TIMEOUT_MS unless requested.positive?
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
- [requested, MAX_TIMEOUT_MS].min
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
- configured = Legion::Settings[:llm][:tool_error_log_chars].to_i
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.15.1'
5
+ VERSION = '0.15.2'
6
6
  end
7
7
  end
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.1
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