shared_tools 0.1.0 → 0.1.3

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: 8ae3eef4f29e12a8be7ff8db5eec7e156bcfa1e767d5375b2ffde66db35629ef
4
- data.tar.gz: 6f73bca50589be410d134eb2c02be5451bed2f875dea5854984181ff9f37fc44
3
+ metadata.gz: b6c52f4b0cd9b70b60f1e0a9ed50c752d440f17c4d3a638932976cb4796a64e6
4
+ data.tar.gz: 8c15bd8b3c71f42265f4ef4b7dbc98fae5297dde626d510f43a02cd952e2a069
5
5
  SHA512:
6
- metadata.gz: 42a193b5df012a25909c94a28d60e1d66347683d32bc29979a578e4fbf705738c02053625c45828c7029fe2473fa8cf40ca2e83a67ed29599ac10ab4436f2d4c
7
- data.tar.gz: 57bb4e3b32137687b81257adf786d684fc1bb521a8d661fb23ea057047c64ae78e1f2af0f2273199093d1194d8553276bbba3302f76d3942677b7c24a78dfcd7
6
+ metadata.gz: aad7b1f18772c7b4407f86fd811141ee5c9c0cc4cddf1a5ca312a27c2f7096cb4502a6295d8c16ec0e5bfa740949faeb7f2d1106cf828f9445b5c80f31e4d2c1
7
+ data.tar.gz: 394c9b6b1d3f22320b3c151d8477912c34fa4e86c9e3a46a023983581d0325886a2abb6a5df536ba11c165a6d4e9454097528794122cd842cabb9a03111d75be
data/CHANGELOG.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.0] - 2025-06-05
3
+ ## Unreleased
4
+ ### [0.1.3] 2025-06-18
5
+ - tweaking the load all tools process
4
6
 
5
- ### Added
7
+ ## Released
6
8
 
9
+ ### [0.1.2] 2025-06-10
10
+ - added `zeitwerk` gem
11
+
12
+ ### [0.1.0] - 2025-06-05
7
13
  - Initial gem release
8
14
  - SharedTools core module with automatic logger integration
9
15
  - RubyLlm tools: EditFile, ListFiles, PdfPageReader, ReadFile, RunShellCommand
data/README.md CHANGED
@@ -1,9 +1,14 @@
1
- # shared_tools
2
- A Ruby gem providing a collection of shared tools and utilities for Ruby applications, including configurable logging and AI-related tools.
1
+ <div align="center">
2
+ <h1>Shared Tools</h1>
3
+ <img src="images/shared_tools.png" alt="Two Robots sharing the same set of tools.">
4
+ </div>
3
5
 
4
- ## Libraries Supported
6
+ A Ruby gem providing a collection of common tools (call-back functions) for use with the following gems:
5
7
 
6
- - ruby_llm: multi-provider
8
+ - ruby_llm: multi-provider `gem install ruby_llm`
9
+ - llm: multi-provider `gem install llm.rb`
10
+ - omniai: multi-provider `gem install omniai-tools` (Not part of the SharedTools namespace)
11
+ - more to come ...
7
12
 
8
13
  ## Installation
9
14
 
@@ -27,86 +32,34 @@ gem install shared_tools
27
32
 
28
33
  ## Usage
29
34
 
30
- ```ruby
31
- # To load all tools for a specific library
32
- require 'shared_tools/ruby_llm'
33
-
34
- # OR you can load a specific tool
35
- require 'shared_tools/ruby_llm/edit_file'
36
- ```
37
-
38
- ## Shared Logging
39
-
40
- All classes within the SharedTools namespace automatically have access to a configurable logger without requiring any explicit includes or setup.
41
-
42
- ### Basic Usage
43
-
44
- Within any class in the SharedTools namespace, you can directly use the logger:
35
+ ### Basic Loading
45
36
 
46
37
  ```ruby
47
- module SharedTools
48
- class MyTool
49
- def perform_action
50
- logger.info "Starting action"
51
- # Do something
52
- logger.debug "Details about action"
53
- # Handle errors
54
- rescue => e
55
- logger.error "Action failed: #{e.message}"
56
- raise
57
- ensure
58
- logger.info "Action completed"
59
- end
60
- end
61
- end
38
+ require 'shared_tools'
62
39
  ```
63
40
 
64
- ### Configuration
41
+ ### Loading RubyLLM Tools
65
42
 
66
- The logger can be configured using the `configure_logger` method:
43
+ RubyLLM tools are loaded conditionally when needed:
67
44
 
68
45
  ```ruby
69
- # Configure the logger in your application initialization
70
- SharedTools.configure_logger do |config|
71
- config.level = Logger::DEBUG # Set log level
72
- config.log_device = "logs/shared_tools.log" # Set output file
73
- config.formatter = proc do |severity, time, progname, msg|
74
- "[#{time.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
75
- end
76
- end
77
- ```
78
-
79
- ### Using with Rails
46
+ require 'shared_tools'
80
47
 
81
- To use the SharedTools logger with Rails and make it use the same logger instance:
48
+ # Load all RubyLLM tools (requires ruby_llm gem to be available and loaded first)
49
+ require 'shared_tools/ruby_llm'
82
50
 
83
- ```ruby
84
- # In config/initializers/shared_tools.rb
85
- Rails.application.config.after_initialize do
86
- # Make SharedTools use the Rails logger
87
- SharedTools.logger = Rails.logger
88
-
89
- # Alternatively, configure the Rails logger to use SharedTools settings
90
- # SharedTools.configure_logger do |config|
91
- # config.level = Rails.logger.level
92
- # config.log_device = Rails.logger.instance_variable_get(:@logdev).dev
93
- # end
94
- # Rails.logger = SharedTools.logger
95
-
96
- Rails.logger.info "SharedTools integrated with Rails logger"
97
- end
51
+ # Or load a specific tool directly
52
+ require 'shared_tools/ruby_llm/edit_file'
53
+ require 'shared_tools/ruby_llm/read_file'
54
+ require 'shared_tools/ruby_llm/python_eval'
55
+ # etc.
98
56
  ```
99
57
 
100
- ### Available Log Levels
58
+ ### Rails and Autoloader Compatibility
101
59
 
102
- The logger supports the standard Ruby Logger levels:
60
+ This gem uses Zeitwerk for autoloading, making it fully compatible with Rails and other Ruby applications that use modern autoloaders. RubyLLM tools are excluded from autoloading and loaded manually to avoid namespace conflicts.
103
61
 
104
- - `logger.debug` - Detailed debug information
105
- - `logger.info` - General information messages
106
- - `logger.warn` - Warning messages
107
- - `logger.error` - Error messages
108
- - `logger.fatal` - Fatal error messages
109
62
 
110
- ### Thread Safety
63
+ ### Special Thanks
111
64
 
112
- The shared logger is thread-safe and can be used across multiple threads in your application.
65
+ A special shout-out to Kevin's [omniai-tools](https://github.com/your-github-url/omniai-tools) gem, which is a curated collection of tools for use with his OmniAI gem.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ verify_gem :llm_rb
7
+
8
+ RunShellCommand = LLM.function(:system) do |fn|
9
+ fn.description "Run a shell command"
10
+
11
+ fn.params do |schema|
12
+ schema.object(command: schema.string.required)
13
+ end
14
+
15
+ fn.define do |params|
16
+ ro, wo = IO.pipe
17
+ re, we = IO.pipe
18
+ Process.wait Process.spawn(params.command, out: wo, err: we)
19
+ [wo, we].each(&:close)
20
+ { stderr: re.read, stdout: ro.read }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../shared_tools'
4
+
5
+ SharedTools.verify_gem :llm_rb
6
+
7
+ Dir.glob(File.join(__dir__, "llm_rb", "*.rb")).each do |file|
8
+ require file
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../shared_tools'
4
+
5
+ SharedTools.verify_gem :omniai
6
+
7
+ Dir.glob(File.join(__dir__, "omniai", "*.rb")).each do |file|
8
+ require file
9
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../shared_tools"
4
+
5
+ module SharedTools
6
+ verify_gem :ruby_llm
7
+
8
+ class WhatIsTheWeather
9
+ include Raix::ChatCompletion
10
+ include Raix::FunctionDispatch
11
+
12
+ function :check_weather,
13
+ "Check the weather for a location",
14
+ location: { type: "string", required: true } do |arguments|
15
+ "The weather in #{arguments[:location]} is hot and sunny"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../shared_tools'
4
+
5
+ SharedTools.verify_gem :raix
6
+
7
+ Dir.glob(File.join(__dir__, "raix", "*.rb")).each do |file|
8
+ require file
9
+ end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require("ruby_llm") unless defined?(RubyLLM)
4
- require("shared_tools") unless defined?(SharedTools)
3
+ require_relative '../../shared_tools'
5
4
 
6
5
  module SharedTools
7
- class EditFile < RubyLLM::Tool
6
+ verify_gem :ruby_llm
7
+
8
+ class EditFile < ::RubyLLM::Tool
8
9
 
9
10
  description <<~DESCRIPTION
10
11
  Make edits to a text file.
@@ -22,47 +23,47 @@ module SharedTools
22
23
  param :replace_all, desc: "Whether to replace all occurrences (true) or just the first one (false)", required: false
23
24
 
24
25
  def execute(path:, old_str:, new_str:, replace_all: false)
25
- logger.info("Editing file: #{path}")
26
+ RubyLLM.logger.info("Editing file: #{path}")
26
27
 
27
28
  # Normalize path to absolute path
28
29
  absolute_path = File.absolute_path(path)
29
30
 
30
31
  if File.exist?(absolute_path)
31
- logger.debug("File exists, reading content")
32
+ RubyLLM.logger.debug("File exists, reading content")
32
33
  content = File.read(absolute_path)
33
34
  else
34
- logger.debug("File doesn't exist, creating new file")
35
+ RubyLLM.logger.debug("File doesn't exist, creating new file")
35
36
  content = ""
36
37
  end
37
38
 
38
39
  matches = content.scan(old_str).size
39
- logger.debug("Found #{matches} matches for the string to replace")
40
+ RubyLLM.logger.debug("Found #{matches} matches for the string to replace")
40
41
 
41
42
  if matches == 0
42
- logger.warn("No matches found for the string to replace. File will remain unchanged.")
43
+ RubyLLM.logger.warn("No matches found for the string to replace. File will remain unchanged.")
43
44
  return { success: false, warning: "No matches found for the string to replace" }
44
45
  end
45
46
 
46
47
  if matches > 1 && !replace_all
47
- logger.warn("Multiple matches (#{matches}) found for the string to replace. Only the first occurrence will be replaced.")
48
+ RubyLLM.logger.warn("Multiple matches (#{matches}) found for the string to replace. Only the first occurrence will be replaced.")
48
49
  end
49
50
 
50
51
  if replace_all
51
52
  updated_content = content.gsub(old_str, new_str)
52
53
  replaced_count = matches
53
- logger.info("Replacing all #{matches} occurrences")
54
+ RubyLLM.logger.info("Replacing all #{matches} occurrences")
54
55
  else
55
56
  updated_content = content.sub(old_str, new_str)
56
57
  replaced_count = 1
57
- logger.info("Replacing first occurrence only")
58
+ RubyLLM.logger.info("Replacing first occurrence only")
58
59
  end
59
60
 
60
61
  File.write(absolute_path, updated_content)
61
62
 
62
- logger.info("Successfully updated file: #{path}")
63
+ RubyLLM.logger.info("Successfully updated file: #{path}")
63
64
  { success: true, matches: matches, replaced: replaced_count }
64
65
  rescue => e
65
- logger.error("Failed to edit file '#{path}': #{e.message}")
66
+ RubyLLM.logger.error("Failed to edit file '#{path}': #{e.message}")
66
67
  { error: e.message }
67
68
  end
68
69
  end
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require("ruby_llm") unless defined?(RubyLLM)
4
- require("shared_tools") unless defined?(SharedTools)
3
+ require_relative '../../shared_tools'
5
4
 
6
5
  module SharedTools
7
- class ListFiles < RubyLLM::Tool
6
+ verify_gem :ruby_llm
7
+
8
+ class ListFiles < ::RubyLLM::Tool
8
9
 
9
10
  description "List files and directories at a given path. If no path is provided, lists files in the current directory."
10
11
  param :path, desc: "Optional relative path to list files from. Defaults to current directory if not provided."
11
12
 
12
13
  def execute(path: Dir.pwd)
13
- logger.info("Listing files in path: #{path}")
14
+ RubyLLM.logger.info("Listing files in path: #{path}")
14
15
 
15
16
  # Convert to absolute path for consistency
16
17
  absolute_path = File.absolute_path(path)
@@ -18,7 +19,7 @@ module SharedTools
18
19
  # Verify the path exists and is a directory
19
20
  unless File.directory?(absolute_path)
20
21
  error_msg = "Path does not exist or is not a directory: #{path}"
21
- logger.error(error_msg)
22
+ RubyLLM.logger.error(error_msg)
22
23
  return { error: error_msg }
23
24
  end
24
25
 
@@ -37,10 +38,10 @@ module SharedTools
37
38
  end
38
39
  end
39
40
 
40
- logger.debug("Found #{formatted_files.size} files/directories (including #{hidden_files.size} hidden)")
41
+ RubyLLM.logger.debug("Found #{formatted_files.size} files/directories (including #{hidden_files.size} hidden)")
41
42
  formatted_files
42
43
  rescue => e
43
- logger.error("Failed to list files in '#{path}': #{e.message}")
44
+ RubyLLM.logger.error("Failed to list files in '#{path}': #{e.message}")
44
45
  { error: e.message }
45
46
  end
46
47
  end
@@ -2,12 +2,12 @@
2
2
  # Credit: https://max.engineer/giant-pdf-llm
3
3
 
4
4
  require "pdf-reader"
5
-
6
- require("ruby_llm") unless defined?(RubyLLM)
7
- require("shared_tools") unless defined?(SharedTools)
5
+ require_relative '../../shared_tools'
8
6
 
9
7
  module SharedTools
10
- class PdfPageReader < RubyLLM::Tool
8
+ verify_gem :ruby_llm
9
+
10
+ class PdfPageReader < ::RubyLLM::Tool
11
11
 
12
12
  description "Read the text of any set of pages from a PDF document."
13
13
  param :page_numbers,
@@ -16,21 +16,21 @@ module SharedTools
16
16
  desc: "Path to the PDF document."
17
17
 
18
18
  def execute(page_numbers:, doc_path:)
19
- logger.info("Reading PDF: #{doc_path}, pages: #{page_numbers}")
19
+ RubyLLM.logger.info("Reading PDF: #{doc_path}, pages: #{page_numbers}")
20
20
 
21
21
  begin
22
22
  @doc ||= PDF::Reader.new(doc_path)
23
- logger.debug("PDF loaded successfully, total pages: #{@doc.pages.size}")
23
+ RubyLLM.logger.debug("PDF loaded successfully, total pages: #{@doc.pages.size}")
24
24
 
25
25
  page_numbers = page_numbers.split(",").map { |num| num.strip.to_i }
26
- logger.debug("Processing pages: #{page_numbers.join(", ")}")
26
+ RubyLLM.logger.debug("Processing pages: #{page_numbers.join(", ")}")
27
27
 
28
28
  # Validate page numbers
29
29
  total_pages = @doc.pages.size
30
30
  invalid_pages = page_numbers.select { |num| num < 1 || num > total_pages }
31
31
 
32
32
  if invalid_pages.any?
33
- logger.warn("Invalid page numbers requested: #{invalid_pages.join(", ")}. Document has #{total_pages} pages.")
33
+ RubyLLM.logger.warn("Invalid page numbers requested: #{invalid_pages.join(", ")}. Document has #{total_pages} pages.")
34
34
  end
35
35
 
36
36
  # Filter valid pages and map to content
@@ -42,15 +42,15 @@ module SharedTools
42
42
  requested_pages: page_numbers,
43
43
  invalid_pages: invalid_pages,
44
44
  pages: pages.map { |num, p|
45
- logger.debug("Extracted text from page #{num} (#{p&.text&.bytesize || 0} bytes)")
45
+ RubyLLM.logger.debug("Extracted text from page #{num} (#{p&.text&.bytesize || 0} bytes)")
46
46
  { page: num, text: p&.text }
47
47
  },
48
48
  }
49
49
 
50
- logger.info("Successfully extracted #{pages.size} pages from PDF")
50
+ RubyLLM.logger.info("Successfully extracted #{pages.size} pages from PDF")
51
51
  result
52
52
  rescue => e
53
- logger.error("Failed to read PDF '#{doc_path}': #{e.message}")
53
+ RubyLLM.logger.error("Failed to read PDF '#{doc_path}': #{e.message}")
54
54
  { error: e.message }
55
55
  end
56
56
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ verify_gem :ruby_llm
7
+
8
+ class PythonEval < ::RubyLLM::Tool
9
+
10
+ description <<~DESCRIPTION
11
+ Execute Python source code safely and return the result.
12
+
13
+ This tool evaluates Python code by writing it to a temporary file
14
+ and executing it with the python3 command, capturing both stdout
15
+ and the final expression result.
16
+
17
+ WARNING: This tool executes arbitrary Python code. Use with caution.
18
+ NOTE: Requires python3 to be available in the system PATH.
19
+ DESCRIPTION
20
+ param :code, desc: "The Python code to execute"
21
+
22
+ def execute(code:)
23
+ RubyLLM.logger.info("Requesting permission to execute Python code")
24
+
25
+ if code.strip.empty?
26
+ error_msg = "Python code cannot be empty"
27
+ RubyLLM.logger.error(error_msg)
28
+ return { error: error_msg }
29
+ end
30
+
31
+ # Show user the code and ask for confirmation
32
+ allowed = SharedTools.execute?(tool: self.class.name, stuff: code)
33
+
34
+ unless allowed
35
+ RubyLLM.logger.warn("User declined to execute the Python code")
36
+ return { error: "User declined to execute the Python code" }
37
+ end
38
+
39
+ RubyLLM.logger.info("Executing Python code")
40
+
41
+ begin
42
+ require 'tempfile'
43
+ require 'open3'
44
+ require 'json'
45
+
46
+ # Create a Python script that captures both output and result
47
+ python_script = create_python_wrapper(code)
48
+
49
+ # Write to temporary file
50
+ temp_file = Tempfile.new(['python_eval', '.py'])
51
+ temp_file.write(python_script)
52
+ temp_file.flush
53
+
54
+ # Execute the Python script
55
+ stdout, stderr, status = Open3.capture3("python3", temp_file.path)
56
+
57
+ temp_file.close
58
+ temp_file.unlink
59
+
60
+ if status.success?
61
+ RubyLLM.logger.debug("Python code execution completed successfully")
62
+ parse_python_output(stdout)
63
+ else
64
+ RubyLLM.logger.error("Python code execution failed: #{stderr}")
65
+ {
66
+ error: stderr.strip,
67
+ success: false
68
+ }
69
+ end
70
+ rescue => e
71
+ RubyLLM.logger.error("Failed to execute Python code: #{e.message}")
72
+ {
73
+ error: e.message,
74
+ backtrace: e.backtrace&.first(5),
75
+ success: false
76
+ }
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def create_python_wrapper(user_code)
83
+ require 'base64'
84
+ encoded_code = Base64.strict_encode64(user_code)
85
+
86
+ <<~PYTHON
87
+ import sys
88
+ import json
89
+ import io
90
+ import base64
91
+ from contextlib import redirect_stdout
92
+
93
+ # Decode the user code
94
+ user_code = base64.b64decode('#{encoded_code}').decode('utf-8')
95
+
96
+ # Capture stdout
97
+ captured_output = io.StringIO()
98
+
99
+ try:
100
+ with redirect_stdout(captured_output):
101
+ # Handle compound statements (semicolon-separated)
102
+ if ';' in user_code and not user_code.strip().startswith('for ') and not user_code.strip().startswith('if '):
103
+ # Split by semicolon, execute all but last, eval the last
104
+ parts = [part.strip() for part in user_code.split(';') if part.strip()]
105
+ if len(parts) > 1:
106
+ for part in parts[:-1]:
107
+ exec(part)
108
+ # Try to eval the last part
109
+ try:
110
+ result = eval(parts[-1])
111
+ except SyntaxError:
112
+ exec(parts[-1])
113
+ result = None
114
+ else:
115
+ # Single part, try eval then exec
116
+ try:
117
+ result = eval(parts[0])
118
+ except SyntaxError:
119
+ exec(parts[0])
120
+ result = None
121
+ else:
122
+ # Try to evaluate as expression first
123
+ try:
124
+ result = eval(user_code)
125
+ except SyntaxError:
126
+ # If not an expression, execute as statement
127
+ exec(user_code)
128
+ result = None
129
+
130
+ output = captured_output.getvalue()
131
+
132
+ # Prepare result for JSON serialization
133
+ try:
134
+ json.dumps(result) # Test if result is JSON serializable
135
+ serializable_result = result
136
+ except (TypeError, ValueError):
137
+ serializable_result = str(result)
138
+
139
+ result_data = {
140
+ "success": True,
141
+ "result": serializable_result,
142
+ "output": output if output else None,
143
+ "python_type": type(result).__name__
144
+ }
145
+
146
+ print("PYTHON_EVAL_RESULT:", json.dumps(result_data))
147
+
148
+ except Exception as e:
149
+ error_data = {
150
+ "success": False,
151
+ "error": str(e),
152
+ "error_type": type(e).__name__
153
+ }
154
+ print("PYTHON_EVAL_RESULT:", json.dumps(error_data))
155
+ PYTHON
156
+ end
157
+
158
+ def parse_python_output(stdout)
159
+ lines = stdout.split("\n")
160
+ result_line = lines.find { |line| line.start_with?("PYTHON_EVAL_RESULT:") }
161
+
162
+ if result_line
163
+ json_data = result_line.sub("PYTHON_EVAL_RESULT:", "").strip
164
+ result = JSON.parse(json_data)
165
+
166
+ # Add display formatting
167
+ if result["success"]
168
+ if result["output"].nil? || result["output"].empty?
169
+ result["display"] = result["result"].inspect
170
+ else
171
+ result_part = result["result"].nil? ? "" : "\n=> #{result["result"].inspect}"
172
+ result["display"] = result["output"] + result_part
173
+ end
174
+ end
175
+
176
+ # Convert string keys to symbols
177
+ result.transform_keys(&:to_sym)
178
+ else
179
+ {
180
+ error: "Failed to parse Python execution result",
181
+ raw_output: stdout,
182
+ success: false
183
+ }
184
+ end
185
+ rescue JSON::ParserError => e
186
+ {
187
+ error: "Failed to parse Python result as JSON: #{e.message}",
188
+ raw_output: stdout,
189
+ success: false
190
+ }
191
+ end
192
+ end
193
+ end
@@ -1,37 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require("ruby_llm") unless defined?(RubyLLM)
4
- require("shared_tools") unless defined?(SharedTools)
3
+ require_relative '../../shared_tools'
5
4
 
6
5
  module SharedTools
7
- class ReadFile < RubyLLM::Tool
6
+ verify_gem :ruby_llm
7
+
8
+ class ReadFile < ::RubyLLM::Tool
8
9
 
9
10
  description "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names."
10
11
  param :path, desc: "The relative path of a file in the working directory."
11
12
 
12
13
  def execute(path:)
13
- logger.info("Reading file: #{path}")
14
+ RubyLLM.logger.info("Reading file: #{path}")
14
15
 
15
16
  # Handle both relative and absolute paths consistently
16
17
  absolute_path = File.absolute_path(path)
17
18
 
18
19
  if File.directory?(absolute_path)
19
20
  error_msg = "Path is a directory, not a file: #{path}"
20
- logger.error(error_msg)
21
+ RubyLLM.logger.error(error_msg)
21
22
  return { error: error_msg }
22
23
  end
23
24
 
24
25
  unless File.exist?(absolute_path)
25
26
  error_msg = "File does not exist: #{path}"
26
- logger.error(error_msg)
27
+ RubyLLM.logger.error(error_msg)
27
28
  return { error: error_msg }
28
29
  end
29
30
 
30
31
  content = File.read(absolute_path)
31
- logger.debug("Successfully read #{content.bytesize} bytes from #{path}")
32
+ RubyLLM.logger.debug("Successfully read #{content.bytesize} bytes from #{path}")
32
33
  content
33
34
  rescue => e
34
- logger.error("Failed to read file '#{path}': #{e.message}")
35
+ RubyLLM.logger.error("Failed to read file '#{path}': #{e.message}")
35
36
  { error: e.message }
36
37
  end
37
38
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ verify_gem :ruby_llm
7
+
8
+ class RubyEval < ::RubyLLM::Tool
9
+
10
+ description <<~DESCRIPTION
11
+ Execute Ruby source code safely and return the result.
12
+
13
+ This tool evaluates Ruby code in a sandboxed context and returns
14
+ the result of the last expression or any output produced.
15
+
16
+ WARNING: This tool executes arbitrary Ruby code. Use with caution.
17
+ DESCRIPTION
18
+ param :code, desc: "The Ruby code to execute"
19
+
20
+ def execute(code:)
21
+ RubyLLM.logger.info("Requesting permission to execute Ruby code")
22
+
23
+ if code.strip.empty?
24
+ error_msg = "Ruby code cannot be empty"
25
+ RubyLLM.logger.error(error_msg)
26
+ return { error: error_msg }
27
+ end
28
+
29
+ # Show user the code and ask for confirmation
30
+ allowed = SharedTools.execute?(tool: self.class.name, stuff: code)
31
+
32
+ unless allowed
33
+ RubyLLM.logger.warn("User declined to execute the Ruby code")
34
+ return { error: "User declined to execute the Ruby code" }
35
+ end
36
+
37
+ RubyLLM.logger.info("Executing Ruby code")
38
+
39
+ # Capture both stdout and the result of evaluation
40
+ original_stdout = $stdout
41
+ captured_output = StringIO.new
42
+ $stdout = captured_output
43
+
44
+ begin
45
+ result = eval(code)
46
+ output = captured_output.string
47
+
48
+ RubyLLM.logger.debug("Ruby code execution completed successfully")
49
+
50
+ response = {
51
+ result: result,
52
+ output: output.empty? ? nil : output,
53
+ success: true
54
+ }
55
+
56
+ # Include both result and output in a readable format
57
+ if output.empty?
58
+ response[:display] = result.inspect
59
+ else
60
+ response[:display] = output + (result.nil? ? "" : "\n=> #{result.inspect}")
61
+ end
62
+
63
+ response
64
+ rescue SyntaxError, StandardError => e
65
+ RubyLLM.logger.error("Ruby code execution failed: #{e.message}")
66
+ {
67
+ error: e.message,
68
+ backtrace: e.backtrace&.first(5),
69
+ success: false
70
+ }
71
+ ensure
72
+ $stdout = original_stdout
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,48 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require("ruby_llm") unless defined?(RubyLLM)
4
- require("shared_tools") unless defined?(SharedTools)
3
+ require_relative '../../shared_tools'
5
4
 
6
5
  module SharedTools
7
- class RunShellCommand < RubyLLM::Tool
6
+ SharedTools.verify_gem :ruby_llm
7
+
8
+ class RunShellCommand < ::RubyLLM::Tool
8
9
 
9
10
  description "Execute a shell command"
10
11
  param :command, desc: "The command to execute"
11
12
 
12
13
  def execute(command:)
13
- logger.info("Requesting permission to execute command: '#{command}'")
14
+ RubyLLM.logger.info("Requesting permission to execute command: '#{command}'")
14
15
 
15
16
  if command.strip.empty?
16
17
  error_msg = "Command cannot be empty"
17
- logger.error(error_msg)
18
+ RubyLLM.logger.error(error_msg)
18
19
  return { error: error_msg }
19
20
  end
20
21
 
21
22
  # Show user the command and ask for confirmation
22
- puts "AI wants to execute the following shell command: '#{command}'"
23
- print "Do you want to execute it? (y/n) "
24
- response = gets.chomp.downcase
23
+ allowed = SharedTools.execute?(tool: self.class.name, stuff: command)
25
24
 
26
- unless response == "y"
27
- logger.warn("User declined to execute the command: '#{command}'")
25
+ unless allowed
26
+ RubyLLM.logger.warn("User declined to execute the command: '#{command}'")
28
27
  return { error: "User declined to execute the command" }
29
28
  end
30
29
 
31
- logger.info("Executing command: '#{command}'")
30
+ RubyLLM.logger.info("Executing command: '#{command}'")
32
31
 
33
32
  # Use Open3 for safer command execution with proper error handling
34
33
  require "open3"
35
34
  stdout, stderr, status = Open3.capture3(command)
36
35
 
37
36
  if status.success?
38
- logger.debug("Command execution completed successfully with #{stdout.bytesize} bytes of output")
37
+ RubyLLM.logger.debug("Command execution completed successfully with #{stdout.bytesize} bytes of output")
39
38
  { stdout: stdout, exit_status: status.exitstatus }
40
39
  else
41
- logger.warn("Command execution failed with exit code #{status.exitstatus}: #{stderr}")
40
+ RubyLLM.logger.warn("Command execution failed with exit code #{status.exitstatus}: #{stderr}")
42
41
  { error: "Command failed with exit code #{status.exitstatus}", stderr: stderr, exit_status: status.exitstatus }
43
42
  end
44
43
  rescue => e
45
- logger.error("Command execution failed: #{e.message}")
44
+ RubyLLM.logger.error("Command execution failed: #{e.message}")
46
45
  { error: e.message }
47
46
  end
48
47
  end
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require("ruby_llm") unless defined?(RubyLLM)
4
- require("shared_tools") unless defined?(SharedTools)
3
+ require_relative '../shared_tools'
5
4
 
6
- require_relative "ruby_llm/edit_file"
7
- require_relative "ruby_llm/list_files"
8
- require_relative "ruby_llm/pdf_page_reader"
9
- require_relative "ruby_llm/read_file"
10
- require_relative "ruby_llm/run_shell_command"
5
+ SharedTools.verify_gem :ruby_llm
6
+
7
+ Dir.glob(File.join(__dir__, "ruby_llm", "*.rb")).each do |file|
8
+ require file
9
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SharedTools
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/shared_tools.rb CHANGED
@@ -1,46 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "shared_tools/version"
3
+ require 'io/console'
4
+
5
+ require "zeitwerk"
6
+ loader = Zeitwerk::Loader.for_gem
7
+ # Ignore aggregate loader files that don't define constants
8
+ loader.ignore("#{__dir__}/shared_tools/ruby_llm.rb")
9
+ loader.ignore("#{__dir__}/shared_tools/llm_rb.rb")
10
+ loader.ignore("#{__dir__}/shared_tools/omniai.rb")
11
+ loader.ignore("#{__dir__}/shared_tools/raix.rb")
12
+ loader.setup
4
13
 
5
14
  module SharedTools
15
+ SUPPORTED_GEMS = %i(ruby_llm llm_rb omniai raix)
16
+ @auto_execute = false # Human in the loop
17
+
6
18
  class << self
7
- # def included(base)
8
- # base.extend(ClassMethods)
9
-
10
- # if base.is_a?(Class)
11
- # base.class_eval do
12
- # include InstanceMethods
13
- # end
14
- # else
15
- # base.module_eval do
16
- # def self.included(sub_base)
17
- # sub_base.include(SharedTools)
18
- # end
19
- # end
20
- # end
21
- # end
22
-
23
- # def extended(object)
24
- # object.extend(ClassMethods)
25
- # end
26
-
27
- # Hook to automatically inject logger into RubyLLM::Tool subclasses
28
- def const_added(const_name)
29
- const = const_get(const_name)
30
-
31
- if const.is_a?(Class) && defined?(RubyLLM::Tool) && const < RubyLLM::Tool
32
- const.class_eval do
33
- def logger
34
- SharedTools.logger
35
- end
36
-
37
- def self.logger
38
- SharedTools.logger
39
- end
40
- end
41
- end
19
+ def auto_execute(wildwest=true)
20
+ @auto_execute = wildwest
21
+ end
22
+
23
+ def execute?(tool: 'unknown', stuff: '')
24
+ # Return true if auto_execute is explicitly enabled
25
+ return true if @auto_execute == true
26
+
27
+ puts "\n\nThe AI (tool: #{tool}) wants to do the following ..."
28
+ puts "="*42
29
+ puts(stuff.empty? ? "unknown strange and mysterious things" : stuff)
30
+ puts "="*42
31
+
32
+ sleep 0.2 if defined?(AIA) # Allows CLI spinner to recycle
33
+ print "\nIs it okay to proceed? (y/N"
34
+ STDIN.getch == "y"
35
+ end
36
+
37
+
38
+ def detected_gem
39
+ return :ruby_llm if defined?(::RubyLLM::Tool)
40
+ return :llm_rb if defined?(::LLM) || defined?(::Llm)
41
+ return :omniai if defined?(::OmniAI) || defined?(::Omniai)
42
+ return :raix if defined?(::Raix::FunctionDispatch)
43
+ nil
44
+ end
45
+
46
+ def verify_gem(a_symbol)
47
+ loaded = a_symbol == detected_gem
48
+ return true if loaded
49
+ raise "SharedTools: Please require '#{a_symbol}' gem before requiring 'shared_tools'."
42
50
  end
43
51
  end
44
- end
45
52
 
46
- require "shared_tools/core"
53
+ if detected_gem.nil?
54
+ warn "⚠️ SharedTools: No supported gem detected. Supported gems are: #{SUPPORTED_GEMS.join(', ')}"
55
+ end
56
+ end
metadata CHANGED
@@ -1,28 +1,56 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shared_tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - MadBomber Team
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: logger
13
+ name: zeitwerk
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.0'
18
+ version: '2.6'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.0'
25
+ version: '2.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: llm.rb
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: omniai
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
26
54
  - !ruby/object:Gem::Dependency
27
55
  name: pdf-reader
28
56
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +65,20 @@ dependencies:
37
65
  - - "~>"
38
66
  - !ruby/object:Gem::Version
39
67
  version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: raix
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
40
82
  - !ruby/object:Gem::Dependency
41
83
  name: ruby_llm
42
84
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +107,20 @@ dependencies:
65
107
  - - "~>"
66
108
  - !ruby/object:Gem::Version
67
109
  version: '2.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: debug_me
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
68
124
  - !ruby/object:Gem::Dependency
69
125
  name: rake
70
126
  requirement: !ruby/object:Gem::Requirement
@@ -93,8 +149,9 @@ dependencies:
93
149
  - - "~>"
94
150
  - !ruby/object:Gem::Version
95
151
  version: '3.0'
96
- description: SharedTools provides common functionality including configurable logging
97
- and LLM tools
152
+ description: |
153
+ SharedTools provides a collection of reusable common tools (aka callback functions)
154
+ for Ruby applications using various LLM-provider API gems.
98
155
  email:
99
156
  - example@example.com
100
157
  executables: []
@@ -105,12 +162,18 @@ files:
105
162
  - LICENSE
106
163
  - README.md
107
164
  - lib/shared_tools.rb
108
- - lib/shared_tools/core.rb
165
+ - lib/shared_tools/llm_rb.rb
166
+ - lib/shared_tools/llm_rb/run_shell_command.rb
167
+ - lib/shared_tools/omniai.rb
168
+ - lib/shared_tools/raix.rb
169
+ - lib/shared_tools/raix/what_is_the_weather.rb
109
170
  - lib/shared_tools/ruby_llm.rb
110
171
  - lib/shared_tools/ruby_llm/edit_file.rb
111
172
  - lib/shared_tools/ruby_llm/list_files.rb
112
173
  - lib/shared_tools/ruby_llm/pdf_page_reader.rb
174
+ - lib/shared_tools/ruby_llm/python_eval.rb
113
175
  - lib/shared_tools/ruby_llm/read_file.rb
176
+ - lib/shared_tools/ruby_llm/ruby_eval.rb
114
177
  - lib/shared_tools/ruby_llm/run_shell_command.rb
115
178
  - lib/shared_tools/version.rb
116
179
  homepage: https://github.com/madbomber/shared_tools
@@ -127,7 +190,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
127
190
  requirements:
128
191
  - - ">="
129
192
  - !ruby/object:Gem::Version
130
- version: 2.7.0
193
+ version: 3.3.0
131
194
  required_rubygems_version: !ruby/object:Gem::Requirement
132
195
  requirements:
133
196
  - - ">="
@@ -136,5 +199,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
136
199
  requirements: []
137
200
  rubygems_version: 3.6.9
138
201
  specification_version: 4
139
- summary: A collection of shared tools and utilities for Ruby applications
202
+ summary: Shared utilities and AI tools for Ruby applications with configurable logging
140
203
  test_files: []
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "logger"
4
- require "singleton"
5
-
6
- module SharedTools
7
- DEFAULT_LOG_LEVEL = Logger::INFO
8
-
9
- module InstanceMethods
10
- # @return [Logger] The shared logger instance
11
- def logger
12
- SharedTools.logger
13
- end
14
- end
15
-
16
- module ClassMethods
17
- # @return [Logger] The shared logger instance
18
- def logger
19
- SharedTools.logger
20
- end
21
- end
22
-
23
-
24
- class LoggerConfiguration
25
- include Singleton
26
-
27
- attr_accessor :level, :log_device, :formatter
28
-
29
- def initialize
30
- @level = DEFAULT_LOG_LEVEL
31
- @log_device = STDOUT
32
- @formatter = nil
33
- end
34
-
35
-
36
- def apply_to(logger)
37
- logger.level = @level
38
- logger.formatter = @formatter if @formatter
39
- end
40
- end
41
-
42
-
43
- class << self
44
- # @return [Logger] The configured logger instance
45
- def logger
46
- @logger ||= create_logger
47
- end
48
-
49
- # @param new_logger [Logger] A Logger instance to use
50
- def logger=(new_logger)
51
- @logger = new_logger
52
- end
53
-
54
-
55
- # @example
56
- # SharedTools.configure_logger do |config|
57
- # config.level = Logger::DEBUG
58
- # config.log_device = 'logs/application.log'
59
- # config.formatter = proc { |severity, time, progname, msg| "[#{time}] #{severity} - #{msg}\n" }
60
- # end
61
- def configure_logger
62
- yield LoggerConfiguration.instance
63
-
64
-
65
- if @logger
66
- @logger = create_logger
67
- end
68
- end
69
-
70
- private
71
-
72
- # @return [Logger] A newly configured logger
73
- def create_logger
74
- config = LoggerConfiguration.instance
75
- logger = Logger.new(config.log_device)
76
- config.apply_to(logger)
77
- logger
78
- end
79
- end
80
-
81
-
82
- # module LoggingSupport
83
- # def self.included(base)
84
- # base.include(SharedTools)
85
- # end
86
- # end
87
- end