Module: Legion::LLM::Tools::Special

Extended by:
Legion::Logging::Helper
Defined in:
lib/legion/llm/tools/special.rb

Constant Summary collapse

LIST_SPECIAL_TOOLS_NAME =
'legion_list_special_tools'
LIST_ALL_TOOLS_NAME =
'legion_list_all_tools'
TOOL_ALIASES =
{
  'python' => %w[python python3],
  'pip'    => %w[pip pip3]
}.freeze
PYTHON_PACKAGES =
%w[
  python-pptx
  python-docx
  openpyxl
  pandas
  pillow
  requests
  lxml
  PyYAML
  tabulate
  markdown
].freeze

Class Method Summary collapse

Class Method Details

.aliases_for(tool_name) ⇒ Object



65
66
67
68
# File 'lib/legion/llm/tools/special.rb', line 65

def aliases_for(tool_name)
  normalized = normalize_tool_name(tool_name)
  TOOL_ALIASES.fetch(normalized, [normalized])
end

.all_tools_definitionObject



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/legion/llm/tools/special.rb', line 155

def all_tools_definition
  Types::ToolDefinition.build(
    name:        LIST_ALL_TOOLS_NAME,
    description: 'List ALL registered Legion tools from all loaded extensions, grouped by extension and runner. ' \
                 'Use this to discover what tools are available for a specific domain (e.g. Teams, Apollo, identity).',
    parameters:  {
      type:       'object',
      properties: {
        extension: { type: 'string', description: 'Filter by extension name (e.g. "microsoft_teams", "apollo"). Omit for all.' },
        deferred:  { type: 'boolean', description: 'Filter by deferred status. Omit for all.' }
      }
    },
    source:      { type: :special, handler: :all_tools_inventory, pinned: true }
  )
end

.all_tools_inventory(**args) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/legion/llm/tools/special.rb', line 78

def all_tools_inventory(**args)
  tools = settings_extensions_tools
  extension_filter = args[:extension] || args['extension']
  deferred_filter = args.key?(:deferred) ? args[:deferred] : args['deferred']

  if extension_filter
    normalized_filter = extension_filter.to_s.tr('-', '_').delete_prefix('lex_')
    tools = tools.select { |t| t[:extension].to_s.tr('-', '_').delete_prefix('lex_').include?(normalized_filter) }
  end

  tools = tools.select { |t| t[:deferred] == deferred_filter } unless deferred_filter.nil?

  grouped = tools.group_by { |t| t[:extension] || 'unknown' }
  {
    total:      tools.size,
    extensions: grouped.transform_values do |ext_tools|
      ext_tools.group_by { |t| t[:runner] || 'default' }.transform_values do |runner_tools|
        runner_tools.map { |t| { name: t[:name], description: t[:description], deferred: t[:deferred] } }
      end
    end
  }
rescue StandardError => e
  handle_exception(e, level: :warn, handled: true, operation: 'llm.tools.special.all_tools_inventory')
  { total: 0, extensions: {}, error: e.message }
end

.array_args(args) ⇒ Object



315
316
317
318
319
320
321
322
323
324
325
# File 'lib/legion/llm/tools/special.rb', line 315

def array_args(args)
  raw = args[:args] || args['args']
  case raw
  when Array
    raw.map(&:to_s)
  when String
    Shellwords.split(raw)
  else
    []
  end
end

.dispatch(tool_name, **args) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/legion/llm/tools/special.rb', line 45

def dispatch(tool_name, **args)
  case normalize_tool_name(tool_name)
  when LIST_SPECIAL_TOOLS_NAME
    { status: :success, result: Legion::JSON.dump(inventory) }
  when LIST_ALL_TOOLS_NAME
    { status: :success, result: Legion::JSON.dump(all_tools_inventory(**args)) }
  when 'ruby'
    dispatch_runtime('ruby', ruby_path, **args)
  when 'python', 'python3'
    dispatch_runtime('python', python_path, **args)
  when 'pip', 'pip3'
    dispatch_runtime('pip', pip_path, **args)
  else
    { status: :error, result: "Unknown Legion special tool: #{tool_name}" }
  end
rescue StandardError => e
  handle_exception(e, level: :warn, handled: true, operation: 'llm.tools.special.dispatch', tool_name: tool_name)
  { status: :error, result: e.message }
end

.dispatch_runtime(runtime_name, executable, **args) ⇒ Object



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/legion/llm/tools/special.rb', line 285

def dispatch_runtime(runtime_name, executable, **args)
  return { status: :error, result: "#{runtime_name} runtime is unavailable." } unless executable_file?(executable)

  argv = runtime_argv(runtime_name, **args)
  return { status: :error, result: "#{runtime_name} tool requires `code`, `command`, or `args`." } if argv.empty?

  output, status = run_process(executable, argv, **args)
  command = Shellwords.join([executable, *argv])
  result = "command=#{command}\nexit=#{status.exitstatus}\n#{output}"
  runtime_result(status: status, result: result, command: command, output: output)
rescue Timeout::Error
  timeout_result = "#{runtime_name} tool timed out after #{timeout_ms(args)}ms."
  {
    status:      :error,
    result:      timeout_result,
    error:       timeout_result,
    exit_status: nil,
    output_tail: ''
  }
end

.executable_file?(path) ⇒ Boolean

Returns:

  • (Boolean)


421
422
423
# File 'lib/legion/llm/tools/special.rb', line 421

def executable_file?(path)
  !path.to_s.empty? && File.file?(path.to_s) && File.executable?(path.to_s)
end

.executable_from_path(command) ⇒ Object



414
415
416
417
418
419
# File 'lib/legion/llm/tools/special.rb', line 414

def executable_from_path(command)
  ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).filter_map do |dir|
    path = File.join(dir, command)
    path if executable_file?(path)
  end.first
end

.inventoryObject



70
71
72
73
74
75
76
# File 'lib/legion/llm/tools/special.rb', line 70

def inventory
  {
    special_tools:             special_tool_summaries,
    settings_extensions_tools: settings_extensions_tools,
    runtime:                   runtime_inventory
  }
end

.legionio_packaged_ruby?(path) ⇒ Boolean

Returns:

  • (Boolean)


409
410
411
412
# File 'lib/legion/llm/tools/special.rb', line 409

def legionio_packaged_ruby?(path)
  normalized = path.to_s
  normalized.include?('/Cellar/legionio/') || normalized.include?('/libexec/bin/ruby')
end

.normalize_inventory_entry(entry) ⇒ Object



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/legion/llm/tools/special.rb', line 244

def normalize_inventory_entry(entry)
  return unless entry.respond_to?(:transform_keys)

  normalized = entry.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
  name = normalized[:name].to_s
  return if name.empty?

  {
    name:          name,
    description:   normalized[:description].to_s,
    deferred:      normalized[:deferred] == true,
    extension:     normalized[:extension],
    runner:        normalized[:runner],
    function:      normalized[:function],
    trigger_words: Array(normalized[:trigger_words]).map(&:to_s),
    parameters:    normalized[:input_schema] || normalized[:parameters] || {},
    source:        normalized[:tool_class] ? 'registry' : 'extension'
  }.compact
end

.normalize_tool_name(tool_name) ⇒ Object



425
426
427
# File 'lib/legion/llm/tools/special.rb', line 425

def normalize_tool_name(tool_name)
  tool_name.to_s.tr('.', '_')
end

.path_rubyObject



405
406
407
# File 'lib/legion/llm/tools/special.rb', line 405

def path_ruby
  executable_from_path('ruby')
end

.pinned_definitionsObject



39
40
41
42
43
# File 'lib/legion/llm/tools/special.rb', line 39

def pinned_definitions
  definitions = [special_tools_definition, all_tools_definition, ruby_definition]
  definitions.concat(python_definitions) if python_available?
  definitions
end

.pip_candidates_for(bin_dir) ⇒ Object



395
396
397
398
399
# File 'lib/legion/llm/tools/special.rb', line 395

def pip_candidates_for(bin_dir)
  return [] if bin_dir.to_s.empty?

  [File.join(bin_dir, 'pip'), File.join(bin_dir, 'pip3')]
end

.pip_pathObject



124
125
126
127
128
129
130
131
# File 'lib/legion/llm/tools/special.rb', line 124

def pip_path
  @pip_path ||= begin
    candidates = []
    candidates.concat(pip_candidates_for(File.dirname(python_path))) if python_available?
    candidates.concat(pip_candidates_for(File.join(python_venv_dir, 'bin')))
    candidates.compact.uniq.find { |path| executable_file?(path) }
  end
end

.pip_schemaObject



211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/legion/llm/tools/special.rb', line 211

def pip_schema
  {
    type:       'object',
    properties: {
      command: { type: 'string', description: 'pip arguments, such as `install pandas` or `list`.' },
      args:    { type: 'array', items: { type: 'string' }, description: 'pip argv entries.' },
      cwd:     { type: 'string', description: 'Working directory. Defaults to the current process directory.' },
      timeout: { type: 'integer', description: 'Timeout in milliseconds.' },
      stdin:   { type: 'string', description: 'Optional stdin content.' }
    }
  }
end

.process_cwd(args) ⇒ Object



373
374
375
376
# File 'lib/legion/llm/tools/special.rb', line 373

def process_cwd(args)
  cwd = args[:cwd] || args['cwd']
  cwd.to_s.empty? ? Dir.pwd : cwd.to_s
end

.process_group_alive?(process_group_id) ⇒ Boolean

Returns:

  • (Boolean)


366
367
368
369
370
371
# File 'lib/legion/llm/tools/special.rb', line 366

def process_group_alive?(process_group_id)
  ::Process.kill(0, -process_group_id)
  true
rescue Errno::ESRCH
  false
end

.process_ruby_pathObject



401
402
403
# File 'lib/legion/llm/tools/special.rb', line 401

def process_ruby_path
  RbConfig.ruby
end

.process_stdin(args) ⇒ Object



378
379
380
381
# File 'lib/legion/llm/tools/special.rb', line 378

def process_stdin(args)
  stdin = args[:stdin] || args['stdin']
  stdin.nil? ? '' : stdin.to_s
end

.python_available?Boolean

Returns:

  • (Boolean)


104
105
106
# File 'lib/legion/llm/tools/special.rb', line 104

def python_available?
  !python_path.to_s.empty?
end

.python_definitionsObject



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/legion/llm/tools/special.rb', line 180

def python_definitions
  [
    Types::ToolDefinition.build(
      name:        'python',
      description: "Run Python with the Legion-managed Python environment from `legionio setup python`. Current path: #{python_path}.",
      parameters:  runtime_schema('Python'),
      source:      { type: :special, handler: :python_runtime, pinned: true, executable: python_path }
    ),
    Types::ToolDefinition.build(
      name:        'pip',
      description: "Run pip inside the Legion-managed Python environment from `legionio setup python`. Current path: #{pip_path || 'unavailable'}.",
      parameters:  pip_schema,
      source:      { type: :special, handler: :pip_runtime, pinned: true, executable: pip_path }
    )
  ]
end

.python_pathObject



115
116
117
118
119
120
121
122
# File 'lib/legion/llm/tools/special.rb', line 115

def python_path
  @python_path ||= begin
    candidates = []
    candidates << ENV.fetch('LEGION_PYTHON', nil)
    candidates << File.join(python_venv_dir, 'bin', 'python3')
    candidates.compact.find { |path| executable_file?(path) }
  end
end

.python_venv_dirObject



138
139
140
141
# File 'lib/legion/llm/tools/special.rb', line 138

def python_venv_dir
  configured = Legion::Settings[:llm][:tools][:python_venv_dir]
  ENV['LEGION_PYTHON_VENV'] || File.expand_path(configured)
end

.reset_runtime_cache!Object



133
134
135
136
# File 'lib/legion/llm/tools/special.rb', line 133

def reset_runtime_cache!
  @python_path = nil
  @pip_path = nil
end

.ruby_definitionObject



171
172
173
174
175
176
177
178
# File 'lib/legion/llm/tools/special.rb', line 171

def ruby_definition
  Types::ToolDefinition.build(
    name:        'ruby',
    description: "Run Ruby with the current Legion Ruby environment. Current path: #{ruby_path}.",
    parameters:  runtime_schema('Ruby'),
    source:      { type: :special, handler: :ruby_runtime, pinned: true, executable: ruby_path }
  )
end

.ruby_pathObject



108
109
110
111
112
113
# File 'lib/legion/llm/tools/special.rb', line 108

def ruby_path
  process_path = process_ruby_path
  return process_path if legionio_packaged_ruby?(process_path) && executable_file?(process_path)

  path_ruby || process_path
end

.run_process(executable, argv, **args) ⇒ Object



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/legion/llm/tools/special.rb', line 327

def run_process(executable, argv, **args)
  Open3.popen2e(executable, *argv, chdir: process_cwd(args), pgroup: true) do |stdin, output, wait_thread|
    output_reader = Thread.new { output.read }
    output_reader.report_on_exception = false
    stdin_writer = Thread.new do
      stdin.write(process_stdin(args))
    rescue Errno::EPIPE, IOError
      nil
    ensure
      stdin.close unless stdin.closed?
    end
    stdin_writer.report_on_exception = false

    unless wait_thread.join(timeout_ms(args) / 1000.0)
      terminate_process_group(wait_thread)
      stdin_writer.join
      output_reader.join
      raise Timeout::Error
    end

    stdin_writer.join
    [output_reader.value, wait_thread.value]
  end
end

.runtime_argv(runtime_name, **args) ⇒ Object



306
307
308
309
310
311
312
313
# File 'lib/legion/llm/tools/special.rb', line 306

def runtime_argv(runtime_name, **args)
  code = args[:code] || args['code']
  return [runtime_name == 'python' ? '-c' : '-e', code.to_s, *array_args(args)] unless code.to_s.empty?

  command = args[:command] || args['command']
  command_args = command.to_s.empty? ? [] : Shellwords.split(command.to_s)
  command_args + array_args(args)
end

.runtime_error_summary(output, exit_status) ⇒ Object



441
442
443
444
# File 'lib/legion/llm/tools/special.rb', line 441

def runtime_error_summary(output, exit_status)
  first_line = output.to_s.lines.map(&:strip).find { |line| !line.empty? }
  first_line || "process exited with status #{exit_status}"
end

.runtime_inventoryObject



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/legion/llm/tools/special.rb', line 264

def runtime_inventory
  {
    ruby:   {
      path:            ruby_path,
      path_ruby:       path_ruby,
      process_ruby:    process_ruby_path,
      description:     RUBY_DESCRIPTION,
      bundler_version: defined?(Bundler) ? Bundler::VERSION : nil,
      bundle_gemfile:  ENV.fetch('BUNDLE_GEMFILE', nil),
      bundle_bin_path: ENV.fetch('BUNDLE_BIN_PATH', nil)
    }.compact,
    python: {
      available:        python_available?,
      path:             python_path,
      venv_dir:         python_venv_dir,
      pip:              pip_path,
      default_packages: PYTHON_PACKAGES
    }.compact
  }
end

.runtime_result(status:, result:, command:, output:) ⇒ Object



429
430
431
432
433
434
435
436
437
438
439
# File 'lib/legion/llm/tools/special.rb', line 429

def runtime_result(status:, result:, command:, output:)
  payload = {
    status:          status.success? ? :success : :error,
    result:          result,
    exit_status:     status.exitstatus,
    command_preview: trim_for_tool_log(command),
    output_tail:     trim_tail_for_tool_log(output)
  }
  payload[:error] = runtime_error_summary(output, status.exitstatus) unless status.success?
  payload
end

.runtime_schema(language) ⇒ Object



197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/legion/llm/tools/special.rb', line 197

def runtime_schema(language)
  {
    type:       'object',
    properties: {
      code:    { type: 'string', description: "#{language} code to execute with -e/-c." },
      command: { type: 'string', description: "#{language} command arguments, such as a script path plus args." },
      args:    { type: 'array', items: { type: 'string' }, description: 'Additional argv entries.' },
      cwd:     { type: 'string', description: 'Working directory. Defaults to the current process directory.' },
      timeout: { type: 'integer', description: 'Timeout in milliseconds.' },
      stdin:   { type: 'string', description: 'Optional stdin content.' }
    }
  }
end

.settings_extensions_toolsObject



235
236
237
238
239
240
241
242
# File 'lib/legion/llm/tools/special.rb', line 235

def settings_extensions_tools
  return [] unless defined?(Legion::Settings::Extensions) && Legion::Settings::Extensions.respond_to?(:tools)

  Array(Legion::Settings::Extensions.tools).filter_map { |entry| normalize_inventory_entry(entry) }
rescue StandardError => e
  handle_exception(e, level: :warn, handled: true, operation: 'llm.tools.special.settings_extensions_inventory')
  []
end

.signal_process_group(signal, process_group_id) ⇒ Object



360
361
362
363
364
# File 'lib/legion/llm/tools/special.rb', line 360

def signal_process_group(signal, process_group_id)
  ::Process.kill(signal, -process_group_id)
rescue Errno::ESRCH
  nil
end

.special_tool_summariesObject



224
225
226
227
228
229
230
231
232
233
# File 'lib/legion/llm/tools/special.rb', line 224

def special_tool_summaries
  pinned_definitions.map do |definition|
    {
      name:        definition.name,
      description: definition.description,
      parameters:  definition.parameters,
      source:      'legion-special'
    }
  end
end

.special_tools_definitionObject



143
144
145
146
147
148
149
150
151
152
153
# File 'lib/legion/llm/tools/special.rb', line 143

def special_tools_definition
  Types::ToolDefinition.build(
    name:        LIST_SPECIAL_TOOLS_NAME,
    description: 'Show all Legion special tools available to this LLM from the current Legion::Settings::Extensions inventory.',
    parameters:  {
      type:       'object',
      properties: {}
    },
    source:      { type: :special, handler: :settings_extensions_inventory, pinned: true }
  )
end

.terminate_grace_msObject



391
392
393
# File 'lib/legion/llm/tools/special.rb', line 391

def terminate_grace_ms
  Legion::Settings[:llm][:tools][:timeouts][:terminate_grace]
end

.terminate_process_group(wait_thread) ⇒ Object



352
353
354
355
356
357
358
# File 'lib/legion/llm/tools/special.rb', line 352

def terminate_process_group(wait_thread)
  process_group_id = wait_thread.pid
  signal_process_group('TERM', process_group_id)
  wait_thread.join(terminate_grace_ms / 1000.0)
  signal_process_group('KILL', process_group_id) if process_group_alive?(process_group_id)
  wait_thread.join
end

.timeout_ms(args) ⇒ Object



383
384
385
386
387
388
389
# File 'lib/legion/llm/tools/special.rb', line 383

def timeout_ms(args)
  timeouts = Legion::Settings[:llm][:tools][:timeouts]
  requested = (args[:timeout] || args['timeout'] || timeouts[:default]).to_i
  return timeouts[:default] unless requested.positive?

  [requested, timeouts[:max]].min
end

.tool_error_log_charsObject



456
457
458
# File 'lib/legion/llm/tools/special.rb', line 456

def tool_error_log_chars
  Legion::Settings[:llm][:tools][:error_log_chars]
end

.trim_for_tool_log(value) ⇒ Object



446
447
448
# File 'lib/legion/llm/tools/special.rb', line 446

def trim_for_tool_log(value)
  value.to_s[0, tool_error_log_chars]
end

.trim_tail_for_tool_log(value) ⇒ Object



450
451
452
453
454
# File 'lib/legion/llm/tools/special.rb', line 450

def trim_tail_for_tool_log(value)
  text = value.to_s
  limit = tool_error_log_chars
  text.length > limit ? text[-limit, limit] : text
end