legion-llm 0.6.29 → 0.6.31
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 +13 -1
- data/lib/legion/llm/conversation_store.rb +23 -3
- data/lib/legion/llm/pipeline/executor.rb +19 -2
- data/lib/legion/llm/pipeline/profile.rb +4 -4
- data/lib/legion/llm/pipeline/steps/trigger_match.rb +111 -0
- data/lib/legion/llm/pipeline/steps.rb +1 -0
- data/lib/legion/llm/settings.rb +9 -1
- data/lib/legion/llm/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a2032854701a258fc788e3b3ef4cd495a2f031765c29775ed1486dd5eb8f35f
|
|
4
|
+
data.tar.gz: 62cd0ed1943b0730be9adb30c6c1b77308f10f9e223cfaea47f13d90b9794b6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e7a3f1e28c2af1d5cc3489a4bbe2592e7e019511920ad0c7c82943a0f444f36500d9707f6cda52f342a9af79922178bb5df249b1f53b477b52a9920ed856e9d
|
|
7
|
+
data.tar.gz: 69d8dc01c5c43a4fa7acfde15e81986e757670127fae2140b22ca77c4d8938ff0d1fa91a61d4c6664269ae147b26416c066ef57ab4fecbc86b605d8541c047c2
|
data/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.31] - 2026-04-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `ConversationStore#db_append_message` — coerce multi-part content blocks (arrays of `{type:, text:}` hashes) to plain string before Sequel insert, preventing `PG::UndefinedColumn` errors
|
|
9
|
+
- `ConversationStore#next_seq` — fall back to DB max seq when in-memory message list is empty, preventing seq collisions after eviction or restart
|
|
10
|
+
|
|
11
|
+
## [0.6.30] - 2026-04-10
|
|
12
|
+
|
|
5
13
|
### Added
|
|
6
|
-
-
|
|
14
|
+
- `Legion::LLM::Pipeline::Steps::TriggerMatch` — pipeline step that matches recent message words against `Legion::Tools::TriggerIndex` and populates `@triggered_tools`
|
|
15
|
+
- `tool_trigger` settings defaults (`scan_depth: 2`, `tool_limit: 10`) in `Legion::LLM::Settings`
|
|
16
|
+
- Trigger-matched tools are injected into the RubyLLM session in `inject_registry_tools` after always-loaded tools
|
|
17
|
+
- `:trigger_match` step inserted between `:rag_context` and `:tool_discovery` in `STEPS` and `PRE_PROVIDER_STEPS`
|
|
18
|
+
- `:trigger_match` added to all profile skip lists that skip `:tool_discovery` (`GAIA_SKIP`, `SYSTEM_SKIP`, `QUICK_REPLY_SKIP`, `SERVICE_SKIP`)
|
|
7
19
|
|
|
8
20
|
## [0.6.29] - 2026-04-09
|
|
9
21
|
|
|
@@ -182,6 +182,16 @@ module Legion
|
|
|
182
182
|
|
|
183
183
|
def next_seq(conversation_id)
|
|
184
184
|
msgs = conversations[conversation_id][:messages]
|
|
185
|
+
if msgs.empty? && db_available?
|
|
186
|
+
begin
|
|
187
|
+
max = Legion::Data.connection[:conversation_messages]
|
|
188
|
+
.where(conversation_id: conversation_id)
|
|
189
|
+
.max(:seq)
|
|
190
|
+
return (max || 0) + 1
|
|
191
|
+
rescue StandardError
|
|
192
|
+
# fall through to default
|
|
193
|
+
end
|
|
194
|
+
end
|
|
185
195
|
msgs.empty? ? 1 : msgs.last[:seq] + 1
|
|
186
196
|
end
|
|
187
197
|
|
|
@@ -373,13 +383,23 @@ module Legion
|
|
|
373
383
|
end
|
|
374
384
|
|
|
375
385
|
def db_append_message(conversation_id, msg)
|
|
376
|
-
content
|
|
377
|
-
|
|
386
|
+
# Coerce content to plain string — content may arrive as an array of
|
|
387
|
+
# multi-part blocks (e.g. [{type: "text", text: "..."}]) which Sequel
|
|
388
|
+
# would misinterpret as a filter expression, causing PG::UndefinedColumn.
|
|
389
|
+
raw_content = msg[:content]
|
|
390
|
+
coerced_content = if raw_content.is_a?(Array)
|
|
391
|
+
raw_content.filter_map do |b|
|
|
392
|
+
b.is_a?(Hash) ? (b[:text] || b['text']) : b.to_s
|
|
393
|
+
end.join
|
|
394
|
+
else
|
|
395
|
+
raw_content.to_s
|
|
396
|
+
end
|
|
397
|
+
|
|
378
398
|
row = {
|
|
379
399
|
conversation_id: conversation_id,
|
|
380
400
|
seq: msg[:seq],
|
|
381
401
|
role: msg[:role].to_s,
|
|
382
|
-
content:
|
|
402
|
+
content: coerced_content,
|
|
383
403
|
provider: msg[:provider]&.to_s,
|
|
384
404
|
model: msg[:model]&.to_s,
|
|
385
405
|
input_tokens: msg[:input_tokens],
|
|
@@ -19,6 +19,7 @@ module Legion
|
|
|
19
19
|
:escalation_chain
|
|
20
20
|
attr_accessor :tool_event_handler
|
|
21
21
|
|
|
22
|
+
include Steps::TriggerMatch
|
|
22
23
|
include Steps::ToolDiscovery
|
|
23
24
|
include Steps::ToolCalls
|
|
24
25
|
include Steps::KnowledgeCapture
|
|
@@ -29,14 +30,14 @@ module Legion
|
|
|
29
30
|
|
|
30
31
|
STEPS = %i[
|
|
31
32
|
tracing_init idempotency conversation_uuid context_load
|
|
32
|
-
rbac classification billing gaia_advisory tier_assignment rag_context tool_discovery
|
|
33
|
+
rbac classification billing gaia_advisory tier_assignment rag_context trigger_match tool_discovery
|
|
33
34
|
routing request_normalization token_budget provider_call response_normalization
|
|
34
35
|
debate confidence_scoring tool_calls context_store post_response knowledge_capture response_return
|
|
35
36
|
].freeze
|
|
36
37
|
|
|
37
38
|
PRE_PROVIDER_STEPS = %i[
|
|
38
39
|
tracing_init idempotency conversation_uuid context_load
|
|
39
|
-
rbac classification billing gaia_advisory tier_assignment rag_context tool_discovery
|
|
40
|
+
rbac classification billing gaia_advisory tier_assignment rag_context trigger_match tool_discovery
|
|
40
41
|
routing request_normalization token_budget
|
|
41
42
|
].freeze
|
|
42
43
|
|
|
@@ -62,6 +63,7 @@ module Legion
|
|
|
62
63
|
@raw_response = nil
|
|
63
64
|
@exchange_id = nil
|
|
64
65
|
@discovered_tools = []
|
|
66
|
+
@triggered_tools = []
|
|
65
67
|
@resolved_provider = nil
|
|
66
68
|
@resolved_model = nil
|
|
67
69
|
@confidence_score = nil
|
|
@@ -102,6 +104,20 @@ module Legion
|
|
|
102
104
|
handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_always_tool')
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
# Trigger-matched tools — inject tools surfaced by trigger word matching
|
|
108
|
+
if @triggered_tools.any?
|
|
109
|
+
@triggered_tools.each do |tool_class|
|
|
110
|
+
adapter = ToolAdapter.new(tool_class)
|
|
111
|
+
next if injected_names.include?(adapter.name)
|
|
112
|
+
|
|
113
|
+
session.with_tool(adapter)
|
|
114
|
+
injected_names << adapter.name
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
@warnings << "Failed to inject triggered tool: #{e.message}"
|
|
117
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.inject_triggered_tool')
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
105
121
|
# Requested deferred tools — inject only if explicitly requested
|
|
106
122
|
deferred = ::Legion::Tools::Registry.respond_to?(:deferred_tools) ? ::Legion::Tools::Registry.deferred_tools : []
|
|
107
123
|
requested = requested_deferred_tool_names
|
|
@@ -121,6 +137,7 @@ module Legion
|
|
|
121
137
|
log.info(
|
|
122
138
|
"[llm][tools] inject request_id=#{@request.id} " \
|
|
123
139
|
"always=#{::Legion::Tools::Registry.tools.size} " \
|
|
140
|
+
"triggered=#{@triggered_tools.size} " \
|
|
124
141
|
"deferred_available=#{deferred.size} " \
|
|
125
142
|
"requested_deferred=#{requested.size} " \
|
|
126
143
|
"injected=#{injected_names.size} names=#{injected_names.first(25).join(',')}"
|
|
@@ -6,18 +6,18 @@ module Legion
|
|
|
6
6
|
module Profile
|
|
7
7
|
GAIA_SKIP = %i[
|
|
8
8
|
idempotency conversation_uuid context_load rbac classification
|
|
9
|
-
billing gaia_advisory tool_discovery context_store post_response
|
|
9
|
+
billing gaia_advisory trigger_match tool_discovery context_store post_response
|
|
10
10
|
].freeze
|
|
11
11
|
|
|
12
12
|
SYSTEM_SKIP = %i[
|
|
13
13
|
idempotency conversation_uuid context_load rbac classification
|
|
14
|
-
billing gaia_advisory rag_context tool_discovery context_store
|
|
14
|
+
billing gaia_advisory rag_context trigger_match tool_discovery context_store
|
|
15
15
|
post_response
|
|
16
16
|
].freeze
|
|
17
17
|
|
|
18
18
|
QUICK_REPLY_SKIP = %i[
|
|
19
19
|
idempotency conversation_uuid context_load classification
|
|
20
|
-
gaia_advisory rag_context tool_discovery confidence_scoring
|
|
20
|
+
gaia_advisory rag_context trigger_match tool_discovery confidence_scoring
|
|
21
21
|
tool_calls context_store post_response knowledge_capture
|
|
22
22
|
].freeze
|
|
23
23
|
|
|
@@ -25,7 +25,7 @@ module Legion
|
|
|
25
25
|
|
|
26
26
|
SERVICE_SKIP = %i[
|
|
27
27
|
conversation_uuid context_load gaia_advisory
|
|
28
|
-
rag_context tool_discovery confidence_scoring
|
|
28
|
+
rag_context trigger_match tool_discovery confidence_scoring
|
|
29
29
|
tool_calls context_store knowledge_capture
|
|
30
30
|
].freeze
|
|
31
31
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Pipeline
|
|
8
|
+
module Steps
|
|
9
|
+
module TriggerMatch
|
|
10
|
+
include Legion::Logging::Helper
|
|
11
|
+
|
|
12
|
+
def step_trigger_match
|
|
13
|
+
start_time = nil
|
|
14
|
+
return unless defined?(::Legion::Tools::TriggerIndex)
|
|
15
|
+
return if ::Legion::Tools::TriggerIndex.empty?
|
|
16
|
+
|
|
17
|
+
start_time = ::Time.now
|
|
18
|
+
|
|
19
|
+
text = extract_recent_text
|
|
20
|
+
word_set = normalize_message_words(text)
|
|
21
|
+
return if word_set.empty?
|
|
22
|
+
|
|
23
|
+
matched, per_word = ::Legion::Tools::TriggerIndex.match(word_set)
|
|
24
|
+
subtract_always_loaded(matched)
|
|
25
|
+
return if matched.empty?
|
|
26
|
+
|
|
27
|
+
limit = trigger_tool_limit
|
|
28
|
+
@triggered_tools = if matched.size <= limit
|
|
29
|
+
matched.to_a
|
|
30
|
+
else
|
|
31
|
+
rank_and_cap(matched, per_word, limit)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if @triggered_tools.any?
|
|
35
|
+
names = @triggered_tools.map(&:tool_name)
|
|
36
|
+
@enrichments['tool:trigger_match'] = {
|
|
37
|
+
content: "#{@triggered_tools.size} tools matched via trigger words",
|
|
38
|
+
data: { tool_count: @triggered_tools.size, tool_names: names },
|
|
39
|
+
timestamp: ::Time.now
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
record_trigger_match_timeline(@triggered_tools.size, start_time)
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
@warnings << "Trigger match error: #{e.message}"
|
|
46
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.trigger_match')
|
|
47
|
+
record_trigger_match_timeline(0, start_time)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def extract_recent_text
|
|
53
|
+
depth = trigger_scan_depth
|
|
54
|
+
messages = @request.messages.last(depth)
|
|
55
|
+
messages.map do |msg|
|
|
56
|
+
if msg.is_a?(Hash)
|
|
57
|
+
msg[:content] || msg['content'] || msg.to_s
|
|
58
|
+
else
|
|
59
|
+
msg.to_s
|
|
60
|
+
end
|
|
61
|
+
end.join(' ')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_message_words(text)
|
|
65
|
+
return Set.new if text.nil? || text.empty?
|
|
66
|
+
|
|
67
|
+
text.downcase.gsub(/[^a-z ]/, ' ').split.to_set
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rank_and_cap(matched, per_word, limit)
|
|
71
|
+
scores = Hash.new(0)
|
|
72
|
+
per_word.each_value do |tools|
|
|
73
|
+
tools.each { |tool| scores[tool] += 1 }
|
|
74
|
+
end
|
|
75
|
+
matched.to_a
|
|
76
|
+
.sort_by { |tool| [-scores[tool], tool.tool_name] }
|
|
77
|
+
.first(limit)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def subtract_always_loaded(matched)
|
|
81
|
+
return unless defined?(::Legion::Tools::Registry) &&
|
|
82
|
+
::Legion::Tools::Registry.respond_to?(:always_loaded_names)
|
|
83
|
+
|
|
84
|
+
always = ::Legion::Tools::Registry.always_loaded_names
|
|
85
|
+
matched.reject! { |tool| always.include?(tool.tool_name) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def trigger_scan_depth
|
|
89
|
+
Legion::Settings.dig(:llm, :tool_trigger, :scan_depth) || 2
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def trigger_tool_limit
|
|
93
|
+
Legion::Settings.dig(:llm, :tool_trigger, :tool_limit) || 10
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record_trigger_match_timeline(count, start_time = nil)
|
|
97
|
+
return unless @timeline.respond_to?(:record)
|
|
98
|
+
|
|
99
|
+
duration = start_time ? ((::Time.now - start_time) * 1000).to_i : 0
|
|
100
|
+
@timeline.record(
|
|
101
|
+
category: :enrichment, key: 'tool:trigger_match',
|
|
102
|
+
direction: :inbound, detail: "#{count} tools matched via trigger words",
|
|
103
|
+
from: 'trigger_index', to: 'pipeline',
|
|
104
|
+
duration_ms: duration
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -18,6 +18,7 @@ require_relative 'steps/gaia_advisory'
|
|
|
18
18
|
require_relative 'steps/tier_assigner'
|
|
19
19
|
require_relative 'steps/post_response'
|
|
20
20
|
require_relative 'steps/tool_discovery'
|
|
21
|
+
require_relative 'steps/trigger_match'
|
|
21
22
|
require_relative 'steps/tool_calls'
|
|
22
23
|
require_relative 'steps/rag_context'
|
|
23
24
|
require_relative 'steps/rag_guard'
|
data/lib/legion/llm/settings.rb
CHANGED
|
@@ -33,7 +33,8 @@ module Legion
|
|
|
33
33
|
telemetry: telemetry_defaults,
|
|
34
34
|
context_curation: context_curation_defaults,
|
|
35
35
|
debate: debate_defaults,
|
|
36
|
-
provider_layer: provider_layer_defaults
|
|
36
|
+
provider_layer: provider_layer_defaults,
|
|
37
|
+
tool_trigger: tool_trigger_defaults
|
|
37
38
|
}
|
|
38
39
|
end
|
|
39
40
|
|
|
@@ -226,6 +227,13 @@ module Legion
|
|
|
226
227
|
}
|
|
227
228
|
end
|
|
228
229
|
|
|
230
|
+
def self.tool_trigger_defaults
|
|
231
|
+
{
|
|
232
|
+
scan_depth: 2,
|
|
233
|
+
tool_limit: 10
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
229
237
|
def self.debate_defaults
|
|
230
238
|
{
|
|
231
239
|
enabled: false,
|
data/lib/legion/llm/version.rb
CHANGED
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.6.
|
|
4
|
+
version: 0.6.31
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -300,6 +300,7 @@ files:
|
|
|
300
300
|
- lib/legion/llm/pipeline/steps/token_budget.rb
|
|
301
301
|
- lib/legion/llm/pipeline/steps/tool_calls.rb
|
|
302
302
|
- lib/legion/llm/pipeline/steps/tool_discovery.rb
|
|
303
|
+
- lib/legion/llm/pipeline/steps/trigger_match.rb
|
|
303
304
|
- lib/legion/llm/pipeline/timeline.rb
|
|
304
305
|
- lib/legion/llm/pipeline/tool_adapter.rb
|
|
305
306
|
- lib/legion/llm/pipeline/tool_dispatcher.rb
|