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 +4 -4
- data/CHANGELOG.md +20 -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/context/curator.rb +4 -2
- data/lib/legion/llm/discovery/system.rb +0 -3
- data/lib/legion/llm/inference/conversation.rb +35 -3
- data/lib/legion/llm/inference/executor.rb +4 -4
- data/lib/legion/llm/inference/steps/rag_context.rb +42 -7
- 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/metering.rb +6 -6
- 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: ce44e3d928a848ab67e5cd50574c7454ff3490a455c1d040c7089641e1091e5e
|
|
4
|
+
data.tar.gz: ef3eaa05c9340b08f94af99c7b4f35334cef2a1d8dd09aeafb3535532840b4ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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|
|
|
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
|
|
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/metering.rb
CHANGED
|
@@ -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 =
|
|
145
|
+
usage = extract_hash_value(response, :usage) || {}
|
|
146
146
|
{
|
|
147
|
-
input_tokens:
|
|
148
|
-
output_tokens:
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
extract_hash_value(extract_hash_value(response, :meta), :model) || extract_hash_value(response, :model)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
def
|
|
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
|
data/lib/legion/llm/version.rb
CHANGED