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
- .aliases_for(tool_name) ⇒ Object
- .all_tools_definition ⇒ Object
- .all_tools_inventory(**args) ⇒ Object
- .array_args(args) ⇒ Object
- .dispatch(tool_name, **args) ⇒ Object
- .dispatch_runtime(runtime_name, executable, **args) ⇒ Object
- .executable_file?(path) ⇒ Boolean
- .executable_from_path(command) ⇒ Object
- .inventory ⇒ Object
- .legionio_packaged_ruby?(path) ⇒ Boolean
- .normalize_inventory_entry(entry) ⇒ Object
- .normalize_tool_name(tool_name) ⇒ Object
- .path_ruby ⇒ Object
- .pinned_definitions ⇒ Object
- .pip_candidates_for(bin_dir) ⇒ Object
- .pip_path ⇒ Object
- .pip_schema ⇒ Object
- .process_cwd(args) ⇒ Object
- .process_group_alive?(process_group_id) ⇒ Boolean
- .process_ruby_path ⇒ Object
- .process_stdin(args) ⇒ Object
- .python_available? ⇒ Boolean
- .python_definitions ⇒ Object
- .python_path ⇒ Object
- .python_venv_dir ⇒ Object
- .reset_runtime_cache! ⇒ Object
- .ruby_definition ⇒ Object
- .ruby_path ⇒ Object
- .run_process(executable, argv, **args) ⇒ Object
- .runtime_argv(runtime_name, **args) ⇒ Object
- .runtime_error_summary(output, exit_status) ⇒ Object
- .runtime_inventory ⇒ Object
- .runtime_result(status:, result:, command:, output:) ⇒ Object
- .runtime_schema(language) ⇒ Object
- .settings_extensions_tools ⇒ Object
- .signal_process_group(signal, process_group_id) ⇒ Object
- .special_tool_summaries ⇒ Object
- .special_tools_definition ⇒ Object
- .terminate_grace_ms ⇒ Object
- .terminate_process_group(wait_thread) ⇒ Object
- .timeout_ms(args) ⇒ Object
- .tool_error_log_chars ⇒ Object
- .trim_for_tool_log(value) ⇒ Object
- .trim_tail_for_tool_log(value) ⇒ Object
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_definition ⇒ Object
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. } 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. } 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
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 |
.inventory ⇒ Object
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
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_ruby ⇒ Object
405 406 407 |
# File 'lib/legion/llm/tools/special.rb', line 405 def path_ruby executable_from_path('ruby') end |
.pinned_definitions ⇒ Object
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_path ⇒ Object
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_schema ⇒ Object
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
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_path ⇒ Object
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
104 105 106 |
# File 'lib/legion/llm/tools/special.rb', line 104 def python_available? !python_path.to_s.empty? end |
.python_definitions ⇒ Object
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_path ⇒ Object
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_dir ⇒ Object
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.(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_definition ⇒ Object
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_path ⇒ Object
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_inventory ⇒ Object
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_tools ⇒ Object
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_summaries ⇒ Object
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_definition ⇒ Object
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_ms ⇒ Object
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_chars ⇒ Object
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 |