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'
DEFAULT_TIMEOUT_MS =
120_000
MAX_TIMEOUT_MS =
600_000
PYTHON_PACKAGES =
%w[
  python-pptx
  python-docx
  openpyxl
  pandas
  pillow
  requests
  lxml
  PyYAML
  tabulate
  markdown
].freeze

Class Method Summary collapse

Class Method Details

.all_tools_definitionObject



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/legion/llm/tools/special.rb', line 147

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



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/legion/llm/tools/special.rb', line 71

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



299
300
301
302
303
304
305
306
307
308
309
# File 'lib/legion/llm/tools/special.rb', line 299

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



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

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



277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/legion/llm/tools/special.rb', line 277

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)
  result = "command=#{Shellwords.join([executable, *argv])}\nexit=#{status.exitstatus}\n#{output}"
  { status: status.success? ? :success : :error, result: result }
rescue Timeout::Error
  { status: :error, result: "#{runtime_name} tool timed out after #{timeout_ms(args)}ms." }
end

.executable_file?(path) ⇒ Boolean

Returns:

  • (Boolean)


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

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

.executable_from_path(command) ⇒ Object



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

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



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

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

.legionio_packaged_ruby?(path) ⇒ Boolean

Returns:

  • (Boolean)


348
349
350
351
# File 'lib/legion/llm/tools/special.rb', line 348

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

.normalize_inventory_entry(entry) ⇒ Object



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/legion/llm/tools/special.rb', line 236

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



364
365
366
# File 'lib/legion/llm/tools/special.rb', line 364

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

.path_rubyObject



344
345
346
# File 'lib/legion/llm/tools/special.rb', line 344

def path_ruby
  executable_from_path('ruby')
end

.pinned_definitionsObject



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

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



334
335
336
337
338
# File 'lib/legion/llm/tools/special.rb', line 334

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



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

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



203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/legion/llm/tools/special.rb', line 203

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



317
318
319
320
# File 'lib/legion/llm/tools/special.rb', line 317

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

.process_ruby_pathObject



340
341
342
# File 'lib/legion/llm/tools/special.rb', line 340

def process_ruby_path
  RbConfig.ruby
end

.process_stdin(args) ⇒ Object



322
323
324
325
# File 'lib/legion/llm/tools/special.rb', line 322

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

.python_available?Boolean

Returns:

  • (Boolean)


97
98
99
# File 'lib/legion/llm/tools/special.rb', line 97

def python_available?
  !python_path.to_s.empty?
end

.python_definitionsObject



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/legion/llm/tools/special.rb', line 172

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



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

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



131
132
133
# File 'lib/legion/llm/tools/special.rb', line 131

def python_venv_dir
  ENV['LEGION_PYTHON_VENV'] || File.expand_path('~/.legionio/python')
end

.reset_runtime_cache!Object



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

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

.ruby_definitionObject



163
164
165
166
167
168
169
170
# File 'lib/legion/llm/tools/special.rb', line 163

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



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

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



311
312
313
314
315
# File 'lib/legion/llm/tools/special.rb', line 311

def run_process(executable, argv, **args)
  Timeout.timeout(timeout_ms(args) / 1000.0) do
    Open3.capture2e(executable, *argv, chdir: process_cwd(args), stdin_data: process_stdin(args))
  end
end

.runtime_argv(runtime_name, **args) ⇒ Object



290
291
292
293
294
295
296
297
# File 'lib/legion/llm/tools/special.rb', line 290

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_inventoryObject



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/legion/llm/tools/special.rb', line 256

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_schema(language) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/legion/llm/tools/special.rb', line 189

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



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

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

.special_tool_summariesObject



216
217
218
219
220
221
222
223
224
225
# File 'lib/legion/llm/tools/special.rb', line 216

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



135
136
137
138
139
140
141
142
143
144
145
# File 'lib/legion/llm/tools/special.rb', line 135

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

.timeout_ms(args) ⇒ Object



327
328
329
330
331
332
# File 'lib/legion/llm/tools/special.rb', line 327

def timeout_ms(args)
  requested = (args[:timeout] || args['timeout'] || DEFAULT_TIMEOUT_MS).to_i
  return DEFAULT_TIMEOUT_MS unless requested.positive?

  [requested, MAX_TIMEOUT_MS].min
end