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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ba576c21739b35e60594706468f7a05769871b25e5acd466ae01cbc451eec70
|
|
4
|
+
data.tar.gz: e0ac7077fdc03270059abe385f202b681928e62d667f0cefad10a0a7d1b79760
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
160
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|