shared_tools 0.2.1 → 0.3.0

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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -0
  3. data/README.md +594 -42
  4. data/lib/shared_tools/{ruby_llm/mcp → mcp}/github_mcp_server.rb +31 -24
  5. data/lib/shared_tools/mcp/imcp.rb +28 -0
  6. data/lib/shared_tools/mcp/tavily_mcp_server.rb +44 -0
  7. data/lib/shared_tools/mcp.rb +24 -0
  8. data/lib/shared_tools/tools/browser/base_driver.rb +64 -0
  9. data/lib/shared_tools/tools/browser/base_tool.rb +50 -0
  10. data/lib/shared_tools/tools/browser/click_tool.rb +54 -0
  11. data/lib/shared_tools/tools/browser/elements/element_grouper.rb +73 -0
  12. data/lib/shared_tools/tools/browser/elements/nearby_element_detector.rb +109 -0
  13. data/lib/shared_tools/tools/browser/formatters/action_formatter.rb +37 -0
  14. data/lib/shared_tools/tools/browser/formatters/data_entry_formatter.rb +135 -0
  15. data/lib/shared_tools/tools/browser/formatters/element_formatter.rb +52 -0
  16. data/lib/shared_tools/tools/browser/formatters/input_formatter.rb +59 -0
  17. data/lib/shared_tools/tools/browser/inspect_tool.rb +87 -0
  18. data/lib/shared_tools/tools/browser/inspect_utils.rb +51 -0
  19. data/lib/shared_tools/tools/browser/page_inspect/button_summarizer.rb +140 -0
  20. data/lib/shared_tools/tools/browser/page_inspect/form_summarizer.rb +98 -0
  21. data/lib/shared_tools/tools/browser/page_inspect/html_summarizer.rb +37 -0
  22. data/lib/shared_tools/tools/browser/page_inspect/link_summarizer.rb +103 -0
  23. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +55 -0
  24. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +39 -0
  25. data/lib/shared_tools/tools/browser/selector_generator/base_selectors.rb +28 -0
  26. data/lib/shared_tools/tools/browser/selector_generator/contextual_selectors.rb +140 -0
  27. data/lib/shared_tools/tools/browser/selector_generator.rb +73 -0
  28. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +67 -0
  29. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +45 -0
  30. data/lib/shared_tools/tools/browser/visit_tool.rb +43 -0
  31. data/lib/shared_tools/tools/browser/watir_driver.rb +132 -0
  32. data/lib/shared_tools/tools/browser.rb +27 -0
  33. data/lib/shared_tools/tools/browser_tool.rb +255 -0
  34. data/lib/shared_tools/tools/calculator_tool.rb +169 -0
  35. data/lib/shared_tools/tools/composite_analysis_tool.rb +520 -0
  36. data/lib/shared_tools/tools/computer/base_driver.rb +177 -0
  37. data/lib/shared_tools/tools/computer/mac_driver.rb +103 -0
  38. data/lib/shared_tools/tools/computer.rb +21 -0
  39. data/lib/shared_tools/tools/computer_tool.rb +207 -0
  40. data/lib/shared_tools/tools/data_science_kit.rb +707 -0
  41. data/lib/shared_tools/tools/database/base_driver.rb +17 -0
  42. data/lib/shared_tools/tools/database/postgres_driver.rb +30 -0
  43. data/lib/shared_tools/tools/database/sqlite_driver.rb +29 -0
  44. data/lib/shared_tools/tools/database.rb +9 -0
  45. data/lib/shared_tools/tools/database_query_tool.rb +313 -0
  46. data/lib/shared_tools/tools/database_tool.rb +99 -0
  47. data/lib/shared_tools/tools/devops_toolkit.rb +420 -0
  48. data/lib/shared_tools/tools/disk/base_driver.rb +91 -0
  49. data/lib/shared_tools/tools/disk/base_tool.rb +20 -0
  50. data/lib/shared_tools/tools/disk/directory_create_tool.rb +39 -0
  51. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +39 -0
  52. data/lib/shared_tools/tools/disk/directory_list_tool.rb +37 -0
  53. data/lib/shared_tools/tools/disk/directory_move_tool.rb +40 -0
  54. data/lib/shared_tools/tools/disk/file_create_tool.rb +38 -0
  55. data/lib/shared_tools/tools/disk/file_delete_tool.rb +40 -0
  56. data/lib/shared_tools/tools/disk/file_move_tool.rb +43 -0
  57. data/lib/shared_tools/tools/disk/file_read_tool.rb +40 -0
  58. data/lib/shared_tools/tools/disk/file_replace_tool.rb +44 -0
  59. data/lib/shared_tools/tools/disk/file_write_tool.rb +40 -0
  60. data/lib/shared_tools/tools/disk/local_driver.rb +91 -0
  61. data/lib/shared_tools/tools/disk.rb +17 -0
  62. data/lib/shared_tools/tools/disk_tool.rb +132 -0
  63. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +79 -0
  64. data/lib/shared_tools/tools/doc.rb +8 -0
  65. data/lib/shared_tools/tools/doc_tool.rb +109 -0
  66. data/lib/shared_tools/tools/docker/base_tool.rb +56 -0
  67. data/lib/shared_tools/tools/docker/compose_run_tool.rb +77 -0
  68. data/lib/shared_tools/tools/docker.rb +8 -0
  69. data/lib/shared_tools/tools/error_handling_tool.rb +403 -0
  70. data/lib/shared_tools/tools/eval/python_eval_tool.rb +209 -0
  71. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +93 -0
  72. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +64 -0
  73. data/lib/shared_tools/tools/eval.rb +10 -0
  74. data/lib/shared_tools/tools/eval_tool.rb +139 -0
  75. data/lib/shared_tools/tools/secure_tool_template.rb +353 -0
  76. data/lib/shared_tools/tools/version.rb +7 -0
  77. data/lib/shared_tools/tools/weather_tool.rb +197 -0
  78. data/lib/shared_tools/tools/workflow_manager_tool.rb +312 -0
  79. data/lib/shared_tools/tools.rb +16 -0
  80. data/lib/shared_tools/version.rb +1 -1
  81. data/lib/shared_tools.rb +9 -33
  82. metadata +189 -68
  83. data/lib/shared_tools/llm_rb/run_shell_command.rb +0 -23
  84. data/lib/shared_tools/llm_rb.rb +0 -9
  85. data/lib/shared_tools/omniai.rb +0 -9
  86. data/lib/shared_tools/raix/what_is_the_weather.rb +0 -18
  87. data/lib/shared_tools/raix.rb +0 -9
  88. data/lib/shared_tools/ruby_llm/edit_file.rb +0 -71
  89. data/lib/shared_tools/ruby_llm/incomplete/calculator_tool.rb +0 -70
  90. data/lib/shared_tools/ruby_llm/incomplete/composite_analysis_tool.rb +0 -89
  91. data/lib/shared_tools/ruby_llm/incomplete/data_science_kit.rb +0 -128
  92. data/lib/shared_tools/ruby_llm/incomplete/database_query_tool.rb +0 -100
  93. data/lib/shared_tools/ruby_llm/incomplete/devops_toolkit.rb +0 -112
  94. data/lib/shared_tools/ruby_llm/incomplete/error_handling_tool.rb +0 -109
  95. data/lib/shared_tools/ruby_llm/incomplete/secure_tool_template.rb +0 -117
  96. data/lib/shared_tools/ruby_llm/incomplete/weather_tool.rb +0 -110
  97. data/lib/shared_tools/ruby_llm/incomplete/workflow_manager_tool.rb +0 -145
  98. data/lib/shared_tools/ruby_llm/list_files.rb +0 -49
  99. data/lib/shared_tools/ruby_llm/mcp/imcp.rb +0 -33
  100. data/lib/shared_tools/ruby_llm/mcp.rb +0 -10
  101. data/lib/shared_tools/ruby_llm/pdf_page_reader.rb +0 -59
  102. data/lib/shared_tools/ruby_llm/python_eval.rb +0 -194
  103. data/lib/shared_tools/ruby_llm/read_file.rb +0 -40
  104. data/lib/shared_tools/ruby_llm/ruby_eval.rb +0 -77
  105. data/lib/shared_tools/ruby_llm/run_shell_command.rb +0 -49
  106. data/lib/shared_tools/ruby_llm.rb +0 -12
@@ -1,110 +0,0 @@
1
- # weather_tool.rb - API integration example
2
- require 'ruby_llm/tool'
3
- require 'net/http'
4
- require 'json'
5
-
6
- module Tools
7
- class WeatherTool < RubyLLM::Tool
8
- def self.name = 'weather_tool'
9
-
10
- description <<~DESCRIPTION
11
- Retrieve comprehensive current weather information for any city worldwide using the OpenWeatherMap API.
12
- This tool provides real-time weather data including temperature, atmospheric conditions, humidity,
13
- and wind information. It supports multiple temperature units and can optionally include extended
14
- forecast data. The tool requires a valid OpenWeatherMap API key to be configured in the
15
- OPENWEATHER_API_KEY environment variable. All weather data is fetched in real-time and includes
16
- timestamps for accuracy verification.
17
- DESCRIPTION
18
-
19
- param :city,
20
- desc: <<~DESC,
21
- Name of the city for weather lookup. Can include city name only (e.g., 'London')
22
- or city with country code for better accuracy (e.g., 'London,UK' or 'Paris,FR').
23
- For cities with common names in multiple countries, including the country code
24
- is recommended to ensure accurate results. The API will attempt to find the
25
- closest match if an exact match is not found.
26
- DESC
27
- type: :string,
28
- required: true
29
-
30
- param :units,
31
- desc: <<~DESC,
32
- Temperature unit system for the weather data. Options are:
33
- - 'metric': Temperature in Celsius, wind speed in m/s, pressure in hPa
34
- - 'imperial': Temperature in Fahrenheit, wind speed in mph, pressure in hPa
35
- - 'kelvin': Temperature in Kelvin (scientific standard), wind speed in m/s
36
- Default is 'metric' which is most commonly used internationally.
37
- DESC
38
- type: :string,
39
- default: "metric",
40
- enum: ["metric", "imperial", "kelvin"]
41
-
42
- param :include_forecast,
43
- desc: <<~DESC,
44
- Boolean flag to include a 3-day weather forecast in addition to current conditions.
45
- When set to true, the response will include forecast data with daily high/low temperatures,
46
- precipitation probability, and general weather conditions for the next three days.
47
- This requires additional API calls and may increase response time slightly.
48
- DESC
49
- type: :boolean,
50
- default: false
51
-
52
- def execute(city:, units: "metric", include_forecast: false)
53
- begin
54
- api_key = ENV['OPENWEATHER_API_KEY']
55
- raise "OpenWeather API key not configured" unless api_key
56
-
57
- current_weather = fetch_current_weather(city, units, api_key)
58
- result = {
59
- success: true,
60
- city: city,
61
- current: current_weather,
62
- units: units,
63
- timestamp: Time.now.iso8601
64
- }
65
-
66
- if include_forecast
67
- forecast_data = fetch_forecast(city, units, api_key)
68
- result[:forecast] = forecast_data
69
- end
70
-
71
- result
72
- rescue => e
73
- {
74
- success: false,
75
- error: e.message,
76
- city: city,
77
- suggestion: "Verify city name and API key configuration"
78
- }
79
- end
80
- end
81
-
82
- private
83
-
84
- def fetch_current_weather(city, units, api_key)
85
- uri = URI("https://api.openweathermap.org/data/2.5/weather")
86
- params = {
87
- q: city,
88
- appid: api_key,
89
- units: units
90
- }
91
- uri.query = URI.encode_www_form(params)
92
-
93
- response = Net::HTTP.get_response(uri)
94
- raise "Weather API error: #{response.code}" unless response.code == '200'
95
-
96
- data = JSON.parse(response.body)
97
- {
98
- temperature: data['main']['temp'],
99
- description: data['weather'][0]['description'],
100
- humidity: data['main']['humidity'],
101
- wind_speed: data['wind']['speed']
102
- }
103
- end
104
-
105
- def fetch_forecast(city, units, api_key)
106
- # Implementation for forecast data
107
- # Similar pattern to current weather
108
- end
109
- end
110
- end
@@ -1,145 +0,0 @@
1
- # workflow_manager_tool.rb - Managing state across tool invocations
2
- require 'ruby_llm/tool'
3
- require 'securerandom'
4
- require 'json'
5
-
6
- module Tools
7
- class WorkflowManager < RubyLLM::Tool
8
- def self.name = 'workflow_manager'
9
-
10
- description <<~DESCRIPTION
11
- Manage complex multi-step workflows with persistent state tracking across tool invocations.
12
- This tool enables the creation and management of stateful workflows that can span multiple
13
- AI interactions and tool calls. It provides workflow initialization, step-by-step execution,
14
- status monitoring, and completion tracking. Each workflow maintains its state in persistent
15
- storage, allowing for resumption of long-running processes and coordination between
16
- multiple tools and AI interactions. Perfect for complex automation tasks that require
17
- multiple stages and decision points.
18
- DESCRIPTION
19
-
20
- param :action,
21
- desc: <<~DESC,
22
- Workflow management action to perform:
23
- - 'start': Initialize a new workflow with initial data and return workflow ID
24
- - 'step': Execute the next step in an existing workflow using provided step data
25
- - 'status': Check the current status and progress of an existing workflow
26
- - 'complete': Mark a workflow as finished and clean up associated resources
27
- Each action requires different combinations of the other parameters.
28
- DESC
29
- type: :string,
30
- required: true,
31
- enum: ["start", "step", "status", "complete"]
32
-
33
- param :workflow_id,
34
- desc: <<~DESC,
35
- Unique identifier for an existing workflow. Required for 'step', 'status', and 'complete'
36
- actions. This ID is returned when starting a new workflow and should be used for all
37
- subsequent operations on that workflow. The ID is a UUID string that ensures
38
- uniqueness across all workflow instances.
39
- DESC
40
- type: :string
41
-
42
- param :step_data,
43
- desc: <<~DESC,
44
- Hash containing data and parameters specific to the current workflow step.
45
- For 'start' action: Initial configuration and parameters for the workflow.
46
- For 'step' action: Input data, parameters, and context needed for the next step.
47
- The structure depends on the specific workflow type and current step requirements.
48
- Can include nested hashes, arrays, and any JSON-serializable data types.
49
- DESC
50
- type: :hash,
51
- default: {}
52
-
53
- def execute(action:, workflow_id: nil, step_data: {})
54
- case action
55
- when "start"
56
- start_workflow(step_data)
57
- when "step"
58
- process_workflow_step(workflow_id, step_data)
59
- when "status"
60
- get_workflow_status(workflow_id)
61
- when "complete"
62
- complete_workflow(workflow_id)
63
- else
64
- { success: false, error: "Unknown action: #{action}" }
65
- end
66
- end
67
-
68
- private
69
-
70
- def start_workflow(initial_data)
71
- workflow_id = SecureRandom.uuid
72
- workflow_state = {
73
- id: workflow_id,
74
- status: "active",
75
- steps: [],
76
- created_at: Time.now.iso8601,
77
- data: initial_data
78
- }
79
-
80
- save_workflow_state(workflow_id, workflow_state)
81
-
82
- {
83
- success: true,
84
- workflow_id: workflow_id,
85
- status: "started",
86
- next_actions: suggested_next_actions(initial_data)
87
- }
88
- end
89
-
90
- def process_workflow_step(workflow_id, step_data)
91
- workflow_state = load_workflow_state(workflow_id)
92
- return { success: false, error: "Workflow not found" } unless workflow_state
93
-
94
- step = {
95
- step_number: workflow_state[:steps].length + 1,
96
- data: step_data,
97
- processed_at: Time.now.iso8601,
98
- result: process_step_logic(step_data, workflow_state)
99
- }
100
-
101
- workflow_state[:steps] << step
102
- workflow_state[:updated_at] = Time.now.iso8601
103
-
104
- save_workflow_state(workflow_id, workflow_state)
105
-
106
- {
107
- success: true,
108
- workflow_id: workflow_id,
109
- step_completed: step,
110
- workflow_status: workflow_state[:status],
111
- next_actions: suggested_next_actions(workflow_state)
112
- }
113
- end
114
-
115
- def save_workflow_state(workflow_id, state)
116
- # Implementation for state persistence
117
- # Could use files, database, or memory store
118
- File.write(".workflow_#{workflow_id}.json", state.to_json)
119
- end
120
-
121
- def load_workflow_state(workflow_id)
122
- # Implementation for state loading
123
- file_path = ".workflow_#{workflow_id}.json"
124
- return nil unless File.exist?(file_path)
125
-
126
- JSON.parse(File.read(file_path), symbolize_names: true)
127
- end
128
-
129
- def get_workflow_status(workflow_id)
130
- # Implementation for status retrieval
131
- end
132
-
133
- def complete_workflow(workflow_id)
134
- # Implementation for workflow completion
135
- end
136
-
137
- def suggested_next_actions(workflow_state)
138
- # Implementation for suggesting next actions
139
- end
140
-
141
- def process_step_logic(step_data, workflow_state)
142
- # Implementation for processing step logic
143
- end
144
- end
145
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../../shared_tools'
4
-
5
- module SharedTools
6
- verify_gem :ruby_llm
7
-
8
- class ListFiles < ::RubyLLM::Tool
9
- def self.name = 'list_files'
10
-
11
- description "List files and directories at a given path. If no path is provided, lists files in the current directory."
12
- param :path, desc: "Optional relative path to list files from. Defaults to current directory if not provided."
13
-
14
- def execute(path: Dir.pwd)
15
- RubyLLM.logger.info("Listing files in path: #{path}")
16
-
17
- # Convert to absolute path for consistency
18
- absolute_path = File.absolute_path(path)
19
-
20
- # Verify the path exists and is a directory
21
- unless File.directory?(absolute_path)
22
- error_msg = "Path does not exist or is not a directory: #{path}"
23
- RubyLLM.logger.error(error_msg)
24
- return { error: error_msg }
25
- end
26
-
27
- # Get all files including hidden ones
28
- visible_files = Dir.glob(File.join(absolute_path, "*"))
29
- hidden_files = Dir.glob(File.join(absolute_path, ".*"))
30
- .reject { |f| f.end_with?("/.") || f.end_with?("/..") }
31
-
32
- # Combine and format results
33
- all_files = (visible_files + hidden_files).sort
34
- formatted_files = all_files.map do |filename|
35
- if File.directory?(filename)
36
- "#{filename}/"
37
- else
38
- filename
39
- end
40
- end
41
-
42
- RubyLLM.logger.debug("Found #{formatted_files.size} files/directories (including #{hidden_files.size} hidden)")
43
- formatted_files
44
- rescue => e
45
- RubyLLM.logger.error("Failed to list files in '#{path}': #{e.message}")
46
- { error: e.message }
47
- end
48
- end
49
- end
@@ -1,33 +0,0 @@
1
- # shared_tools/ruby_llm/mcp/imcp.rb
2
- # iMCP is a MacOS program that provides access to notes,calendar,contacts, etc.
3
- # See: https://github.com/loopwork/iMCP
4
- # brew install --cask loopwork/tap/iMCP
5
- #
6
- # CAUTION: AIA is getting an exception when trying to use this MCP client. Its returning to
7
- # do a to_sym on a nil value. This is due to a lack of a nil guard in the
8
- # version 0.3.1 of the ruby_llm-mpc Parameter#item_type method.
9
- #
10
- # NOTE: iMCP's server is a noisy little thing shooting all its log messages to STDERR.
11
- # To silence it, redirect STDERR to /dev/null.
12
- # If you messages then you might want to redirect STDERR to a file.
13
- #
14
-
15
- require 'debug_me'
16
- include DebugMe
17
-
18
- require 'ruby_llm'
19
- require 'ruby_llm/mcp'
20
-
21
- require_relative '../../../shared_tools'
22
-
23
- module SharedTools
24
- verify_gem :ruby_llm
25
-
26
- mcp_servers << RubyLLM::MCP.client(
27
- name: "imcp-server",
28
- transport_type: :stdio,
29
- config: {
30
- command: "/Applications/iMCP.app/Contents/MacOS/imcp-server 2> /dev/null"
31
- }
32
- )
33
- end
@@ -1,10 +0,0 @@
1
- # shared_tools/ruby_llm/mcp.rb
2
- # This file loads all Ruby files in the mcp directory
3
-
4
- # Get the directory path
5
- mcp_dir = File.join(__dir__, 'mcp')
6
-
7
- # Load all .rb files in the mcp directory
8
- Dir.glob(File.join(mcp_dir, '*.rb')).each do |file|
9
- require file
10
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
- # Credit: https://max.engineer/giant-pdf-llm
3
-
4
- require "pdf-reader"
5
- require_relative '../../shared_tools'
6
-
7
- module SharedTools
8
- verify_gem :ruby_llm
9
-
10
- class PdfPageReader < ::RubyLLM::Tool
11
- def self.name = 'pdf_page_reader'
12
-
13
- description "Read the text of any set of pages from a PDF document."
14
- param :page_numbers,
15
- desc: 'Comma-separated page numbers (first page: 1). (e.g. "12, 14, 15")'
16
- param :doc_path,
17
- desc: "Path to the PDF document."
18
-
19
- def execute(page_numbers:, doc_path:)
20
- RubyLLM.logger.info("Reading PDF: #{doc_path}, pages: #{page_numbers}")
21
-
22
- begin
23
- @doc ||= PDF::Reader.new(doc_path)
24
- RubyLLM.logger.debug("PDF loaded successfully, total pages: #{@doc.pages.size}")
25
-
26
- page_numbers = page_numbers.split(",").map { |num| num.strip.to_i }
27
- RubyLLM.logger.debug("Processing pages: #{page_numbers.join(", ")}")
28
-
29
- # Validate page numbers
30
- total_pages = @doc.pages.size
31
- invalid_pages = page_numbers.select { |num| num < 1 || num > total_pages }
32
-
33
- if invalid_pages.any?
34
- RubyLLM.logger.warn("Invalid page numbers requested: #{invalid_pages.join(", ")}. Document has #{total_pages} pages.")
35
- end
36
-
37
- # Filter valid pages and map to content
38
- valid_pages = page_numbers.select { |num| num >= 1 && num <= total_pages }
39
- pages = valid_pages.map { |num| [num, @doc.pages[num.to_i - 1]] }
40
-
41
- result = {
42
- total_pages: total_pages,
43
- requested_pages: page_numbers,
44
- invalid_pages: invalid_pages,
45
- pages: pages.map { |num, p|
46
- RubyLLM.logger.debug("Extracted text from page #{num} (#{p&.text&.bytesize || 0} bytes)")
47
- { page: num, text: p&.text }
48
- },
49
- }
50
-
51
- RubyLLM.logger.info("Successfully extracted #{pages.size} pages from PDF")
52
- result
53
- rescue => e
54
- RubyLLM.logger.error("Failed to read PDF '#{doc_path}': #{e.message}")
55
- { error: e.message }
56
- end
57
- end
58
- end
59
- end
@@ -1,194 +0,0 @@
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
- def self.name = 'python_eval'
10
-
11
- description <<~DESCRIPTION
12
- Execute Python source code safely and return the result.
13
-
14
- This tool evaluates Python code by writing it to a temporary file
15
- and executing it with the python3 command, capturing both stdout
16
- and the final expression result.
17
-
18
- WARNING: This tool executes arbitrary Python code. Use with caution.
19
- NOTE: Requires python3 to be available in the system PATH.
20
- DESCRIPTION
21
- param :code, desc: "The Python code to execute"
22
-
23
- def execute(code:)
24
- RubyLLM.logger.info("Requesting permission to execute Python code")
25
-
26
- if code.strip.empty?
27
- error_msg = "Python code cannot be empty"
28
- RubyLLM.logger.error(error_msg)
29
- return { error: error_msg }
30
- end
31
-
32
- # Show user the code and ask for confirmation
33
- allowed = SharedTools.execute?(tool: self.class.to_s, stuff: code)
34
-
35
- unless allowed
36
- RubyLLM.logger.warn("User declined to execute the Python code")
37
- return { error: "User declined to execute the Python code" }
38
- end
39
-
40
- RubyLLM.logger.info("Executing Python code")
41
-
42
- begin
43
- require 'tempfile'
44
- require 'open3'
45
- require 'json'
46
-
47
- # Create a Python script that captures both output and result
48
- python_script = create_python_wrapper(code)
49
-
50
- # Write to temporary file
51
- temp_file = Tempfile.new(['python_eval', '.py'])
52
- temp_file.write(python_script)
53
- temp_file.flush
54
-
55
- # Execute the Python script
56
- stdout, stderr, status = Open3.capture3("python3", temp_file.path)
57
-
58
- temp_file.close
59
- temp_file.unlink
60
-
61
- if status.success?
62
- RubyLLM.logger.debug("Python code execution completed successfully")
63
- parse_python_output(stdout)
64
- else
65
- RubyLLM.logger.error("Python code execution failed: #{stderr}")
66
- {
67
- error: stderr.strip,
68
- success: false
69
- }
70
- end
71
- rescue => e
72
- RubyLLM.logger.error("Failed to execute Python code: #{e.message}")
73
- {
74
- error: e.message,
75
- backtrace: e.backtrace&.first(5),
76
- success: false
77
- }
78
- end
79
- end
80
-
81
- private
82
-
83
- def create_python_wrapper(user_code)
84
- require 'base64'
85
- encoded_code = Base64.strict_encode64(user_code)
86
-
87
- <<~PYTHON
88
- import sys
89
- import json
90
- import io
91
- import base64
92
- from contextlib import redirect_stdout
93
-
94
- # Decode the user code
95
- user_code = base64.b64decode('#{encoded_code}').decode('utf-8')
96
-
97
- # Capture stdout
98
- captured_output = io.StringIO()
99
-
100
- try:
101
- with redirect_stdout(captured_output):
102
- # Handle compound statements (semicolon-separated)
103
- if ';' in user_code and not user_code.strip().startswith('for ') and not user_code.strip().startswith('if '):
104
- # Split by semicolon, execute all but last, eval the last
105
- parts = [part.strip() for part in user_code.split(';') if part.strip()]
106
- if len(parts) > 1:
107
- for part in parts[:-1]:
108
- exec(part)
109
- # Try to eval the last part
110
- try:
111
- result = eval(parts[-1])
112
- except SyntaxError:
113
- exec(parts[-1])
114
- result = None
115
- else:
116
- # Single part, try eval then exec
117
- try:
118
- result = eval(parts[0])
119
- except SyntaxError:
120
- exec(parts[0])
121
- result = None
122
- else:
123
- # Try to evaluate as expression first
124
- try:
125
- result = eval(user_code)
126
- except SyntaxError:
127
- # If not an expression, execute as statement
128
- exec(user_code)
129
- result = None
130
-
131
- output = captured_output.getvalue()
132
-
133
- # Prepare result for JSON serialization
134
- try:
135
- json.dumps(result) # Test if result is JSON serializable
136
- serializable_result = result
137
- except (TypeError, ValueError):
138
- serializable_result = str(result)
139
-
140
- result_data = {
141
- "success": True,
142
- "result": serializable_result,
143
- "output": output if output else None,
144
- "python_type": type(result).__name__
145
- }
146
-
147
- print("PYTHON_EVAL_RESULT:", json.dumps(result_data))
148
-
149
- except Exception as e:
150
- error_data = {
151
- "success": False,
152
- "error": str(e),
153
- "error_type": type(e).__name__
154
- }
155
- print("PYTHON_EVAL_RESULT:", json.dumps(error_data))
156
- PYTHON
157
- end
158
-
159
- def parse_python_output(stdout)
160
- lines = stdout.split("\n")
161
- result_line = lines.find { |line| line.start_with?("PYTHON_EVAL_RESULT:") }
162
-
163
- if result_line
164
- json_data = result_line.sub("PYTHON_EVAL_RESULT:", "").strip
165
- result = JSON.parse(json_data)
166
-
167
- # Add display formatting
168
- if result["success"]
169
- if result["output"].nil? || result["output"].empty?
170
- result["display"] = result["result"].inspect
171
- else
172
- result_part = result["result"].nil? ? "" : "\n=> #{result["result"].inspect}"
173
- result["display"] = result["output"] + result_part
174
- end
175
- end
176
-
177
- # Convert string keys to symbols
178
- result.transform_keys(&:to_sym)
179
- else
180
- {
181
- error: "Failed to parse Python execution result",
182
- raw_output: stdout,
183
- success: false
184
- }
185
- end
186
- rescue JSON::ParserError => e
187
- {
188
- error: "Failed to parse Python result as JSON: #{e.message}",
189
- raw_output: stdout,
190
- success: false
191
- }
192
- end
193
- end
194
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../../shared_tools'
4
-
5
- module SharedTools
6
- verify_gem :ruby_llm
7
-
8
- class ReadFile < ::RubyLLM::Tool
9
- def self.name = 'read_file'
10
-
11
- 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."
12
- param :path, desc: "The relative path of a file in the working directory."
13
-
14
- def execute(path:)
15
- RubyLLM.logger.info("Reading file: #{path}")
16
-
17
- # Handle both relative and absolute paths consistently
18
- absolute_path = File.absolute_path(path)
19
-
20
- if File.directory?(absolute_path)
21
- error_msg = "Path is a directory, not a file: #{path}"
22
- RubyLLM.logger.error(error_msg)
23
- return { error: error_msg }
24
- end
25
-
26
- unless File.exist?(absolute_path)
27
- error_msg = "File does not exist: #{path}"
28
- RubyLLM.logger.error(error_msg)
29
- return { error: error_msg }
30
- end
31
-
32
- content = File.read(absolute_path)
33
- RubyLLM.logger.debug("Successfully read #{content.bytesize} bytes from #{path}")
34
- content
35
- rescue => e
36
- RubyLLM.logger.error("Failed to read file '#{path}': #{e.message}")
37
- { error: e.message }
38
- end
39
- end
40
- end