legionio 1.7.36 → 1.7.37

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: 2c0a91d98736f789285e0e29fd43fd40d3ed5ceaf5e050a23049049ff2d579ca
4
- data.tar.gz: 145442ec524abec8224134b81f5117f2da1eac532debb23bc9336cb127fd9d66
3
+ metadata.gz: 2bafa8899bbfa7e80970cec50a67e3989fc259cc426ebbbf7bc531194bd8b0d8
4
+ data.tar.gz: 4e768d2523b3a9b962f8d1119061fe25d71c8e101acb071cef793b40695ebb8c
5
5
  SHA512:
6
- metadata.gz: caa367bf5972fe2370360eaa609c75819b8c43cbd0c0e204f9a4ae92feba428c869eedc95ddc68c29c42fb9402ccdba15fff356843db32a49a6253ef6d957e3a
7
- data.tar.gz: e34c37f8dfd6dd65180d3505d8ac06c7edcc4ea0cda8beed92e65ef45a2518c454ecada439ef3f55228ec9169ebc5989e11a3b22380f0cbb8133ada9c71d2901
6
+ metadata.gz: c20eb127ef11c1f60ad06c42619354a91072a3c79ff103189454c6f8080e60fa1e1b5ad9bdcb77703ccfd3308079117aadad6c7f063f59c5255a01b635d2c3dc
7
+ data.tar.gz: 0bcaa14c6e8c8a3fd0c9b5f47d2cb64abba2cee271cb09eadf812a460f0bfe5069147bb497b205e910fffa71da98702c3889805ef8d18f09cd7d7d8714020c38
data/CHANGELOG.md CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.7.37] - 2026-04-09
6
+
5
7
  ### Added
6
- - register_credential_providers step in boot sequence for Phase 8 credential-only identity module registration with Broker
8
+ - Trigger word tool injection: extensions and runners declare trigger words that auto-promote deferred tools when detected in LLM messages
9
+ - `Legion::Tools::TriggerIndex` — Concurrent::Map-backed reverse index for O(1) trigger word lookup
10
+ - `trigger_words` DSL on Extensions::Core, runner modules, and Tools::Base
7
11
 
8
12
  ## [1.7.36] - 2026-04-09
9
13
 
@@ -314,40 +314,40 @@ module Legion
314
314
  case event[:type]
315
315
  when :tool_call
316
316
  emitted_tool_call_ids << event[:tool_call_id] if event[:tool_call_id]
317
- out << "event: tool-call\ndata: #{Legion::JSON.dump({
318
- toolCallId: event[:tool_call_id],
319
- toolName: event[:tool_name],
320
- args: event[:arguments] || {},
321
- startedAt: event[:started_at]&.iso8601(3),
322
- timestamp: event[:started_at]&.iso8601(3) || Time.now.iso8601(3)
323
- })}\n\n"
317
+ out << "event: tool-call\ndata: #{Legion::JSON.generate({
318
+ toolCallId: event[:tool_call_id],
319
+ toolName: event[:tool_name],
320
+ args: event[:arguments] || {},
321
+ startedAt: event[:started_at]&.iso8601(3),
322
+ timestamp: event[:started_at]&.iso8601(3) || Time.now.iso8601(3)
323
+ })}\n\n"
324
324
  when :tool_result
325
- out << "event: tool-result\ndata: #{Legion::JSON.dump({
326
- toolCallId: event[:tool_call_id],
327
- toolName: event[:tool_name],
328
- result: event[:result],
329
- startedAt: event[:started_at]&.iso8601(3),
330
- finishedAt: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3),
331
- durationMs: event[:duration_ms],
332
- timestamp: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3)
333
- })}\n\n"
325
+ out << "event: tool-result\ndata: #{Legion::JSON.generate({
326
+ toolCallId: event[:tool_call_id],
327
+ toolName: event[:tool_name],
328
+ result: event[:result],
329
+ startedAt: event[:started_at]&.iso8601(3),
330
+ finishedAt: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3),
331
+ durationMs: event[:duration_ms],
332
+ timestamp: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3)
333
+ })}\n\n"
334
334
  when :tool_error
335
- out << "event: tool-error\ndata: #{Legion::JSON.dump({
336
- toolCallId: event[:tool_call_id],
337
- toolName: event[:tool_name],
338
- error: (event[:error] || event[:result]).to_s,
339
- startedAt: event[:started_at]&.iso8601(3),
340
- finishedAt: Time.now.iso8601(3),
341
- timestamp: Time.now.iso8601(3)
342
- })}\n\n"
343
- when :model_fallback
344
- out << "event: model-fallback\ndata: #{Legion::JSON.dump({
345
- fromModel: event[:from_model],
346
- toModel: event[:to_model],
347
- toModelKey: event[:to_model],
348
- error: event[:error] || 'Provider unavailable',
349
- reason: event[:reason] || 'provider_fallback'
335
+ out << "event: tool-error\ndata: #{Legion::JSON.generate({
336
+ toolCallId: event[:tool_call_id],
337
+ toolName: event[:tool_name],
338
+ error: (event[:error] || event[:result]).to_s,
339
+ startedAt: event[:started_at]&.iso8601(3),
340
+ finishedAt: Time.now.iso8601(3),
341
+ timestamp: Time.now.iso8601(3)
350
342
  })}\n\n"
343
+ when :model_fallback
344
+ out << "event: model-fallback\ndata: #{Legion::JSON.generate({
345
+ fromModel: event[:from_model],
346
+ toModel: event[:to_model],
347
+ toModelKey: event[:to_model],
348
+ error: event[:error] || 'Provider unavailable',
349
+ reason: event[:reason] || 'provider_fallback'
350
+ })}\n\n"
351
351
  end
352
352
  end
353
353
 
@@ -357,7 +357,7 @@ module Legion
357
357
  next if text.empty?
358
358
 
359
359
  full_text << text
360
- out << "event: text-delta\ndata: #{Legion::JSON.dump({ delta: text })}\n\n"
360
+ out << "event: text-delta\ndata: #{Legion::JSON.generate({ delta: text })}\n\n"
361
361
  end
362
362
 
363
363
  # Post-hoc safety net: emit any tool-calls that weren't fired in real-time
@@ -367,11 +367,11 @@ module Legion
367
367
  tc_id = tc.respond_to?(:id) ? tc.id : nil
368
368
  next if tc_id && emitted_tool_call_ids.include?(tc_id)
369
369
 
370
- out << "event: tool-call\ndata: #{Legion::JSON.dump({
371
- toolCallId: tc_id,
372
- toolName: tc.respond_to?(:name) ? tc.name : tc.to_s,
373
- args: tc.respond_to?(:arguments) ? tc.arguments : {}
374
- })}\n\n"
370
+ out << "event: tool-call\ndata: #{Legion::JSON.generate({
371
+ toolCallId: tc_id,
372
+ toolName: tc.respond_to?(:name) ? tc.name : tc.to_s,
373
+ args: tc.respond_to?(:arguments) ? tc.arguments : {}
374
+ })}\n\n"
375
375
  end
376
376
  end
377
377
 
@@ -384,20 +384,20 @@ module Legion
384
384
  resolved_model = (model || provider).to_s.strip
385
385
  next if resolved_model.empty?
386
386
 
387
- out << "event: model-fallback\ndata: #{Legion::JSON.dump({
388
- fromModel: pipeline_response.routing&.dig(:model),
389
- toModel: resolved_model,
390
- toModelKey: resolved_model,
391
- error: w[:original_error] || 'Provider unavailable',
392
- reason: 'provider_fallback'
393
- })}\n\n"
387
+ out << "event: model-fallback\ndata: #{Legion::JSON.generate({
388
+ fromModel: pipeline_response.routing&.dig(:model),
389
+ toModel: resolved_model,
390
+ toModelKey: resolved_model,
391
+ error: w[:original_error] || 'Provider unavailable',
392
+ reason: 'provider_fallback'
393
+ })}\n\n"
394
394
  end
395
395
 
396
396
  enrichments = pipeline_response.enrichments
397
- out << "event: enrichment\ndata: #{Legion::JSON.dump(enrichments)}\n\n" if enrichments.is_a?(Hash) && !enrichments.empty?
397
+ out << "event: enrichment\ndata: #{Legion::JSON.generate(enrichments)}\n\n" if enrichments.is_a?(Hash) && !enrichments.empty?
398
398
 
399
399
  tokens = pipeline_response.tokens
400
- out << "event: done\ndata: #{Legion::JSON.dump({
400
+ out << "event: done\ndata: #{Legion::JSON.generate({
401
401
  content: full_text,
402
402
  model: pipeline_response.routing&.dig(:model),
403
403
  conversation_id: pipeline_response.conversation_id,
@@ -409,7 +409,7 @@ module Legion
409
409
  }.compact)}\n\n"
410
410
  rescue StandardError => e
411
411
  Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference stream failed', component_type: :api)
412
- out << "event: error\ndata: #{Legion::JSON.dump({ code: 'stream_error', message: e.message })}\n\n"
412
+ out << "event: error\ndata: #{Legion::JSON.generate({ code: 'stream_error', message: e.message })}\n\n"
413
413
  end
414
414
  else
415
415
  pipeline_response = executor.call
@@ -26,7 +26,7 @@ module Legion
26
26
  end
27
27
 
28
28
  def to_ndjson(records)
29
- export_batch(records).map { |r| Legion::JSON.dump(r) }.join("\n")
29
+ export_batch(records).map { |r| Legion::JSON.generate(r) }.join("\n")
30
30
  end
31
31
  end
32
32
  end
@@ -21,41 +21,44 @@ module Legion
21
21
  def build_runner_list
22
22
  runner_files.each do |file|
23
23
  runner_name = file.split('/').last.sub('.rb', '')
24
- runner_class = "#{lex_class}::Runners::#{runner_name.split('_').collect(&:capitalize).join}"
24
+ runner_class = "#{lex_class}::Runners::#{runner_name.split('_').collect(&:capitalize).join}"
25
25
  loaded_runner = Kernel.const_get(runner_class)
26
26
  loaded_runner.extend(Legion::Extensions::Definitions) unless loaded_runner.respond_to?(:definition)
27
27
  Legion::Logging.debug "[Runners] registered: #{runner_class}" if defined?(Legion::Logging)
28
+ @runners[runner_name.to_sym] = build_runner_entry(runner_name, runner_class, loaded_runner, file)
29
+ populate_runner_methods(runner_name, loaded_runner)
30
+ end
31
+ end
28
32
 
29
- @runners[runner_name.to_sym] = {
30
- extension: lex_class.to_s.downcase,
31
- extension_name: extension_name,
32
- extension_class: lex_class,
33
- runner_name: runner_name,
34
- runner_class: runner_class,
35
- runner_module: loaded_runner,
36
- runner_path: file,
37
- class_methods: {}
38
- }
39
-
40
- @runners[runner_name.to_sym][:scheduled_tasks] = loaded_runner.scheduled_tasks if loaded_runner.method_defined? :scheduled_tasks
41
-
42
- if settings.key?(:runners) && settings[:runners].key?(runner_name.to_sym)
43
- @runners[runner_name.to_sym][:desc] = settings[:runners][runner_name.to_sym][:desc]
44
- end
45
-
46
- loaded_runner.public_instance_methods(false).each do |runner_method|
47
- @runners[runner_name.to_sym][:class_methods][runner_method] = {
48
- args: loaded_runner.instance_method(runner_method).parameters
49
- }
50
- end
33
+ def build_runner_entry(runner_name, runner_class, loaded_runner, file)
34
+ entry = {
35
+ extension: lex_class.to_s.downcase,
36
+ extension_name: extension_name,
37
+ extension_class: lex_class,
38
+ runner_name: runner_name,
39
+ runner_class: runner_class,
40
+ runner_module: loaded_runner,
41
+ runner_path: file,
42
+ class_methods: {}
43
+ }
44
+ entry[:scheduled_tasks] = loaded_runner.scheduled_tasks if loaded_runner.method_defined?(:scheduled_tasks)
45
+ entry[:trigger_words] = loaded_runner.trigger_words if loaded_runner.respond_to?(:trigger_words)
46
+ entry[:desc] = settings[:runners][runner_name.to_sym][:desc] if settings.key?(:runners) && settings[:runners].key?(runner_name.to_sym)
47
+ entry
48
+ end
51
49
 
52
- loaded_runner.methods(false).each do |runner_method|
53
- next if %i[scheduled_tasks runner_description].include? runner_method
50
+ def populate_runner_methods(runner_name, loaded_runner)
51
+ loaded_runner.public_instance_methods(false).each do |runner_method|
52
+ @runners[runner_name.to_sym][:class_methods][runner_method] = {
53
+ args: loaded_runner.instance_method(runner_method).parameters
54
+ }
55
+ end
56
+ loaded_runner.methods(false).each do |runner_method|
57
+ next if %i[scheduled_tasks runner_description].include?(runner_method)
54
58
 
55
- @runners[runner_name.to_sym][:class_methods][runner_method] = {
56
- args: loaded_runner.method(runner_method).parameters
57
- }
58
- end
59
+ @runners[runner_name.to_sym][:class_methods][runner_method] = {
60
+ args: loaded_runner.method(runner_method).parameters
61
+ }
59
62
  end
60
63
  end
61
64
 
@@ -120,6 +120,10 @@ module Legion
120
120
  true
121
121
  end
122
122
 
123
+ def trigger_words
124
+ []
125
+ end
126
+
123
127
  # Auto-generate AMQP message classes for each runner method that has a definition.
124
128
  # Explicit Messages::* classes in the transport directory take precedence.
125
129
  # Runs after build_runners so definitions are populated.
@@ -935,6 +935,13 @@ module Legion
935
935
  require 'legion/tools'
936
936
  Legion::Tools.register_all
937
937
  Legion::Tools::Discovery.discover_and_register
938
+ future = Legion::Tools::TriggerIndex.build_async!
939
+ if future.respond_to?(:rescue)
940
+ @trigger_index_build_future = future.rescue do |e|
941
+ handle_exception(e, level: :warn, operation: 'service.register_core_tools.trigger_index_build')
942
+ nil
943
+ end
944
+ end
938
945
  Legion::Tools::EmbeddingCache.setup
939
946
 
940
947
  log.info(
@@ -69,6 +69,12 @@ module Legion
69
69
  @mcp_tier = val
70
70
  end
71
71
 
72
+ def trigger_words(val = nil)
73
+ return @trigger_words || [] if val.nil?
74
+
75
+ @trigger_words = val
76
+ end
77
+
72
78
  def call(**_args)
73
79
  raise NotImplementedError, "#{name} must implement .call"
74
80
  end
@@ -147,14 +147,15 @@ module Legion
147
147
  ext_name = derive_extension_name(ext)
148
148
  runner_snake = derive_runner_snake(runner_mod)
149
149
  {
150
- tool_name: defn&.dig(:mcp_prefix) || "legion-#{ext_name}-#{runner_snake}-#{func_name}",
151
- description: meta[:desc] || defn&.dig(:desc) || "#{ext_name}##{func_name}",
152
- input_schema: normalize_schema(meta[:options]),
153
- mcp_category: defn&.dig(:mcp_category),
154
- mcp_tier: defn&.dig(:mcp_tier),
155
- deferred: deferred,
156
- ext_name: ext_name,
157
- runner_snake: runner_snake
150
+ tool_name: defn&.dig(:mcp_prefix) || "legion-#{ext_name}-#{runner_snake}-#{func_name}",
151
+ description: meta[:desc] || defn&.dig(:desc) || "#{ext_name}##{func_name}",
152
+ input_schema: normalize_schema(meta[:options]),
153
+ mcp_category: defn&.dig(:mcp_category),
154
+ mcp_tier: defn&.dig(:mcp_tier),
155
+ deferred: deferred,
156
+ ext_name: ext_name,
157
+ runner_snake: runner_snake,
158
+ trigger_words: merge_trigger_words(ext, runner_mod)
158
159
  }
159
160
  end
160
161
 
@@ -168,6 +169,7 @@ module Legion
168
169
  runner(attrs[:runner_snake])
169
170
  mcp_category(attrs[:mcp_category]) if attrs[:mcp_category]
170
171
  mcp_tier(attrs[:mcp_tier]) if attrs[:mcp_tier]
172
+ trigger_words(attrs[:trigger_words])
171
173
 
172
174
  define_singleton_method(:call) do |**params|
173
175
  if runner_ref.respond_to?(func_ref)
@@ -184,6 +186,12 @@ module Legion
184
186
  end
185
187
  end
186
188
 
189
+ def merge_trigger_words(ext, runner_mod)
190
+ ext_words = ext.respond_to?(:trigger_words) ? Array(ext.trigger_words) : []
191
+ runner_words = runner_mod.respond_to?(:trigger_words) ? Array(runner_mod.trigger_words) : []
192
+ (ext_words + runner_words).uniq
193
+ end
194
+
187
195
  def derive_runner_snake(runner_mod)
188
196
  mod_name = runner_mod.name
189
197
  return 'unknown' unless mod_name
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Tools
5
+ module TriggerIndex
6
+ @index = if defined?(Concurrent::Map)
7
+ Concurrent::Map.new
8
+ else
9
+ {}
10
+ end
11
+ @mutex = Mutex.new unless defined?(Concurrent::Map)
12
+
13
+ class << self
14
+ def build_from_registry
15
+ clear
16
+ Registry.all_tools.each do |tool_class|
17
+ words = Array(tool_class.trigger_words)
18
+ next if words.empty?
19
+
20
+ normalized = words.flat_map { |w| w.downcase.gsub(/[^a-z ]/, ' ').split }.uniq
21
+ normalized.each { |word| add_tool_for_word(word, tool_class) }
22
+ end
23
+ end
24
+
25
+ def build_async!
26
+ if defined?(Concurrent::Promises)
27
+ Concurrent::Promises.future { build_from_registry }
28
+ else
29
+ build_from_registry
30
+ end
31
+ end
32
+
33
+ def match(word_set)
34
+ matched = Set.new
35
+ per_word = {}
36
+ word_set.each do |word|
37
+ normalized = word.to_s.downcase.gsub(/[^a-z ]/, ' ').strip
38
+ next if normalized.empty?
39
+
40
+ tools = read_word(normalized)
41
+ next unless tools
42
+
43
+ per_word[normalized] = tools
44
+ matched.merge(tools)
45
+ end
46
+ [matched, per_word]
47
+ end
48
+
49
+ def empty?
50
+ if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
51
+ @index.each_pair.none?
52
+ else
53
+ @index.empty?
54
+ end
55
+ end
56
+
57
+ def size
58
+ if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
59
+ count = 0
60
+ @index.each_pair { count += 1 }
61
+ count
62
+ else
63
+ @index.size
64
+ end
65
+ end
66
+
67
+ def clear
68
+ if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
69
+ @index = Concurrent::Map.new
70
+ else
71
+ @mutex.synchronize { @index = {} }
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def add_tool_for_word(word, tool_class)
78
+ if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
79
+ @index.compute(word) { |existing| ((existing || Set.new) + Set[tool_class]).freeze }
80
+ else
81
+ @mutex.synchronize do
82
+ @index[word] ||= Set.new
83
+ @index[word] = (@index[word] + Set[tool_class]).freeze
84
+ end
85
+ end
86
+ end
87
+
88
+ def read_word(word)
89
+ if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
90
+ @index[word]
91
+ else
92
+ @mutex&.synchronize { @index[word] }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/legion/tools.rb CHANGED
@@ -29,7 +29,9 @@ require_relative 'tools/registry'
29
29
  require_relative 'tools/base'
30
30
  require_relative 'tools/discovery'
31
31
  require_relative 'tools/embedding_cache'
32
+ require_relative 'tools/trigger_index'
32
33
 
33
34
  Dir[File.join(__dir__, 'tools', '*.rb')].each do |f|
34
- require f unless f.end_with?('/base.rb', '/registry.rb', '/discovery.rb', '/embedding_cache.rb')
35
+ require f unless f.end_with?('/base.rb', '/registry.rb', '/discovery.rb', '/embedding_cache.rb',
36
+ '/trigger_index.rb')
35
37
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.36'
4
+ VERSION = '1.7.37'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.36
4
+ version: 1.7.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -901,6 +901,7 @@ files:
901
901
  - lib/legion/tools/embedding_cache/migrations/001_create_tool_embedding_cache.rb
902
902
  - lib/legion/tools/registry.rb
903
903
  - lib/legion/tools/status.rb
904
+ - lib/legion/tools/trigger_index.rb
904
905
  - lib/legion/trace_search.rb
905
906
  - lib/legion/trigger.rb
906
907
  - lib/legion/trigger/envelope.rb