shared_tools 0.1.0 → 0.1.2
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 +4 -4
- data/CHANGELOG.md +6 -2
- data/README.md +25 -72
- data/lib/shared_tools/llm_rb/run_shell_command.rb +23 -0
- data/lib/shared_tools/llm_rb.rb +7 -0
- data/lib/shared_tools/omniai.rb +7 -0
- data/lib/shared_tools/ruby_llm/edit_file.rb +14 -13
- data/lib/shared_tools/ruby_llm/list_files.rb +8 -7
- data/lib/shared_tools/ruby_llm/pdf_page_reader.rb +11 -11
- data/lib/shared_tools/ruby_llm/python_eval.rb +198 -0
- data/lib/shared_tools/ruby_llm/read_file.rb +9 -8
- data/lib/shared_tools/ruby_llm/ruby_eval.rb +81 -0
- data/lib/shared_tools/ruby_llm/run_shell_command.rb +11 -10
- data/lib/shared_tools/ruby_llm.rb +4 -7
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools.rb +23 -37
- metadata +57 -10
- data/lib/shared_tools/core.rb +0 -87
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bef20237112478305bf9cdb8e787ac9f71b9e3c32c3349e27f5e5bfead02e182
|
4
|
+
data.tar.gz: b14559042c705dede19698e6cd0a77eaaa95e7e8c098027e7965688f5135588f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 12d20678d2c41ad9c5c2a27bf6a6fbe57cee1e70f68554c0f13fcf20ceb5b5fadee5762a9f4099db282cdd005df2d657b4e8dce16cbfcc5a39e51847731d7cda
|
7
|
+
data.tar.gz: 7c4e14b09e87cf4ff7a2e6f7f679e19c6a62dc3be368c842959a683eafa6ea7d8ce73cc3f02311bb645a53a72c40cb8b7f3a9a8507f11dec3fe3fd259f90cc17
|
data/CHANGELOG.md
CHANGED
@@ -1,9 +1,13 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
##
|
3
|
+
## Unreleased
|
4
4
|
|
5
|
-
|
5
|
+
## Released
|
6
6
|
|
7
|
+
### [0.1.2] 2025-06-10
|
8
|
+
- added `zeitwerk` gem
|
9
|
+
|
10
|
+
### [0.1.0] - 2025-06-05
|
7
11
|
- Initial gem release
|
8
12
|
- SharedTools core module with automatic logger integration
|
9
13
|
- RubyLlm tools: EditFile, ListFiles, PdfPageReader, ReadFile, RunShellCommand
|
data/README.md
CHANGED
@@ -1,9 +1,14 @@
|
|
1
|
-
|
2
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
###
|
41
|
+
### Loading RubyLLM Tools
|
65
42
|
|
66
|
-
|
43
|
+
RubyLLM tools are loaded conditionally when needed:
|
67
44
|
|
68
45
|
```ruby
|
69
|
-
|
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
|
-
|
48
|
+
# Load all RubyLLM tools (requires ruby_llm gem to be available and loaded first)
|
49
|
+
require 'shared_tools/ruby_llm'
|
82
50
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
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
|
-
###
|
58
|
+
### Rails and Autoloader Compatibility
|
101
59
|
|
102
|
-
|
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
|
-
###
|
63
|
+
### Special Thanks
|
111
64
|
|
112
|
-
|
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
|
@@ -1,10 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
require("shared_tools") unless defined?(SharedTools)
|
3
|
+
require_relative '../../shared_tools'
|
5
4
|
|
6
5
|
module SharedTools
|
7
|
-
|
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
|
-
|
4
|
-
require("shared_tools") unless defined?(SharedTools)
|
3
|
+
require_relative '../../shared_tools'
|
5
4
|
|
6
5
|
module SharedTools
|
7
|
-
|
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
|
-
|
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,198 @@
|
|
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
|
+
puts "AI wants to execute the following Python code:"
|
33
|
+
puts "=" * 50
|
34
|
+
puts code
|
35
|
+
puts "=" * 50
|
36
|
+
print "Do you want to execute it? (y/n) "
|
37
|
+
response = gets.chomp.downcase
|
38
|
+
|
39
|
+
unless response == "y"
|
40
|
+
RubyLLM.logger.warn("User declined to execute the Python code")
|
41
|
+
return { error: "User declined to execute the Python code" }
|
42
|
+
end
|
43
|
+
|
44
|
+
RubyLLM.logger.info("Executing Python code")
|
45
|
+
|
46
|
+
begin
|
47
|
+
require 'tempfile'
|
48
|
+
require 'open3'
|
49
|
+
require 'json'
|
50
|
+
|
51
|
+
# Create a Python script that captures both output and result
|
52
|
+
python_script = create_python_wrapper(code)
|
53
|
+
|
54
|
+
# Write to temporary file
|
55
|
+
temp_file = Tempfile.new(['python_eval', '.py'])
|
56
|
+
temp_file.write(python_script)
|
57
|
+
temp_file.flush
|
58
|
+
|
59
|
+
# Execute the Python script
|
60
|
+
stdout, stderr, status = Open3.capture3("python3", temp_file.path)
|
61
|
+
|
62
|
+
temp_file.close
|
63
|
+
temp_file.unlink
|
64
|
+
|
65
|
+
if status.success?
|
66
|
+
RubyLLM.logger.debug("Python code execution completed successfully")
|
67
|
+
parse_python_output(stdout)
|
68
|
+
else
|
69
|
+
RubyLLM.logger.error("Python code execution failed: #{stderr}")
|
70
|
+
{
|
71
|
+
error: stderr.strip,
|
72
|
+
success: false
|
73
|
+
}
|
74
|
+
end
|
75
|
+
rescue => e
|
76
|
+
RubyLLM.logger.error("Failed to execute Python code: #{e.message}")
|
77
|
+
{
|
78
|
+
error: e.message,
|
79
|
+
backtrace: e.backtrace&.first(5),
|
80
|
+
success: false
|
81
|
+
}
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def create_python_wrapper(user_code)
|
88
|
+
require 'base64'
|
89
|
+
encoded_code = Base64.strict_encode64(user_code)
|
90
|
+
|
91
|
+
<<~PYTHON
|
92
|
+
import sys
|
93
|
+
import json
|
94
|
+
import io
|
95
|
+
import base64
|
96
|
+
from contextlib import redirect_stdout
|
97
|
+
|
98
|
+
# Decode the user code
|
99
|
+
user_code = base64.b64decode('#{encoded_code}').decode('utf-8')
|
100
|
+
|
101
|
+
# Capture stdout
|
102
|
+
captured_output = io.StringIO()
|
103
|
+
|
104
|
+
try:
|
105
|
+
with redirect_stdout(captured_output):
|
106
|
+
# Handle compound statements (semicolon-separated)
|
107
|
+
if ';' in user_code and not user_code.strip().startswith('for ') and not user_code.strip().startswith('if '):
|
108
|
+
# Split by semicolon, execute all but last, eval the last
|
109
|
+
parts = [part.strip() for part in user_code.split(';') if part.strip()]
|
110
|
+
if len(parts) > 1:
|
111
|
+
for part in parts[:-1]:
|
112
|
+
exec(part)
|
113
|
+
# Try to eval the last part
|
114
|
+
try:
|
115
|
+
result = eval(parts[-1])
|
116
|
+
except SyntaxError:
|
117
|
+
exec(parts[-1])
|
118
|
+
result = None
|
119
|
+
else:
|
120
|
+
# Single part, try eval then exec
|
121
|
+
try:
|
122
|
+
result = eval(parts[0])
|
123
|
+
except SyntaxError:
|
124
|
+
exec(parts[0])
|
125
|
+
result = None
|
126
|
+
else:
|
127
|
+
# Try to evaluate as expression first
|
128
|
+
try:
|
129
|
+
result = eval(user_code)
|
130
|
+
except SyntaxError:
|
131
|
+
# If not an expression, execute as statement
|
132
|
+
exec(user_code)
|
133
|
+
result = None
|
134
|
+
|
135
|
+
output = captured_output.getvalue()
|
136
|
+
|
137
|
+
# Prepare result for JSON serialization
|
138
|
+
try:
|
139
|
+
json.dumps(result) # Test if result is JSON serializable
|
140
|
+
serializable_result = result
|
141
|
+
except (TypeError, ValueError):
|
142
|
+
serializable_result = str(result)
|
143
|
+
|
144
|
+
result_data = {
|
145
|
+
"success": True,
|
146
|
+
"result": serializable_result,
|
147
|
+
"output": output if output else None,
|
148
|
+
"python_type": type(result).__name__
|
149
|
+
}
|
150
|
+
|
151
|
+
print("PYTHON_EVAL_RESULT:", json.dumps(result_data))
|
152
|
+
|
153
|
+
except Exception as e:
|
154
|
+
error_data = {
|
155
|
+
"success": False,
|
156
|
+
"error": str(e),
|
157
|
+
"error_type": type(e).__name__
|
158
|
+
}
|
159
|
+
print("PYTHON_EVAL_RESULT:", json.dumps(error_data))
|
160
|
+
PYTHON
|
161
|
+
end
|
162
|
+
|
163
|
+
def parse_python_output(stdout)
|
164
|
+
lines = stdout.split("\n")
|
165
|
+
result_line = lines.find { |line| line.start_with?("PYTHON_EVAL_RESULT:") }
|
166
|
+
|
167
|
+
if result_line
|
168
|
+
json_data = result_line.sub("PYTHON_EVAL_RESULT:", "").strip
|
169
|
+
result = JSON.parse(json_data)
|
170
|
+
|
171
|
+
# Add display formatting
|
172
|
+
if result["success"]
|
173
|
+
if result["output"].nil? || result["output"].empty?
|
174
|
+
result["display"] = result["result"].inspect
|
175
|
+
else
|
176
|
+
result_part = result["result"].nil? ? "" : "\n=> #{result["result"].inspect}"
|
177
|
+
result["display"] = result["output"] + result_part
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Convert string keys to symbols
|
182
|
+
result.transform_keys(&:to_sym)
|
183
|
+
else
|
184
|
+
{
|
185
|
+
error: "Failed to parse Python execution result",
|
186
|
+
raw_output: stdout,
|
187
|
+
success: false
|
188
|
+
}
|
189
|
+
end
|
190
|
+
rescue JSON::ParserError => e
|
191
|
+
{
|
192
|
+
error: "Failed to parse Python result as JSON: #{e.message}",
|
193
|
+
raw_output: stdout,
|
194
|
+
success: false
|
195
|
+
}
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
@@ -1,37 +1,38 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
require("shared_tools") unless defined?(SharedTools)
|
3
|
+
require_relative '../../shared_tools'
|
5
4
|
|
6
5
|
module SharedTools
|
7
|
-
|
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,81 @@
|
|
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
|
+
puts "AI wants to execute the following Ruby code:"
|
31
|
+
puts "=" * 50
|
32
|
+
puts code
|
33
|
+
puts "=" * 50
|
34
|
+
print "Do you want to execute it? (y/n) "
|
35
|
+
response = gets.chomp.downcase
|
36
|
+
|
37
|
+
unless response == "y"
|
38
|
+
RubyLLM.logger.warn("User declined to execute the Ruby code")
|
39
|
+
return { error: "User declined to execute the Ruby code" }
|
40
|
+
end
|
41
|
+
|
42
|
+
RubyLLM.logger.info("Executing Ruby code")
|
43
|
+
|
44
|
+
# Capture both stdout and the result of evaluation
|
45
|
+
original_stdout = $stdout
|
46
|
+
captured_output = StringIO.new
|
47
|
+
$stdout = captured_output
|
48
|
+
|
49
|
+
begin
|
50
|
+
result = eval(code)
|
51
|
+
output = captured_output.string
|
52
|
+
|
53
|
+
RubyLLM.logger.debug("Ruby code execution completed successfully")
|
54
|
+
|
55
|
+
response = {
|
56
|
+
result: result,
|
57
|
+
output: output.empty? ? nil : output,
|
58
|
+
success: true
|
59
|
+
}
|
60
|
+
|
61
|
+
# Include both result and output in a readable format
|
62
|
+
if output.empty?
|
63
|
+
response[:display] = result.inspect
|
64
|
+
else
|
65
|
+
response[:display] = output + (result.nil? ? "" : "\n=> #{result.inspect}")
|
66
|
+
end
|
67
|
+
|
68
|
+
response
|
69
|
+
rescue SyntaxError, StandardError => e
|
70
|
+
RubyLLM.logger.error("Ruby code execution failed: #{e.message}")
|
71
|
+
{
|
72
|
+
error: e.message,
|
73
|
+
backtrace: e.backtrace&.first(5),
|
74
|
+
success: false
|
75
|
+
}
|
76
|
+
ensure
|
77
|
+
$stdout = original_stdout
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -1,20 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
require("shared_tools") unless defined?(SharedTools)
|
3
|
+
require_relative '../../shared_tools'
|
5
4
|
|
6
5
|
module SharedTools
|
7
|
-
|
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
|
|
@@ -24,25 +25,25 @@ module SharedTools
|
|
24
25
|
response = gets.chomp.downcase
|
25
26
|
|
26
27
|
unless response == "y"
|
27
|
-
logger.warn("User declined to execute the command: '#{command}'")
|
28
|
+
RubyLLM.logger.warn("User declined to execute the command: '#{command}'")
|
28
29
|
return { error: "User declined to execute the command" }
|
29
30
|
end
|
30
31
|
|
31
|
-
logger.info("Executing command: '#{command}'")
|
32
|
+
RubyLLM.logger.info("Executing command: '#{command}'")
|
32
33
|
|
33
34
|
# Use Open3 for safer command execution with proper error handling
|
34
35
|
require "open3"
|
35
36
|
stdout, stderr, status = Open3.capture3(command)
|
36
37
|
|
37
38
|
if status.success?
|
38
|
-
logger.debug("Command execution completed successfully with #{stdout.bytesize} bytes of output")
|
39
|
+
RubyLLM.logger.debug("Command execution completed successfully with #{stdout.bytesize} bytes of output")
|
39
40
|
{ stdout: stdout, exit_status: status.exitstatus }
|
40
41
|
else
|
41
|
-
logger.warn("Command execution failed with exit code #{status.exitstatus}: #{stderr}")
|
42
|
+
RubyLLM.logger.warn("Command execution failed with exit code #{status.exitstatus}: #{stderr}")
|
42
43
|
{ error: "Command failed with exit code #{status.exitstatus}", stderr: stderr, exit_status: status.exitstatus }
|
43
44
|
end
|
44
45
|
rescue => e
|
45
|
-
logger.error("Command execution failed: #{e.message}")
|
46
|
+
RubyLLM.logger.error("Command execution failed: #{e.message}")
|
46
47
|
{ error: e.message }
|
47
48
|
end
|
48
49
|
end
|
@@ -1,10 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
require("shared_tools") unless defined?(SharedTools)
|
3
|
+
SharedTools.verify_gem :ruby_llm
|
5
4
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
require_relative "ruby_llm/read_file"
|
10
|
-
require_relative "ruby_llm/run_shell_command"
|
5
|
+
Dir.glob(File.join(__dir__, "ruby_llm", "*.rb")).each do |file|
|
6
|
+
require file
|
7
|
+
end
|
data/lib/shared_tools/version.rb
CHANGED
data/lib/shared_tools.rb
CHANGED
@@ -1,46 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "zeitwerk"
|
4
|
+
loader = Zeitwerk::Loader.for_gem
|
5
|
+
# Ignore aggregate loader files that don't define constants
|
6
|
+
loader.ignore("#{__dir__}/shared_tools/ruby_llm.rb")
|
7
|
+
loader.ignore("#{__dir__}/shared_tools/llm_rb.rb")
|
8
|
+
loader.ignore("#{__dir__}/shared_tools/omniai.rb")
|
9
|
+
loader.setup
|
4
10
|
|
5
11
|
module SharedTools
|
6
|
-
|
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
|
12
|
+
@st_supported_gems = [:ruby_llm, :llm_rb, :omniai]
|
26
13
|
|
27
|
-
|
28
|
-
def
|
29
|
-
|
30
|
-
|
31
|
-
if
|
32
|
-
|
33
|
-
|
34
|
-
SharedTools.logger
|
35
|
-
end
|
14
|
+
class << self
|
15
|
+
def detected_gem
|
16
|
+
return :ruby_llm if defined?(::RubyLLM::Tool)
|
17
|
+
return :llm_rb if defined?(::LLM) || defined?(::Llm)
|
18
|
+
return :omniai if defined?(::OmniAI) || defined?(::Omniai)
|
19
|
+
nil
|
20
|
+
end
|
36
21
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
end
|
22
|
+
def verify_gem(a_symbol)
|
23
|
+
loaded = a_symbol == detected_gem
|
24
|
+
return true if loaded
|
25
|
+
raise "SharedTools: Please require '#{a_symbol}' gem before requiring 'shared_tools'."
|
42
26
|
end
|
43
27
|
end
|
44
|
-
end
|
45
28
|
|
46
|
-
|
29
|
+
if detected_gem.nil?
|
30
|
+
warn "⚠️ SharedTools: No supported LLM provider API gem detected. Please require one of: #{@st_supported_gems.join(', ')} before requiring shared_tools."
|
31
|
+
end
|
32
|
+
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.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- MadBomber Team
|
8
|
-
bindir:
|
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:
|
13
|
+
name: zeitwerk
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
15
15
|
requirements:
|
16
16
|
- - "~>"
|
17
17
|
- !ruby/object:Gem::Version
|
18
|
-
version: '
|
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: '
|
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
|
@@ -65,6 +93,20 @@ dependencies:
|
|
65
93
|
- - "~>"
|
66
94
|
- !ruby/object:Gem::Version
|
67
95
|
version: '2.0'
|
96
|
+
- !ruby/object:Gem::Dependency
|
97
|
+
name: debug_me
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
type: :development
|
104
|
+
prerelease: false
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
68
110
|
- !ruby/object:Gem::Dependency
|
69
111
|
name: rake
|
70
112
|
requirement: !ruby/object:Gem::Requirement
|
@@ -93,8 +135,9 @@ dependencies:
|
|
93
135
|
- - "~>"
|
94
136
|
- !ruby/object:Gem::Version
|
95
137
|
version: '3.0'
|
96
|
-
description:
|
97
|
-
|
138
|
+
description: |
|
139
|
+
SharedTools provides a collection of reusable common tools (aka callback functions)
|
140
|
+
for Ruby applications using various LLM-provider API gems.
|
98
141
|
email:
|
99
142
|
- example@example.com
|
100
143
|
executables: []
|
@@ -105,12 +148,16 @@ files:
|
|
105
148
|
- LICENSE
|
106
149
|
- README.md
|
107
150
|
- lib/shared_tools.rb
|
108
|
-
- lib/shared_tools/
|
151
|
+
- lib/shared_tools/llm_rb.rb
|
152
|
+
- lib/shared_tools/llm_rb/run_shell_command.rb
|
153
|
+
- lib/shared_tools/omniai.rb
|
109
154
|
- lib/shared_tools/ruby_llm.rb
|
110
155
|
- lib/shared_tools/ruby_llm/edit_file.rb
|
111
156
|
- lib/shared_tools/ruby_llm/list_files.rb
|
112
157
|
- lib/shared_tools/ruby_llm/pdf_page_reader.rb
|
158
|
+
- lib/shared_tools/ruby_llm/python_eval.rb
|
113
159
|
- lib/shared_tools/ruby_llm/read_file.rb
|
160
|
+
- lib/shared_tools/ruby_llm/ruby_eval.rb
|
114
161
|
- lib/shared_tools/ruby_llm/run_shell_command.rb
|
115
162
|
- lib/shared_tools/version.rb
|
116
163
|
homepage: https://github.com/madbomber/shared_tools
|
@@ -127,7 +174,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
127
174
|
requirements:
|
128
175
|
- - ">="
|
129
176
|
- !ruby/object:Gem::Version
|
130
|
-
version:
|
177
|
+
version: 3.3.0
|
131
178
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
132
179
|
requirements:
|
133
180
|
- - ">="
|
@@ -136,5 +183,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
136
183
|
requirements: []
|
137
184
|
rubygems_version: 3.6.9
|
138
185
|
specification_version: 4
|
139
|
-
summary:
|
186
|
+
summary: Shared utilities and AI tools for Ruby applications with configurable logging
|
140
187
|
test_files: []
|
data/lib/shared_tools/core.rb
DELETED
@@ -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
|