legion-llm 0.14.18 → 0.14.20

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: eb744016e12f01c149812c027353b25ecba016826789d83a3a795a5b9cba2a19
4
- data.tar.gz: 8ed359b0f6d0b9d147a04a419ad30bf26834a258b1448c625e0a6614187f5db3
3
+ metadata.gz: 96388b9de10bee10e82a3d0ec28ee9206710cfdc0c61684022fc94bb9250a9b0
4
+ data.tar.gz: 93362c4565301c19d55a32cb28b9e2e8fae145a893fb7330594188916280fa9e
5
5
  SHA512:
6
- metadata.gz: 74db62246f4ff813981821a699374fa21e39d84b2d7496c28c969c3f2345ea777d7c74cd5a763222b5fa0014864eda6ae81332609a595b9f51a02162198af778
7
- data.tar.gz: 46b000f6e78863046f6c05560ba24aa49a16d097f92cea59390735b9acb42b9fbed1f1f0d04715262b5a9f07b1b521c370484b11acb010822a6dc56737dde40c
6
+ metadata.gz: 717d93944bb01f208072e86b90ecf4649baa084da362cab3217f3c58c76c6e7da3c35ad088907b8ed10a607199ae4604fcc6f89862c2f625e1ce8da9160afdf7
7
+ data.tar.gz: b80edee9bd60d15b433e3a5b816eeb90ee75065b9018a7ec07acc7574191af17904752531a1361cdf41ebdd44f6fcc9dea08a4db69e3336c1ef3371e1e4a8c80
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.14.20] - 2026-07-24
4
+
5
+ ### Fixed
6
+ - **Curator `strip_thinking` no longer runs on tool messages.** Tool results (`role: :tool`) contain raw command output, not thinking content. The method was incorrectly applying `lstrip` and thinking-tag detection to tool results, stripping leading whitespace from Bash output on every curation pass.
7
+ - **Curator `strip_thinking_tags` no longer lstrips content unconditionally.** Leading whitespace is only removed between consecutive tag removals, not from the original input.
8
+ - **StreamAssembler allows interleaved thinking blocks.** Models that produce think→content→think→content sequences no longer have subsequent thinking phases silently dropped. Each thinking phase opens a new block instead of being discarded.
9
+ - **Trigger match strips all tagged blocks and harness messages.** Tool injection no longer triggers on words from `<system-reminder>`, CLAUDE.md content, or harness prompts (SUGGESTION MODE, title generation). Only actual user conversational text is scanned for trigger words.
10
+
11
+ ### Changed
12
+ - **Context curation token targets raised.** `target_context_tokens` 60K→120K, `summarize_threshold` 90K→120K, `target_tokens` 60K→90K.
13
+
14
+ ## [0.14.19] - 2026-07-24
15
+
16
+ ### Fixed
17
+ - **Curator `strip_thinking` no longer runs on tool messages.** Tool results (`role: :tool`) contain raw command output, not thinking content. The method was incorrectly applying `lstrip` and thinking-tag detection to tool results, stripping leading whitespace from Bash output on every curation pass.
18
+
3
19
  ## [0.14.18] - 2026-07-24
4
20
 
5
21
  ### Changed
@@ -352,24 +352,26 @@ module Legion
352
352
  end
353
353
 
354
354
  def handle_thinking_delta(text, signature)
355
- # Thinking always comes before text per the canonical ordering.
356
- # When emit_thinking_blocks is off we still accumulate the internal
357
- # buffer (so finalize can attach it to the final message audit) but
358
- # never emit blocks to the client.
359
355
  @full_thinking << text.to_s unless text.to_s.empty?
360
356
  @full_thinking_signature ||= signature.to_s unless signature.to_s.empty?
361
357
 
362
358
  return unless @emit_thinking_blocks
363
359
 
364
- unless @thinking_block_open || @thinking_block_closed
360
+ # If thinking arrives after text has started, open a new thinking block
361
+ if @thinking_block_closed
362
+ close_text_block
363
+ @thinking_block_closed = false
364
+ @thinking_block_index = @next_block_index
365
+ @next_block_index += 1
366
+ guard { @emitter.on_thinking_open(block_index: @thinking_block_index) }
367
+ @thinking_block_open = true
368
+ elsif !@thinking_block_open
365
369
  @thinking_block_index = @next_block_index
366
370
  @next_block_index += 1
367
371
  guard { @emitter.on_thinking_open(block_index: @thinking_block_index) }
368
372
  @thinking_block_open = true
369
373
  end
370
374
 
371
- return if @thinking_block_closed
372
-
373
375
  @phase = :mid_thinking
374
376
  guard { @emitter.on_thinking_delta(block_index: @thinking_block_index, text: text.to_s, signature: signature.to_s) }
375
377
  end
@@ -123,6 +123,7 @@ module Legion
123
123
  # Heuristic: remove extended thinking blocks, keep conclusions.
124
124
  def strip_thinking(msg)
125
125
  return msg unless setting(:thinking_eviction, true)
126
+ return msg if msg[:role] == :tool
126
127
 
127
128
  content = msg[:content].to_s
128
129
  stripped = strip_thinking_tags(content)
@@ -136,6 +137,10 @@ module Legion
136
137
  chars_removed = content.length - stripped.length
137
138
  log.info "[llm][curator] action=strip_thinking conversation_id=#{@conversation_id} " \
138
139
  "chars_removed=#{chars_removed} original_chars=#{content.length} stripped_chars=#{stripped.length}"
140
+ if content.length < 50
141
+ log.debug "[llm][curator] action=strip_thinking_debug conversation_id=#{@conversation_id} " \
142
+ "original=#{content.inspect} stripped=#{stripped.inspect} role=#{msg[:role]}"
143
+ end
139
144
  msg.merge(content: stripped, curated: true, original_content: content)
140
145
  end
141
146
 
@@ -293,7 +298,7 @@ module Legion
293
298
  end
294
299
 
295
300
  def strip_thinking_tags(text)
296
- result = text.lstrip
301
+ result = text
297
302
  loop do
298
303
  stripped = false
299
304
  THINKING_TAG_PAIRS.each do |open_tag, close_tag|
@@ -36,7 +36,7 @@ module Legion
36
36
 
37
37
  private
38
38
 
39
- def execute_native_tool_loop
39
+ def execute_native_tool_loop # rubocop:disable Metrics/AbcSize
40
40
  messages = native_dispatch_messages.dup
41
41
  max_rounds = Legion::Settings[:llm][:max_tool_rounds].to_i
42
42
  max_rounds = 200 unless max_rounds.positive?
@@ -60,6 +60,17 @@ module Legion
60
60
  result = apply_synthesized_tool_calls(result, tool_calls) if tool_calls.any?
61
61
  end
62
62
  if tool_calls.empty?
63
+ result_text = result.respond_to?(:text) ? result.text : (result[:result] || result[:content])
64
+ result_thinking = if result.respond_to?(:thinking)
65
+ t = result.thinking
66
+ t.respond_to?(:content) ? t.content : t.to_s
67
+ end
68
+ log.debug "[llm][native_tool_loop] action=exit_no_tool_calls round=#{round} " \
69
+ "result_text_length=#{result_text.to_s.length} " \
70
+ "result_text=#{result_text.to_s.inspect} " \
71
+ "thinking_length=#{result_thinking.to_s.length} " \
72
+ "thinking_first_200=#{result_thinking.to_s[0, 200].inspect} " \
73
+ "stop_reason=#{result.respond_to?(:stop_reason) ? result.stop_reason : 'n/a'}"
63
74
  log.debug "[llm][executor] action=native_tool_loop.complete rounds=#{round} reason=no_tool_calls"
64
75
  @last_tool_loop_messages = messages
65
76
  return result
@@ -143,7 +154,7 @@ module Legion
143
154
  @native_tool_loop_round = nil
144
155
  end
145
156
 
146
- def execute_native_streaming_tool_loop(&block)
157
+ def execute_native_streaming_tool_loop(&block) # rubocop:disable Metrics/AbcSize
147
158
  messages = native_dispatch_messages.dup
148
159
  max_rounds = Legion::Settings[:llm][:max_tool_rounds].to_i
149
160
  max_rounds = 200 unless max_rounds.positive?
@@ -167,6 +178,17 @@ module Legion
167
178
  result = apply_synthesized_tool_calls(result, tool_calls) if tool_calls.any?
168
179
  end
169
180
  if tool_calls.empty?
181
+ result_text = result.respond_to?(:text) ? result.text : (result[:result] || result[:content])
182
+ result_thinking = if result.respond_to?(:thinking)
183
+ t = result.thinking
184
+ t.respond_to?(:content) ? t.content : t.to_s
185
+ end
186
+ log.debug "[llm][native_tool_loop] action=stream_exit_no_tool_calls round=#{round} " \
187
+ "result_text_length=#{result_text.to_s.length} " \
188
+ "result_text=#{result_text.to_s.inspect} " \
189
+ "thinking_length=#{result_thinking.to_s.length} " \
190
+ "thinking_first_200=#{result_thinking.to_s[0, 200].inspect} " \
191
+ "stop_reason=#{result.respond_to?(:stop_reason) ? result.stop_reason : 'n/a'}"
170
192
  log.debug "[llm][executor] action=native_streaming_tool_loop.complete rounds=#{round} reason=no_tool_calls"
171
193
  @last_tool_loop_messages = messages
172
194
  return result
@@ -70,40 +70,56 @@ module Legion
70
70
  record_trigger_match_timeline(0, start_time)
71
71
  end
72
72
 
73
- private
73
+ HARNESS_PREFIXES = [
74
+ '[SUGGESTION MODE',
75
+ '[REQUEST INTERRUPTED',
76
+ 'Write the title in the predominant language'
77
+ ].freeze
74
78
 
75
79
  def extract_recent_text
76
80
  depth = trigger_scan_depth
77
81
  messages = @request.messages.last(depth)
82
+
78
83
  text = messages.filter_map do |msg|
79
84
  next unless msg.is_a?(Hash)
80
85
  next unless (msg[:role] || msg['role']).to_s == 'user'
81
86
 
82
87
  content = msg[:content] || msg['content']
83
- if content.is_a?(Array)
84
- content.filter_map do |c|
85
- if c.is_a?(String)
86
- c
87
- else
88
- (c.is_a?(Hash) ? (c[:text] || c['text']) : nil)
89
- end
90
- end.join(' ')
91
- else
92
- content.to_s
93
- end
88
+ raw = if content.is_a?(Array)
89
+ content.filter_map do |c|
90
+ if c.is_a?(String)
91
+ c
92
+ else
93
+ (c.is_a?(Hash) ? (c[:text] || c['text']) : nil)
94
+ end
95
+ end.join(' ')
96
+ else
97
+ content.to_s
98
+ end
99
+ next if harness_message?(raw)
100
+
101
+ raw
94
102
  end.join(' ')
95
103
 
96
104
  sanitize_trigger_text(text)
97
105
  end
98
106
 
99
107
  def sanitize_trigger_text(text)
100
- stripped = strip_system_reminders(text)
101
- session_text = extract_session_text(stripped)
102
- session_text || stripped
108
+ session_text = extract_session_text(text)
109
+ return session_text if session_text
110
+
111
+ strip_tagged_blocks(text)
103
112
  end
104
113
 
105
- def strip_system_reminders(text)
106
- text.to_s.gsub(%r{<system-reminder\b[^>]*>.*?</system-reminder>}mi, ' ')
114
+ def strip_tagged_blocks(text)
115
+ text.to_s.gsub(%r{<[a-z][\w-]*\b[^>]*>.*?</[a-z][\w-]*>}mi, ' ')
116
+ end
117
+
118
+ def harness_message?(text)
119
+ return false if text.nil? || text.empty?
120
+
121
+ trimmed = text.lstrip
122
+ HARNESS_PREFIXES.any? { |prefix| trimmed.start_with?(prefix) }
107
123
  end
108
124
 
109
125
  def extract_session_text(text)
@@ -437,7 +437,7 @@ module Legion
437
437
  # Claude Code linter/formatter file-dump note.
438
438
  'was modified, either by the user or by a linter'
439
439
  ],
440
- target_context_tokens: 60_000,
440
+ target_context_tokens: 120_000,
441
441
  context_window_threshold: 0.90,
442
442
  archive_dropped_turns: true,
443
443
  archive_preserve_recent: 10
@@ -446,8 +446,8 @@ module Legion
446
446
 
447
447
  def self.conversation_defaults
448
448
  {
449
- summarize_threshold: 90_000,
450
- target_tokens: 60_000,
449
+ summarize_threshold: 120_000,
450
+ target_tokens: 90_000,
451
451
  preserve_recent: 10,
452
452
  auto_compact: true
453
453
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.14.18'
5
+ VERSION = '0.14.20'
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.14.18
4
+ version: 0.14.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity