legion-llm 0.9.14 → 0.9.17

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: ce44e3d928a848ab67e5cd50574c7454ff3490a455c1d040c7089641e1091e5e
4
+ data.tar.gz: ef3eaa05c9340b08f94af99c7b4f35334cef2a1d8dd09aeafb3535532840b4ce
5
5
  SHA512:
6
- metadata.gz: fc3d9e91c66c0128bde4ffc434c19e6e75a21cb14a74cafe206235750ef402380a250c928358dfc7702f11f7259ffc46e5976a2abcf360c508bf8ceb7dca6033
7
- data.tar.gz: 5d1a42619cc8e0d6e086b82245356b69790df2f7601313046e5d09e301ada70483f83ab53b4f6e592f5294c94f631904c7d46847131981542db173950e8e408c
6
+ metadata.gz: 1dc635c864ac647911bc6d55a34209f10273471b63683ab4eaa7dc69fdee7d3047c6b028b9d5180688b0b7ed4624c89fa90e5a7745f2462cb30371fe84607a11
7
+ data.tar.gz: ddd2e32d57a9a56d1fff22c4b7e423145d743183efd7044290482612b704b4c787db9790d28733df53e69a90ed57e4af1d7f6bef89f069ad3a25fafbe09b63ae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.9.17] - 2026-05-11
4
+
5
+ ### Fixed
6
+ - `total_memory_mb` now fetched exactly once on first access and never re-fetched; hardware memory is static so repeated `sysctl` calls every 60s were wasteful. `refresh!` only clears the available memory cache; `reset!` still clears everything (for tests).
7
+ - `trivial_query?` now correctly identifies short/trivial messages: a query is trivial if it matches a known trivial pattern (exact normalized match), or if no custom patterns are configured and the query is short (under `trivial_max_chars`) and a single word. Previously, an empty patterns list caused `.any?` to always return false, so nothing was ever trivial.
8
+ - Added `trivial_patterns` helper with configurable defaults (`ping`, `pong`, `ding`, `test`, `foobar`) readable via `rag.trivial_patterns` setting; when custom patterns are explicitly configured, the short-query heuristic is disabled so only listed patterns are treated as trivial.
9
+
10
+ ## [0.9.16] - 2026-05-11
11
+
12
+ ### Fixed
13
+ - Renamed `Metering#settings_value` to `extract_hash_value` to fix method shadowing with `Legion::Logging::Helper#settings_value`, which resolves a `wrong number of arguments (given 3, expected 2)` error raised from `instance_log_level` when metering is active.
14
+
15
+ ## [0.9.15] - 2026-05-08
16
+
17
+ ### Fixed
18
+ - Normalize structured user message content before RAG query handling, preventing multipart API messages from crashing trivial-query detection or reaching Apollo as arrays.
19
+ - Normalize empty string tool-call arguments to `{}` and make sticky tool history tolerate non-hash argument payloads without dropping state writes.
20
+ - 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.
21
+ - Add runtime logging for client tool receipt, native tool injection summaries, registry-injection skips, and returned tool-call SSE emission.
22
+
3
23
  ## [0.9.14] - 2026-05-08
4
24
 
5
25
  ### 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
@@ -247,11 +247,13 @@ module Legion
247
247
  def load_curated(conversation_id)
248
248
  return nil unless Inference::Conversation.conversation_exists?(conversation_id)
249
249
 
250
- raw = Inference::Conversation.messages(conversation_id)
250
+ # Use raw_messages so CURATED_ROLE entries are visible even though they
251
+ # are filtered out of the public-facing Conversation#messages array.
252
+ raw = Inference::Conversation.raw_messages(conversation_id)
251
253
  curated_entries = raw.select { |m| m[:role] == CURATED_KEY }
252
254
  return nil if curated_entries.empty?
253
255
 
254
- regular = raw.reject { |m| m[:role] == CURATED_KEY }
256
+ regular = raw.reject { |m| [CURATED_KEY, Inference::Conversation::METADATA_ROLE].include?(m[:role]) }
255
257
  summaries = normalized_curated_summaries(curated_entries)
256
258
  if summaries.empty?
257
259
  apply_curation_pipeline(regular)
@@ -31,9 +31,7 @@ module Legion
31
31
  end
32
32
 
33
33
  def refresh!
34
- @total_fetched_at = nil
35
34
  @available_fetched_at = nil
36
- @total_memory_mb = nil
37
35
  @available_memory_mb = nil
38
36
  @last_refreshed_at = Time.now
39
37
  end
@@ -57,7 +55,6 @@ module Legion
57
55
  private
58
56
 
59
57
  def ensure_total_fresh
60
- refresh! if stale?
61
58
  return unless @total_fetched_at.nil?
62
59
 
63
60
  fetch_total
@@ -11,6 +11,7 @@ module Legion
11
11
 
12
12
  MAX_CONVERSATIONS = 256
13
13
  METADATA_ROLE = :__metadata__
14
+ CURATED_ROLE = :__curated__
14
15
 
15
16
  class << self
16
17
  def append(conversation_id, role:, content:, parent_id: nil, sidechain: false,
@@ -38,29 +39,41 @@ module Legion
38
39
 
39
40
  # Returns flat ordered message array — backward-compatible.
40
41
  # Uses chain reconstruction when parent links exist; falls back to seq order.
42
+ # Internal-only roles (__metadata__, __curated__) are filtered out.
41
43
  def messages(conversation_id)
42
44
  if in_memory?(conversation_id)
43
45
  touch(conversation_id)
44
- raw = conversations[conversation_id][:messages].reject { |m| m[:role] == METADATA_ROLE }
46
+ raw = conversations[conversation_id][:messages].reject { |m| internal_role?(m[:role]) }
45
47
  chain_or_seq(raw)
46
48
  else
47
49
  load_from_db(conversation_id)
48
50
  end
49
51
  end
50
52
 
53
+ # Returns ALL messages including internal-role entries (__metadata__, __curated__).
54
+ # Use this when you need access to curation markers or metadata entries.
55
+ def raw_messages(conversation_id)
56
+ if in_memory?(conversation_id)
57
+ touch(conversation_id)
58
+ conversations[conversation_id][:messages].dup
59
+ else
60
+ load_all_from_db(conversation_id)
61
+ end
62
+ end
63
+
51
64
  # Build ordered chain from parent links.
52
65
  # Excludes sidechain messages by default.
53
66
  def build_chain(conversation_id, include_sidechains: false)
54
67
  raw = all_raw_messages(conversation_id)
55
68
  raw = raw.reject { |m| m[:sidechain] } unless include_sidechains
56
- raw = raw.reject { |m| m[:role] == METADATA_ROLE }
69
+ raw = raw.reject { |m| internal_role?(m[:role]) }
57
70
  reconstruct_chain(raw)
58
71
  end
59
72
 
60
73
  # Return sidechain messages; optionally filter by agent_id.
61
74
  def sidechain_messages(conversation_id, agent_id: nil)
62
75
  raw = all_raw_messages(conversation_id)
63
- result = raw.select { |m| m[:sidechain] && m[:role] != METADATA_ROLE }
76
+ result = raw.select { |m| m[:sidechain] && !internal_role?(m[:role]) }
64
77
  result = result.select { |m| m[:agent_id] == agent_id } unless agent_id.nil?
65
78
  result.sort_by { |m| m[:seq] }
66
79
  end
@@ -243,6 +256,12 @@ module Legion
243
256
 
244
257
  private
245
258
 
259
+ # Returns true for roles that are internal bookkeeping and should not
260
+ # appear in the public-facing message array returned by #messages.
261
+ def internal_role?(role)
262
+ [METADATA_ROLE, CURATED_ROLE].include?(role)
263
+ end
264
+
246
265
  def conversations
247
266
  @conversations ||= {}
248
267
  end
@@ -543,9 +562,22 @@ module Legion
543
562
  .where(conversation_id: conversation_id)
544
563
  .order(:seq)
545
564
  .map { |row| symbolize_message(row) }
565
+ .reject { |m| internal_role?(m[:role]) }
546
566
  chain_or_seq(rows)
547
567
  end
548
568
 
569
+ def load_all_from_db(conversation_id)
570
+ return [] unless db_available?
571
+
572
+ Legion::Data.connection[:conversation_messages]
573
+ .where(conversation_id: conversation_id)
574
+ .order(:seq)
575
+ .map { |row| symbolize_message(row) }
576
+ rescue StandardError => e
577
+ handle_exception(e, level: :debug)
578
+ []
579
+ end
580
+
549
581
  def db_conversation_record?(conversation_id)
550
582
  Legion::Data.connection[:conversations].where(id: conversation_id).any?
551
583
  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,18 +127,25 @@ 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
- patterns = rag_setting(:trivial_patterns, [])
137
-
138
- return false if query.length > max_chars
137
+ configured_patterns = rag_setting(:trivial_patterns)
139
138
 
140
139
  normalized = query.strip.downcase.gsub(/[^a-z0-9\s]/, '')
141
- patterns.any? { |p| normalized == p }
140
+ patterns = configured_patterns || trivial_patterns
141
+ return true if patterns.any? { |p| normalized == p }
142
+ return true if configured_patterns.nil? && query.length <= max_chars && normalized.split.length <= 1
143
+
144
+ false
145
+ end
146
+
147
+ def trivial_patterns
148
+ rag_setting(:trivial_patterns, %w[ping pong ding test foobar])
142
149
  end
143
150
 
144
151
  def apollo_available?
@@ -247,7 +254,34 @@ module Legion
247
254
 
248
255
  def extract_query
249
256
  @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) }
257
+ .then { |messages| content_text(message_content(messages.last)) }
258
+ end
259
+
260
+ def message_content(message)
261
+ Legion::LLM::Settings.config_value(message, :content)
262
+ end
263
+
264
+ def content_text(content)
265
+ case content
266
+ when nil
267
+ ''
268
+ when String
269
+ content
270
+ when Array
271
+ content.filter_map { |entry| content_text(entry) }.join
272
+ when Hash
273
+ type = content[:type] || content['type']
274
+ return '' unless type.nil? || type.to_s == 'text'
275
+
276
+ text = if content.key?(:text) || content.key?('text')
277
+ content[:text] || content['text']
278
+ else
279
+ content[:content] || content['content']
280
+ end
281
+ content_text(text)
282
+ else
283
+ content.respond_to?(:text) ? content.text.to_s : content.to_s
284
+ end
251
285
  end
252
286
 
253
287
  def apply_gaia_context_limit(limit, strategy:)
@@ -286,7 +320,8 @@ module Legion
286
320
  def positive_integer(value)
287
321
  integer = Integer(value)
288
322
  integer.positive? ? integer : nil
289
- rescue ArgumentError, TypeError
323
+ rescue ArgumentError, TypeError => e
324
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.pipeline.steps.rag_context.positive_integer')
290
325
  nil
291
326
  end
292
327
  end
@@ -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
@@ -142,26 +142,26 @@ module Legion
142
142
  def extract_usage(response)
143
143
  return { input_tokens: 0, output_tokens: 0 } unless response.is_a?(Hash)
144
144
 
145
- usage = settings_value(response, :usage) || {}
145
+ usage = extract_hash_value(response, :usage) || {}
146
146
  {
147
- input_tokens: settings_value(usage, :input_tokens) || settings_value(usage, :prompt_tokens) || 0,
148
- output_tokens: settings_value(usage, :output_tokens) || settings_value(usage, :completion_tokens) || 0
147
+ input_tokens: extract_hash_value(usage, :input_tokens) || extract_hash_value(usage, :prompt_tokens) || 0,
148
+ output_tokens: extract_hash_value(usage, :output_tokens) || extract_hash_value(usage, :completion_tokens) || 0
149
149
  }
150
150
  end
151
151
 
152
152
  def extract_provider(response)
153
153
  return nil unless response.is_a?(Hash)
154
154
 
155
- settings_value(settings_value(response, :meta), :provider) || settings_value(response, :provider)
155
+ extract_hash_value(extract_hash_value(response, :meta), :provider) || extract_hash_value(response, :provider)
156
156
  end
157
157
 
158
158
  def extract_model(response)
159
159
  return nil unless response.is_a?(Hash)
160
160
 
161
- settings_value(settings_value(response, :meta), :model) || settings_value(response, :model)
161
+ extract_hash_value(extract_hash_value(response, :meta), :model) || extract_hash_value(response, :model)
162
162
  end
163
163
 
164
- def settings_value(hash, key)
164
+ def extract_hash_value(hash, key)
165
165
  return nil unless hash.respond_to?(:key?)
166
166
 
167
167
  string_key = key.to_s
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.9.14'
5
+ VERSION = '0.9.17'
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.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity