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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc06bf006614fbb7a1052b368912ef58744e5b6509680c163c1b7ce0009e87d8
4
- data.tar.gz: 91c96c5fee56c7fb4804ea3d58eb908cec12539a75eba11348e842be26509343
3
+ metadata.gz: 206afbe8609bb8ed7df111d216967aafba55b0d523d5939aad024f169e43f5ef
4
+ data.tar.gz: 283f42c3d5b9ba07aa7857aad3e6d1f30b7559f35282f4a1d00986ea4ab2c646
5
5
  SHA512:
6
- metadata.gz: fc3d9e91c66c0128bde4ffc434c19e6e75a21cb14a74cafe206235750ef402380a250c928358dfc7702f11f7259ffc46e5976a2abcf360c508bf8ceb7dca6033
7
- data.tar.gz: 5d1a42619cc8e0d6e086b82245356b69790df2f7601313046e5d09e301ada70483f83ab53b4f6e592f5294c94f631904c7d46847131981542db173950e8e408c
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
- log.debug("[llm][api][inference] action=tools_built client_tools=#{tool_declarations.size}")
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
- arguments
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 Legion::Settings::Extensions.respond_to?(:tools) &&
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[:content]&.length || 0) / 4 }
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| Legion::LLM::Settings.config_value(messages.last, :content) }
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,Metrics/PerceivedComplexity
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.9.14'
5
+ VERSION = '0.9.15'
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.9.14
4
+ version: 0.9.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity