legion-llm 0.9.14 → 0.9.15
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 +8 -0
- data/lib/legion/llm/api/native/helpers.rb +50 -0
- data/lib/legion/llm/api/native/inference.rb +8 -1
- data/lib/legion/llm/call/dispatch.rb +4 -2
- data/lib/legion/llm/inference/executor.rb +4 -4
- data/lib/legion/llm/inference/steps/rag_context.rb +30 -2
- data/lib/legion/llm/inference/steps/sticky_persist.rb +21 -2
- data/lib/legion/llm/inference/steps/tool_calls.rb +110 -0
- data/lib/legion/llm/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 206afbe8609bb8ed7df111d216967aafba55b0d523d5939aad024f169e43f5ef
|
|
4
|
+
data.tar.gz: 283f42c3d5b9ba07aa7857aad3e6d1f30b7559f35282f4a1d00986ea4ab2c646
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54b3e821013f9ba6f73019907821e85d1aaacc766e8942767f5e6a9630d66757c1d16a8e1b6643054895b1e5de229245e45ebf562f42bdec1cfac8f609024a5c
|
|
7
|
+
data.tar.gz: 45d349d01bef14e68527aa0c8108c4d08f71e05b63c87c331e377703bdacfcee431b903fe72dc2ad48b10c2f73a67523e9c67a0da1dd46b4b9cf3e041c290671
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Legion LLM Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.15] - 2026-05-08
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Normalize structured user message content before RAG query handling, preventing multipart API messages from crashing trivial-query detection or reaching Apollo as arrays.
|
|
7
|
+
- Normalize empty string tool-call arguments to `{}` and make sticky tool history tolerate non-hash argument payloads without dropping state writes.
|
|
8
|
+
- Pass non-executable API client tool calls through to callers as streaming `tool-call` events instead of dispatching them server-side as failed tool executions.
|
|
9
|
+
- Add runtime logging for client tool receipt, native tool injection summaries, registry-injection skips, and returned tool-call SSE emission.
|
|
10
|
+
|
|
3
11
|
## [0.9.14] - 2026-05-08
|
|
4
12
|
|
|
5
13
|
### Fixed
|
|
@@ -347,6 +347,56 @@ module Legion
|
|
|
347
347
|
stream << "event: #{event_name}\ndata: #{Legion::JSON.dump(payload)}\n\n"
|
|
348
348
|
end
|
|
349
349
|
|
|
350
|
+
define_method(:emit_response_tool_call_events) do |stream, pipeline_response|
|
|
351
|
+
tool_calls = extract_tool_calls(pipeline_response)
|
|
352
|
+
return if tool_calls.empty?
|
|
353
|
+
|
|
354
|
+
timeline_tool_call_ids = Array(pipeline_response.timeline).filter_map do |event|
|
|
355
|
+
key = event[:key].to_s
|
|
356
|
+
next unless key.start_with?('tool:execute:')
|
|
357
|
+
|
|
358
|
+
data = event[:data].is_a?(Hash) ? event[:data] : {}
|
|
359
|
+
data[:tool_call_id] || data['tool_call_id']
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
emitted = 0
|
|
363
|
+
skipped_timeline = 0
|
|
364
|
+
request_id = pipeline_response.respond_to?(:request_id) ? pipeline_response.request_id : 'unknown'
|
|
365
|
+
conversation_id = pipeline_response.respond_to?(:conversation_id) ? pipeline_response.conversation_id : 'none'
|
|
366
|
+
|
|
367
|
+
tool_calls.each do |tool_call|
|
|
368
|
+
tool_call_id = tool_call[:id] || tool_call['id']
|
|
369
|
+
if tool_call_id && timeline_tool_call_ids.include?(tool_call_id)
|
|
370
|
+
skipped_timeline += 1
|
|
371
|
+
next
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
tool_name = tool_call[:name] || tool_call['name']
|
|
375
|
+
next if tool_name.to_s.empty?
|
|
376
|
+
|
|
377
|
+
log.info(
|
|
378
|
+
"[llm][api][tools] action=returned_tool_call_sse request_id=#{request_id || 'unknown'} " \
|
|
379
|
+
"conversation_id=#{conversation_id || 'none'} tool_call_id=#{tool_call_id || 'none'} name=#{tool_name} " \
|
|
380
|
+
"args_class=#{(tool_call[:arguments] || tool_call['arguments'] || {}).class}"
|
|
381
|
+
)
|
|
382
|
+
emit_sse_event(stream, 'tool-call', {
|
|
383
|
+
toolCallId: tool_call_id,
|
|
384
|
+
toolName: tool_name,
|
|
385
|
+
args: tool_call[:arguments] || tool_call['arguments'] || {},
|
|
386
|
+
timestamp: Time.now.utc.iso8601
|
|
387
|
+
})
|
|
388
|
+
emitted += 1
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
names = tool_calls.map { |tool_call| tool_call[:name] || tool_call['name'] }.compact
|
|
392
|
+
names = names.first(30).join(',') + (names.size > 30 ? ",+#{names.size - 30}more" : '')
|
|
393
|
+
log.info(
|
|
394
|
+
"[llm][api][tools] action=returned_tool_calls_complete request_id=#{request_id || 'unknown'} " \
|
|
395
|
+
"conversation_id=#{conversation_id || 'none'} total=#{tool_calls.size} emitted=#{emitted} " \
|
|
396
|
+
"skipped_timeline=#{skipped_timeline} names=#{names.empty? ? 'none' : names}"
|
|
397
|
+
)
|
|
398
|
+
end
|
|
399
|
+
|
|
350
400
|
define_method(:emit_timeline_tool_events) do |stream, pipeline_response, skip_tool_results: false|
|
|
351
401
|
timeline = Array(pipeline_response.timeline)
|
|
352
402
|
log.debug("[llm][api][helpers] emit_timeline_tool_events count=#{timeline.size} skip_tool_results=#{skip_tool_results}")
|
|
@@ -72,7 +72,13 @@ module Legion
|
|
|
72
72
|
build_client_tool_class(ts[:name].to_s, ts[:description].to_s, ts[:parameters] || ts[:input_schema])
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
client_tool_names = tool_declarations.map(&:name)
|
|
76
|
+
client_tool_summary = client_tool_names.empty? ? 'none' : client_tool_names.first(30).join(',')
|
|
77
|
+
client_tool_summary = "#{client_tool_summary},+#{client_tool_names.size - 30}more" if client_tool_names.size > 30
|
|
78
|
+
log.info(
|
|
79
|
+
"[llm][api][tools] action=client_tools_built request_id=#{request_id} " \
|
|
80
|
+
"conversation_id=#{conversation_id || 'none'} count=#{tool_declarations.size} names=#{client_tool_summary}"
|
|
81
|
+
)
|
|
76
82
|
|
|
77
83
|
streaming = body[:stream] == true && request.preferred_type.to_s.include?('text/event-stream')
|
|
78
84
|
effective_caller = build_server_caller(source: 'api', path: request.path, env: env,
|
|
@@ -155,6 +161,7 @@ module Legion
|
|
|
155
161
|
emit_sse_event(out, 'text-delta', { delta: text })
|
|
156
162
|
end
|
|
157
163
|
|
|
164
|
+
emit_response_tool_call_events(out, pipeline_response)
|
|
158
165
|
emit_timeline_tool_events(out, pipeline_response, skip_tool_results: !executor.tool_event_handler.nil?)
|
|
159
166
|
|
|
160
167
|
enrichments = pipeline_response.enrichments
|
|
@@ -332,11 +332,13 @@ module Legion
|
|
|
332
332
|
|
|
333
333
|
def parse_arguments(arguments)
|
|
334
334
|
return arguments unless arguments.is_a?(String)
|
|
335
|
+
return {} if arguments.strip.empty?
|
|
335
336
|
|
|
336
|
-
Legion::JSON.parse(arguments)
|
|
337
|
+
parsed = Legion::JSON.parse(arguments)
|
|
338
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
337
339
|
rescue StandardError => e
|
|
338
340
|
handle_exception(e, level: :debug, handled: true, operation: 'llm.dispatch.parse_arguments')
|
|
339
|
-
|
|
341
|
+
{}
|
|
340
342
|
end
|
|
341
343
|
end
|
|
342
344
|
end
|
|
@@ -674,6 +674,7 @@ module Legion
|
|
|
674
674
|
log.debug "[llm][executor] action=native_tool_loop.complete rounds=#{round} reason=no_tool_calls"
|
|
675
675
|
return result
|
|
676
676
|
end
|
|
677
|
+
return client_passthrough_tool_loop_result(result, tool_calls, round) if tool_calls.any? { |tool_call| client_passthrough_tool_call?(tool_call) }
|
|
677
678
|
|
|
678
679
|
round += 1
|
|
679
680
|
tool_names = tool_calls.map { |tc| tc[:name] }.join(',')
|
|
@@ -697,6 +698,7 @@ module Legion
|
|
|
697
698
|
Array(@request.tools).each { |tool| add_native_tool_definition(definitions, tool) }
|
|
698
699
|
add_registry_tool_definitions(definitions) if registry_tool_injection_requested?
|
|
699
700
|
log.debug "[llm][executor] action=native_tool_definitions.built count=#{definitions.size}"
|
|
701
|
+
log_native_tool_definitions(definitions)
|
|
700
702
|
definitions
|
|
701
703
|
end
|
|
702
704
|
end
|
|
@@ -728,9 +730,7 @@ module Legion
|
|
|
728
730
|
end
|
|
729
731
|
|
|
730
732
|
def add_registry_tool_definitions(definitions)
|
|
731
|
-
return unless
|
|
732
|
-
Legion::Settings::Extensions.respond_to?(:filter_tools)
|
|
733
|
-
return unless Array(Legion::Settings::Extensions.tools).any? || @triggered_tools.any?
|
|
733
|
+
return unless registry_tool_sources_available?
|
|
734
734
|
|
|
735
735
|
add_settings_extensions_tool_definitions(definitions)
|
|
736
736
|
rescue StandardError => e
|
|
@@ -841,7 +841,7 @@ module Legion
|
|
|
841
841
|
else
|
|
842
842
|
{}
|
|
843
843
|
end
|
|
844
|
-
normalized[:arguments]
|
|
844
|
+
normalized[:arguments] = normalize_tool_arguments(normalized[:arguments])
|
|
845
845
|
normalized[:id] ||= "call_#{SecureRandom.hex(12)}"
|
|
846
846
|
normalized
|
|
847
847
|
end
|
|
@@ -127,11 +127,12 @@ module Legion
|
|
|
127
127
|
def estimate_utilization
|
|
128
128
|
return 0.0 if @request.tokens[:max].nil? || @request.tokens[:max].zero?
|
|
129
129
|
|
|
130
|
-
message_tokens = @request.messages.sum { |m| (m
|
|
130
|
+
message_tokens = @request.messages.sum { |m| content_text(message_content(m)).length / 4 }
|
|
131
131
|
message_tokens.to_f / @request.tokens[:max]
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
def trivial_query?(query)
|
|
135
|
+
query = content_text(query)
|
|
135
136
|
max_chars = rag_setting(:trivial_max_chars, 20)
|
|
136
137
|
patterns = rag_setting(:trivial_patterns, [])
|
|
137
138
|
|
|
@@ -247,7 +248,34 @@ module Legion
|
|
|
247
248
|
|
|
248
249
|
def extract_query
|
|
249
250
|
@request.messages.select { |m| Legion::LLM::Settings.config_value(m, :role).to_s == 'user' }
|
|
250
|
-
.then { |messages|
|
|
251
|
+
.then { |messages| content_text(message_content(messages.last)) }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def message_content(message)
|
|
255
|
+
Legion::LLM::Settings.config_value(message, :content)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def content_text(content)
|
|
259
|
+
case content
|
|
260
|
+
when nil
|
|
261
|
+
''
|
|
262
|
+
when String
|
|
263
|
+
content
|
|
264
|
+
when Array
|
|
265
|
+
content.filter_map { |entry| content_text(entry) }.join
|
|
266
|
+
when Hash
|
|
267
|
+
type = content[:type] || content['type']
|
|
268
|
+
return '' unless type.nil? || type.to_s == 'text'
|
|
269
|
+
|
|
270
|
+
text = if content.key?(:text) || content.key?('text')
|
|
271
|
+
content[:text] || content['text']
|
|
272
|
+
else
|
|
273
|
+
content[:content] || content['content']
|
|
274
|
+
end
|
|
275
|
+
content_text(text)
|
|
276
|
+
else
|
|
277
|
+
content.respond_to?(:text) ? content.text.to_s : content.to_s
|
|
278
|
+
end
|
|
251
279
|
end
|
|
252
280
|
|
|
253
281
|
def apply_gaia_context_limit(limit, strategy:)
|
|
@@ -17,7 +17,7 @@ module Legion
|
|
|
17
17
|
access_token private_key secret_key auth_token credential
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
|
-
def step_sticky_persist # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
20
|
+
def step_sticky_persist # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
21
21
|
return unless sticky_persist_ready?
|
|
22
22
|
|
|
23
23
|
conv_id = @request.conversation_id
|
|
@@ -100,7 +100,7 @@ module Legion
|
|
|
100
100
|
tool: entry[:tool_name],
|
|
101
101
|
runner: runner_key,
|
|
102
102
|
turn: @sticky_turn_snapshot,
|
|
103
|
-
args: sanitize_args(truncate_args(entry[:args]
|
|
103
|
+
args: sanitize_args(truncate_args(normalize_history_args(entry[:args]))),
|
|
104
104
|
result: entry[:result].to_s[0, max_result_length],
|
|
105
105
|
error: entry[:error] || false
|
|
106
106
|
}
|
|
@@ -162,6 +162,25 @@ module Legion
|
|
|
162
162
|
end
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
+
def normalize_history_args(args)
|
|
166
|
+
case args
|
|
167
|
+
when nil
|
|
168
|
+
{}
|
|
169
|
+
when Hash
|
|
170
|
+
args
|
|
171
|
+
when String
|
|
172
|
+
return {} if args.strip.empty?
|
|
173
|
+
|
|
174
|
+
parsed = Legion::JSON.parse(args)
|
|
175
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
176
|
+
else
|
|
177
|
+
args.respond_to?(:to_h) ? args.to_h : {}
|
|
178
|
+
end
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
handle_exception(e, level: :debug, handled: true, operation: 'llm.pipeline.step_sticky_persist.normalize_args')
|
|
181
|
+
{}
|
|
182
|
+
end
|
|
183
|
+
|
|
165
184
|
def sanitize_args(args)
|
|
166
185
|
args.each_with_object({}) do |(k, v), h|
|
|
167
186
|
h[k] = SENSITIVE_PARAM_NAMES.include?(k.to_s.downcase) ? '[REDACTED]' : v
|
|
@@ -32,6 +32,20 @@ module Legion
|
|
|
32
32
|
source = find_tool_source(tool_name)
|
|
33
33
|
next unless source
|
|
34
34
|
|
|
35
|
+
if client_passthrough_source?(source)
|
|
36
|
+
log.info(
|
|
37
|
+
"[llm][tools] client_passthrough request_id=#{@request.id} " \
|
|
38
|
+
"tool_call_id=#{tool_call_id || 'none'} name=#{tool_name}"
|
|
39
|
+
)
|
|
40
|
+
log_step_debug(
|
|
41
|
+
:tool_calls,
|
|
42
|
+
:client_passthrough,
|
|
43
|
+
tool_call_id: tool_call_id || 'none',
|
|
44
|
+
tool_name: tool_name
|
|
45
|
+
)
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
35
49
|
# Skip builtin tools; native providers handle provider-owned tools.
|
|
36
50
|
if source[:type] == :builtin
|
|
37
51
|
log.info(
|
|
@@ -123,6 +137,102 @@ module Legion
|
|
|
123
137
|
{ type: :builtin }
|
|
124
138
|
end
|
|
125
139
|
|
|
140
|
+
def client_passthrough_source?(source)
|
|
141
|
+
source[:type] == :client && source[:executable] != true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def client_passthrough_tool_call?(tool_call)
|
|
145
|
+
client_passthrough_source?(find_tool_source(tool_call[:name]))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def client_passthrough_tool_loop_result(result, tool_calls, round)
|
|
149
|
+
result[:tool_calls] = tool_calls
|
|
150
|
+
log.debug "[llm][executor] action=native_tool_loop.complete rounds=#{round} reason=client_passthrough"
|
|
151
|
+
result
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_tool_arguments(arguments)
|
|
155
|
+
case arguments
|
|
156
|
+
when nil
|
|
157
|
+
{}
|
|
158
|
+
when Hash
|
|
159
|
+
arguments
|
|
160
|
+
when String
|
|
161
|
+
return {} if arguments.strip.empty?
|
|
162
|
+
|
|
163
|
+
parsed = Legion::JSON.parse(arguments)
|
|
164
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
165
|
+
else
|
|
166
|
+
arguments.respond_to?(:to_h) ? arguments.to_h : {}
|
|
167
|
+
end
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
handle_exception(e, level: :debug, handled: true, operation: 'llm.pipeline.normalize_tool_arguments')
|
|
170
|
+
{}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def registry_tool_sources_available?
|
|
174
|
+
unless Legion::Settings::Extensions.respond_to?(:tools) &&
|
|
175
|
+
Legion::Settings::Extensions.respond_to?(:filter_tools)
|
|
176
|
+
log_tool_injection_skip(:settings_extensions_unavailable)
|
|
177
|
+
return false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
settings_tool_count = Array(Legion::Settings::Extensions.tools).size
|
|
181
|
+
if settings_tool_count.zero? && @triggered_tools.empty?
|
|
182
|
+
log_tool_injection_skip(:no_settings_or_triggered_tools, settings_tool_count: settings_tool_count)
|
|
183
|
+
return false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def log_tool_injection_skip(reason, settings_tool_count: nil)
|
|
190
|
+
log.info(
|
|
191
|
+
"[llm][tools][inject] action=registry_skipped request_id=#{request_log_value(:id, 'unknown')} " \
|
|
192
|
+
"conversation_id=#{request_log_value(:conversation_id, 'none') || 'none'} reason=#{reason} " \
|
|
193
|
+
"settings_tools=#{settings_tool_count || 'unknown'} triggered_tools=#{@triggered_tools.size} " \
|
|
194
|
+
"requested_tools=#{requested_deferred_tool_names.size}"
|
|
195
|
+
)
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
handle_exception(e, level: :debug, handled: true, operation: 'llm.pipeline.log_tool_injection_skip')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def log_native_tool_definitions(definitions)
|
|
201
|
+
log.info(
|
|
202
|
+
"[llm][tools][inject] action=native_tool_definitions request_id=#{request_log_value(:id, 'unknown')} " \
|
|
203
|
+
"conversation_id=#{request_log_value(:conversation_id, 'none') || 'none'} provider=#{@resolved_provider || 'unknown'} " \
|
|
204
|
+
"model=#{@resolved_model || 'unknown'} total=#{definitions.size} sources=#{format_tool_source_counts(definitions)} " \
|
|
205
|
+
"client_request_tools=#{Array(request_log_value(:tools, [])).size} triggered_tools=#{@triggered_tools.size} " \
|
|
206
|
+
"requested_tools=#{requested_deferred_tool_names.size} names=#{format_tool_names(definitions.map(&:name))}"
|
|
207
|
+
)
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
handle_exception(e, level: :debug, handled: true, operation: 'llm.pipeline.log_native_tool_definitions')
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def format_tool_source_counts(definitions)
|
|
213
|
+
counts = definitions.each_with_object(Hash.new(0)) do |definition, memo|
|
|
214
|
+
source = definition.respond_to?(:source) ? definition.source : {}
|
|
215
|
+
key = source.is_a?(Hash) ? (source[:type] || source['type'] || :unknown) : :unknown
|
|
216
|
+
memo[key] += 1
|
|
217
|
+
end
|
|
218
|
+
return 'none' if counts.empty?
|
|
219
|
+
|
|
220
|
+
counts.map { |key, count| "#{key}:#{count}" }.join(',')
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def format_tool_names(names, limit = 30)
|
|
224
|
+
names = Array(names).map(&:to_s).reject(&:empty?)
|
|
225
|
+
return 'none' if names.empty?
|
|
226
|
+
|
|
227
|
+
visible = names.first(limit)
|
|
228
|
+
suffix = names.size > limit ? ",+#{names.size - limit}more" : ''
|
|
229
|
+
"#{visible.join(',')}#{suffix}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def request_log_value(method_name, fallback)
|
|
233
|
+
@request.respond_to?(method_name) ? @request.public_send(method_name) : fallback
|
|
234
|
+
end
|
|
235
|
+
|
|
126
236
|
def describe_tool_source(source)
|
|
127
237
|
case source[:type]
|
|
128
238
|
when :mcp
|
data/lib/legion/llm/version.rb
CHANGED