lex-llm 0.6.14 → 0.6.16

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: b28d2162e2284642dcbb0e39384957962b8b609aa982c2dc1c085f84f560243f
4
- data.tar.gz: a68c5e7d429b6ba04c2f431a4140d44c93542993430fe5b36e921347b1d9a728
3
+ metadata.gz: 3ba576c21739b35e60594706468f7a05769871b25e5acd466ae01cbc451eec70
4
+ data.tar.gz: e0ac7077fdc03270059abe385f202b681928e62d667f0cefad10a0a7d1b79760
5
5
  SHA512:
6
- metadata.gz: 4a76155cd1c0b1ac8952647afff1ed38728bdb130a241ec4b416b752fd19c07074db18cf787c9dca6f2e4ffe1afe3be144800093837add92311e68a63219593b
7
- data.tar.gz: 9eeadc0a087bb111ccaaea7feffc55aae57b44a0f60f331b3f7e0b7a43ed75737b70338319b6d1b8aa62de668a41b04b605444a9989e7776b2d315debd59ca38
6
+ metadata.gz: 997f024896ce8d0eb54d8574615312c0280efea81ecae75859b3bd702b8dd65aae0fbd5f615801e3e43b9d50cf6079ce4638d9a39488edb7e3a86887ee94a977
7
+ data.tar.gz: 3a33857e99d99a8f3358e781d46903560cb6932026d6889be2a922e23b865f3c2dacef4e38bd6a34e52af35474eab7f7e935d4c68360a8c56e2758836648d589
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.16 - 2026-08-04
4
+
5
+ ### Fixed
6
+ - **Interleaved streaming tool-call fragments are correlated by provider index.** `ToolCall` now carries the provider wire index, and `StreamAccumulator` maps continuation fragments back to the call opened at that index. Parallel calls no longer send every id-less fragment to the most recently opened call, which previously lost one call's arguments and contaminated another's.
7
+
8
+ ## 0.6.15 - 2026-07-31
9
+
10
+ ### Fixed
11
+ - **Remove dead `find_tool_call` method from StreamAccumulator.** Zero callers anywhere in lex-llm (confirmed via grep). The method referenced an undefined ivar `@latest_tool_call` (the actual ivar is `@latest_tool_call_id`) — it was broken dead code that could only ever return nil.
12
+ - **StreamAccumulator no longer drops tool_call opening fragments that arrive with `id: nil`.** When a tool_call chunk has no `id` but carries a `name` (indicating it's an opening fragment, not a continuation), the accumulator now generates a UUID and starts the call instead of silently dropping it via `append_tool_call_fragment`. This is defensive hardening — normal OpenAI-compatible streaming always assigns an id to the opening fragment, but non-conforming backends or edge cases could produce id-less openers. Existing id-based correlation is unchanged.
13
+
14
+ ### Added
15
+ - **Contract specs asserting streaming `stop_reason` propagates through the StreamAccumulator.** Three regression specs encoding the accumulator's stop_reason behavior: single stop_reason captured from a chunk and propagated to the assembled Message, last-wins semantics when multiple chunks carry non-nil stop_reason, and nil-when-absent (no chunks carry stop_reason). This is the lex-llm half of the cross-gem streaming finish_reason fix — paired with lex-llm-vllm v0.3.15 (#16) which wires `stop_reason: canonical.stop_reason` through `to_legacy_chunk`.
16
+
3
17
  ## 0.6.14 - 2026-07-31
4
18
 
5
19
  ### Added
@@ -26,6 +26,7 @@ module Legion
26
26
  @untagged_preamble_pending = true
27
27
  @untagged_preamble_buffer = +''
28
28
  @latest_tool_call_id = nil
29
+ @index_to_id = {}
29
30
  end
30
31
 
31
32
  def add(chunk)
@@ -149,6 +150,8 @@ module Legion
149
150
  new_tool_calls.each_value do |tool_call|
150
151
  if tool_call.id
151
152
  start_tool_call(tool_call)
153
+ elsif tool_call.name && @latest_tool_call_id.nil?
154
+ start_tool_call_without_id(tool_call)
152
155
  else
153
156
  append_tool_call_fragment(tool_call)
154
157
  end
@@ -156,13 +159,15 @@ module Legion
156
159
  end
157
160
 
158
161
  def start_tool_call(tool_call)
159
- @tool_calls[tool_call.id] = ToolCall.new(
160
- id: tool_call.id.empty? ? SecureRandom.uuid : tool_call.id,
162
+ resolved_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
163
+ @tool_calls[resolved_id] = ToolCall.new(
164
+ id: resolved_id,
161
165
  name: tool_call.name,
162
166
  arguments: mutable_tool_arguments(tool_call.arguments),
163
167
  thought_signature: tool_call.thought_signature
164
168
  )
165
- @latest_tool_call_id = tool_call.id
169
+ @latest_tool_call_id = resolved_id
170
+ @index_to_id[tool_call.index] = resolved_id unless tool_call.index.nil?
166
171
  end
167
172
 
168
173
  def mutable_tool_arguments(arguments)
@@ -175,8 +180,29 @@ module Legion
175
180
  end
176
181
  end
177
182
 
183
+ def start_tool_call_without_id(tool_call)
184
+ generated_id = SecureRandom.uuid
185
+ @tool_calls[generated_id] = ToolCall.new(
186
+ id: generated_id,
187
+ name: tool_call.name,
188
+ arguments: mutable_tool_arguments(tool_call.arguments),
189
+ thought_signature: tool_call.thought_signature
190
+ )
191
+ @latest_tool_call_id = generated_id
192
+ @index_to_id[tool_call.index] = generated_id unless tool_call.index.nil?
193
+ end
194
+
195
+ # Correlate a continuation fragment (id=nil, name=nil) to its call by the
196
+ # provider's authoritative wire index. Interleaved parallel calls
197
+ # (opener0, opener1, frag0, frag1, ...) only reassemble correctly when
198
+ # each fragment lands on the call at ITS index; recency (@latest_tool_call_id)
199
+ # sends every fragment to the last-opened call, losing one call's arguments
200
+ # and contaminating the other. Recency is retained only as the fallback for
201
+ # providers that emit no index.
178
202
  def append_tool_call_fragment(tool_call)
179
- existing = @tool_calls[@latest_tool_call_id]
203
+ target_id = @index_to_id[tool_call.index] if tool_call.index
204
+ target_id ||= @latest_tool_call_id
205
+ existing = @tool_calls[target_id]
180
206
  return unless existing
181
207
 
182
208
  existing.arguments << tool_call.arguments.to_s
@@ -185,15 +211,6 @@ module Legion
185
211
  existing.thought_signature = tool_call.thought_signature
186
212
  end
187
213
 
188
- def find_tool_call(tool_call_id)
189
- if tool_call_id.nil?
190
- @tool_calls[@latest_tool_call]
191
- else
192
- @latest_tool_call_id = tool_call_id
193
- @tool_calls[tool_call_id]
194
- end
195
- end
196
-
197
214
  def count_tokens(chunk)
198
215
  @input_tokens = chunk.input_tokens if chunk.input_tokens
199
216
  @output_tokens = chunk.output_tokens if chunk.output_tokens
@@ -5,14 +5,19 @@ module Legion
5
5
  module Llm
6
6
  # Represents a function call from an AI model to a Tool.
7
7
  class ToolCall
8
- attr_reader :id, :name, :arguments
8
+ attr_reader :id, :name, :arguments, :index
9
9
  attr_accessor :thought_signature
10
10
 
11
- def initialize(id:, name:, arguments: {}, thought_signature: nil)
11
+ # index carries the provider's authoritative wire position
12
+ # (delta.tool_calls[N].index) so the StreamAccumulator can correlate
13
+ # interleaved argument fragments to the right parallel call by index
14
+ # rather than by recency. nil for non-streaming/single-call paths.
15
+ def initialize(id:, name:, arguments: {}, thought_signature: nil, index: nil)
12
16
  @id = id
13
17
  @name = name
14
18
  @arguments = arguments
15
19
  @thought_signature = thought_signature
20
+ @index = index
16
21
  end
17
22
 
18
23
  def to_h
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.6.14'
6
+ VERSION = '0.6.16'
7
7
  end
8
8
  end
9
9
  end
@@ -99,6 +99,62 @@ RSpec.describe Legion::Extensions::Llm::StreamAccumulator do
99
99
  expect(message.content).to eq(expected)
100
100
  expect(message.thinking).to be_nil
101
101
  end
102
+
103
+ it 'captures stop_reason from a chunk and propagates it to the assembled Message' do
104
+ accumulator = described_class.new
105
+ chunk = Legion::Extensions::Llm::Chunk.new(
106
+ role: :assistant, content: 'partial', model_id: 'm', stop_reason: :max_tokens
107
+ )
108
+
109
+ accumulator.add(chunk)
110
+ expect(accumulator.stop_reason).to eq(:max_tokens)
111
+
112
+ message = accumulator.to_message(nil)
113
+ expect(message.stop_reason).to eq(:max_tokens)
114
+ end
115
+
116
+ it 'uses the last non-nil stop_reason when multiple chunks carry one' do
117
+ accumulator = described_class.new
118
+ accumulator.add(Legion::Extensions::Llm::Chunk.new(
119
+ role: :assistant, content: 'hello', model_id: 'm', stop_reason: :end_turn
120
+ ))
121
+ accumulator.add(Legion::Extensions::Llm::Chunk.new(
122
+ role: :assistant, content: nil, model_id: 'm', stop_reason: :max_tokens
123
+ ))
124
+
125
+ expect(accumulator.stop_reason).to eq(:max_tokens)
126
+ expect(accumulator.to_message(nil).stop_reason).to eq(:max_tokens)
127
+ end
128
+
129
+ it 'leaves stop_reason nil when no chunk carries one' do
130
+ accumulator = described_class.new
131
+ accumulator.add(Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'hi', model_id: 'm'))
132
+
133
+ expect(accumulator.stop_reason).to be_nil
134
+ expect(accumulator.to_message(nil).stop_reason).to be_nil
135
+ end
136
+
137
+ it 'starts a tool call from an opening fragment with id nil but name present' do
138
+ accumulator = described_class.new
139
+ tool_call = Legion::Extensions::Llm::ToolCall.new(id: nil, name: 'weather', arguments: '')
140
+ chunk = Legion::Extensions::Llm::Chunk.new(
141
+ role: :assistant, content: nil, tool_calls: { weather: tool_call }
142
+ )
143
+
144
+ accumulator.add(chunk)
145
+
146
+ frag_call = Legion::Extensions::Llm::ToolCall.new(id: nil, name: nil, arguments: '{"city":"SF"}')
147
+ frag_chunk = Legion::Extensions::Llm::Chunk.new(
148
+ role: :assistant, content: nil, tool_calls: { fragment: frag_call }
149
+ )
150
+ accumulator.add(frag_chunk)
151
+
152
+ message = accumulator.to_message(nil)
153
+ expect(message.tool_calls.size).to eq(1)
154
+ tc = message.tool_calls.values.first
155
+ expect(tc.name).to eq('weather')
156
+ expect(tc.arguments).to eq({ 'city' => 'SF' })
157
+ end
102
158
  end
103
159
 
104
160
  describe '#filtered_chunk' do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.14
4
+ version: 0.6.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO