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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -0
- data/README.md +594 -42
- data/lib/shared_tools/{ruby_llm/mcp → mcp}/github_mcp_server.rb +31 -24
- data/lib/shared_tools/mcp/imcp.rb +28 -0
- data/lib/shared_tools/mcp/tavily_mcp_server.rb +44 -0
- data/lib/shared_tools/mcp.rb +24 -0
- data/lib/shared_tools/tools/browser/base_driver.rb +64 -0
- data/lib/shared_tools/tools/browser/base_tool.rb +50 -0
- data/lib/shared_tools/tools/browser/click_tool.rb +54 -0
- data/lib/shared_tools/tools/browser/elements/element_grouper.rb +73 -0
- data/lib/shared_tools/tools/browser/elements/nearby_element_detector.rb +109 -0
- data/lib/shared_tools/tools/browser/formatters/action_formatter.rb +37 -0
- data/lib/shared_tools/tools/browser/formatters/data_entry_formatter.rb +135 -0
- data/lib/shared_tools/tools/browser/formatters/element_formatter.rb +52 -0
- data/lib/shared_tools/tools/browser/formatters/input_formatter.rb +59 -0
- data/lib/shared_tools/tools/browser/inspect_tool.rb +87 -0
- data/lib/shared_tools/tools/browser/inspect_utils.rb +51 -0
- data/lib/shared_tools/tools/browser/page_inspect/button_summarizer.rb +140 -0
- data/lib/shared_tools/tools/browser/page_inspect/form_summarizer.rb +98 -0
- data/lib/shared_tools/tools/browser/page_inspect/html_summarizer.rb +37 -0
- data/lib/shared_tools/tools/browser/page_inspect/link_summarizer.rb +103 -0
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +55 -0
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +39 -0
- data/lib/shared_tools/tools/browser/selector_generator/base_selectors.rb +28 -0
- data/lib/shared_tools/tools/browser/selector_generator/contextual_selectors.rb +140 -0
- data/lib/shared_tools/tools/browser/selector_generator.rb +73 -0
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +67 -0
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +45 -0
- data/lib/shared_tools/tools/browser/visit_tool.rb +43 -0
- data/lib/shared_tools/tools/browser/watir_driver.rb +132 -0
- data/lib/shared_tools/tools/browser.rb +27 -0
- data/lib/shared_tools/tools/browser_tool.rb +255 -0
- data/lib/shared_tools/tools/calculator_tool.rb +169 -0
- data/lib/shared_tools/tools/composite_analysis_tool.rb +520 -0
- data/lib/shared_tools/tools/computer/base_driver.rb +177 -0
- data/lib/shared_tools/tools/computer/mac_driver.rb +103 -0
- data/lib/shared_tools/tools/computer.rb +21 -0
- data/lib/shared_tools/tools/computer_tool.rb +207 -0
- data/lib/shared_tools/tools/data_science_kit.rb +707 -0
- data/lib/shared_tools/tools/database/base_driver.rb +17 -0
- data/lib/shared_tools/tools/database/postgres_driver.rb +30 -0
- data/lib/shared_tools/tools/database/sqlite_driver.rb +29 -0
- data/lib/shared_tools/tools/database.rb +9 -0
- data/lib/shared_tools/tools/database_query_tool.rb +313 -0
- data/lib/shared_tools/tools/database_tool.rb +99 -0
- data/lib/shared_tools/tools/devops_toolkit.rb +420 -0
- data/lib/shared_tools/tools/disk/base_driver.rb +91 -0
- data/lib/shared_tools/tools/disk/base_tool.rb +20 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +39 -0
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +39 -0
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +37 -0
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +40 -0
- data/lib/shared_tools/tools/disk/file_create_tool.rb +38 -0
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +40 -0
- data/lib/shared_tools/tools/disk/file_move_tool.rb +43 -0
- data/lib/shared_tools/tools/disk/file_read_tool.rb +40 -0
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +44 -0
- data/lib/shared_tools/tools/disk/file_write_tool.rb +40 -0
- data/lib/shared_tools/tools/disk/local_driver.rb +91 -0
- data/lib/shared_tools/tools/disk.rb +17 -0
- data/lib/shared_tools/tools/disk_tool.rb +132 -0
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +79 -0
- data/lib/shared_tools/tools/doc.rb +8 -0
- data/lib/shared_tools/tools/doc_tool.rb +109 -0
- data/lib/shared_tools/tools/docker/base_tool.rb +56 -0
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +77 -0
- data/lib/shared_tools/tools/docker.rb +8 -0
- data/lib/shared_tools/tools/error_handling_tool.rb +403 -0
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +209 -0
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +93 -0
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +64 -0
- data/lib/shared_tools/tools/eval.rb +10 -0
- data/lib/shared_tools/tools/eval_tool.rb +139 -0
- data/lib/shared_tools/tools/secure_tool_template.rb +353 -0
- data/lib/shared_tools/tools/version.rb +7 -0
- data/lib/shared_tools/tools/weather_tool.rb +197 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +312 -0
- data/lib/shared_tools/tools.rb +16 -0
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools.rb +9 -33
- metadata +189 -68
- data/lib/shared_tools/llm_rb/run_shell_command.rb +0 -23
- data/lib/shared_tools/llm_rb.rb +0 -9
- data/lib/shared_tools/omniai.rb +0 -9
- data/lib/shared_tools/raix/what_is_the_weather.rb +0 -18
- data/lib/shared_tools/raix.rb +0 -9
- data/lib/shared_tools/ruby_llm/edit_file.rb +0 -71
- data/lib/shared_tools/ruby_llm/incomplete/calculator_tool.rb +0 -70
- data/lib/shared_tools/ruby_llm/incomplete/composite_analysis_tool.rb +0 -89
- data/lib/shared_tools/ruby_llm/incomplete/data_science_kit.rb +0 -128
- data/lib/shared_tools/ruby_llm/incomplete/database_query_tool.rb +0 -100
- data/lib/shared_tools/ruby_llm/incomplete/devops_toolkit.rb +0 -112
- data/lib/shared_tools/ruby_llm/incomplete/error_handling_tool.rb +0 -109
- data/lib/shared_tools/ruby_llm/incomplete/secure_tool_template.rb +0 -117
- data/lib/shared_tools/ruby_llm/incomplete/weather_tool.rb +0 -110
- data/lib/shared_tools/ruby_llm/incomplete/workflow_manager_tool.rb +0 -145
- data/lib/shared_tools/ruby_llm/list_files.rb +0 -49
- data/lib/shared_tools/ruby_llm/mcp/imcp.rb +0 -33
- data/lib/shared_tools/ruby_llm/mcp.rb +0 -10
- data/lib/shared_tools/ruby_llm/pdf_page_reader.rb +0 -59
- data/lib/shared_tools/ruby_llm/python_eval.rb +0 -194
- data/lib/shared_tools/ruby_llm/read_file.rb +0 -40
- data/lib/shared_tools/ruby_llm/ruby_eval.rb +0 -77
- data/lib/shared_tools/ruby_llm/run_shell_command.rb +0 -49
- data/lib/shared_tools/ruby_llm.rb +0 -12
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SharedTools
|
|
4
|
+
module Tools
|
|
5
|
+
module Database
|
|
6
|
+
# Base class for database drivers (e.g. sqlite, postgres, mysql, etc).
|
|
7
|
+
class BaseDriver
|
|
8
|
+
# @param statement [String] e.g. "SELECT * FROM people"
|
|
9
|
+
#
|
|
10
|
+
# @return [Hash] e.g. { status: :ok, result: [["id", "name"], [1, "John"], [2, "Paul"], ...] }
|
|
11
|
+
def perform(statement:)
|
|
12
|
+
raise NotImplementedError, "#{self.class}##{__method__} undefined"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SharedTools
|
|
4
|
+
module Tools
|
|
5
|
+
module Database
|
|
6
|
+
# @example
|
|
7
|
+
# connection = PG.connect(dbname: "testdb")
|
|
8
|
+
# driver = SharedTools::Tools::Database::PostgresDriver.new
|
|
9
|
+
# driver.perform(statement: "SELECT * FROM people")
|
|
10
|
+
class PostgresDriver < BaseDriver
|
|
11
|
+
# @param connection [Sqlite3::Database]
|
|
12
|
+
def initialize(connection:)
|
|
13
|
+
super()
|
|
14
|
+
@connection = connection
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param statement [String]
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash]
|
|
20
|
+
def perform(statement:)
|
|
21
|
+
@connection.exec(statement) do |result|
|
|
22
|
+
{ status: :ok, result: [result.fields] + result.values }
|
|
23
|
+
end
|
|
24
|
+
rescue ::PG::Error => e
|
|
25
|
+
{ status: :error, message: e.message }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SharedTools
|
|
4
|
+
module Tools
|
|
5
|
+
module Database
|
|
6
|
+
# @example
|
|
7
|
+
# driver = SharedTools::Tools::Database::SqliteDriver.new
|
|
8
|
+
# driver.perform(statement: "SELECT * FROM people")
|
|
9
|
+
class SqliteDriver < BaseDriver
|
|
10
|
+
# @param db [Sqlite3::Database]
|
|
11
|
+
def initialize(db:)
|
|
12
|
+
super()
|
|
13
|
+
@db = db
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param statement [String]
|
|
17
|
+
#
|
|
18
|
+
# @return [Hash]
|
|
19
|
+
def perform(statement:)
|
|
20
|
+
result = @db.execute2(statement)
|
|
21
|
+
|
|
22
|
+
{ status: :ok, result: }
|
|
23
|
+
rescue ::SQLite3::Exception => e
|
|
24
|
+
{ status: :error, message: e.message }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# database_query_tool.rb - Safe database query execution
|
|
2
|
+
require 'ruby_llm/tool'
|
|
3
|
+
require 'sequel'
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
class DatabaseQueryTool < RubyLLM::Tool
|
|
8
|
+
def self.name = 'database_query'
|
|
9
|
+
|
|
10
|
+
description <<~'DESCRIPTION'
|
|
11
|
+
Execute safe, read-only database queries with automatic connection management and security controls.
|
|
12
|
+
This tool is designed for secure data retrieval operations only, restricting access to SELECT statements
|
|
13
|
+
to prevent any data modification. It includes automatic connection pooling, query result limiting,
|
|
14
|
+
query timeout support, and comprehensive error handling. The tool supports multiple database configurations
|
|
15
|
+
through environment variables and ensures all connections are properly closed after use.
|
|
16
|
+
Perfect for AI-assisted data analysis and reporting workflows where read-only access is required.
|
|
17
|
+
|
|
18
|
+
Security features:
|
|
19
|
+
- SELECT-only queries (no INSERT, UPDATE, DELETE, DROP, etc.)
|
|
20
|
+
- Automatic LIMIT clause enforcement
|
|
21
|
+
- Query timeout protection
|
|
22
|
+
- Prepared statement support to prevent SQL injection
|
|
23
|
+
- Connection pooling with automatic cleanup
|
|
24
|
+
|
|
25
|
+
Supported databases:
|
|
26
|
+
- PostgreSQL, MySQL, SQLite, SQL Server, Oracle, and any database supported by Sequel
|
|
27
|
+
|
|
28
|
+
Example usage:
|
|
29
|
+
tool = SharedTools::Tools::DatabaseQueryTool.new
|
|
30
|
+
result = tool.execute(query: "SELECT * FROM users WHERE active = ?", params: [true])
|
|
31
|
+
puts "Found #{result[:row_count]} users"
|
|
32
|
+
DESCRIPTION
|
|
33
|
+
|
|
34
|
+
params do
|
|
35
|
+
string :query, description: <<~DESC.strip
|
|
36
|
+
SQL SELECT query to execute against the database. Only SELECT statements are permitted
|
|
37
|
+
for security reasons - INSERT, UPDATE, DELETE, and DDL statements will be rejected.
|
|
38
|
+
The query should be well-formed SQL appropriate for the target database system.
|
|
39
|
+
|
|
40
|
+
Use placeholders (?) for parameterized queries to prevent SQL injection:
|
|
41
|
+
- Good: "SELECT * FROM users WHERE id = ?"
|
|
42
|
+
- Bad: "SELECT * FROM users WHERE id = \#{user_id}"
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
- 'SELECT * FROM users WHERE active = true'
|
|
46
|
+
- 'SELECT COUNT(*) FROM orders'
|
|
47
|
+
- 'SELECT name, email FROM customers WHERE created_at > ?'
|
|
48
|
+
DESC
|
|
49
|
+
|
|
50
|
+
string :database, description: <<~DESC.strip, required: false
|
|
51
|
+
Database configuration name to use for the connection. This corresponds to environment
|
|
52
|
+
variables like DATABASE_URL, STAGING_DATABASE_URL, etc. The tool will look for
|
|
53
|
+
an environment variable named {DATABASE_NAME}_DATABASE_URL (uppercase).
|
|
54
|
+
Default is 'default' which looks for DEFAULT_DATABASE_URL or DATABASE_URL environment variable.
|
|
55
|
+
Common values: 'default', 'staging', 'analytics', 'reporting', 'production'.
|
|
56
|
+
DESC
|
|
57
|
+
|
|
58
|
+
integer :limit, description: <<~DESC.strip, required: false
|
|
59
|
+
Maximum number of rows to return from the query to prevent excessive memory usage
|
|
60
|
+
and long response times. The tool automatically adds a LIMIT clause if one is not
|
|
61
|
+
present in the original query. Set to a reasonable value based on expected data size.
|
|
62
|
+
Minimum: 1, Maximum: 10000, Default: 100. For large datasets, consider using
|
|
63
|
+
pagination or more specific WHERE clauses.
|
|
64
|
+
DESC
|
|
65
|
+
|
|
66
|
+
integer :timeout, description: <<~DESC.strip, required: false
|
|
67
|
+
Query timeout in seconds. If the query takes longer than this to execute, it will
|
|
68
|
+
be cancelled and an error will be returned. This prevents long-running queries from
|
|
69
|
+
consuming excessive resources. Minimum: 1, Maximum: 300, Default: 30 seconds.
|
|
70
|
+
DESC
|
|
71
|
+
|
|
72
|
+
array :params, of: :string, description: <<~DESC.strip, required: false
|
|
73
|
+
Parameters to bind to the query placeholders (?). Use parameterized queries to prevent
|
|
74
|
+
SQL injection vulnerabilities. The number of parameters must match the number of
|
|
75
|
+
placeholders in the query. Parameters are automatically escaped and quoted based on
|
|
76
|
+
their type. Example: params: [1, "john@example.com", true] for a query with 3 placeholders.
|
|
77
|
+
DESC
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param logger [Logger] optional logger
|
|
81
|
+
def initialize(logger: nil)
|
|
82
|
+
@logger = logger || RubyLLM.logger
|
|
83
|
+
@connection_cache = {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Execute read-only database query
|
|
87
|
+
#
|
|
88
|
+
# @param query [String] SQL SELECT query to execute
|
|
89
|
+
# @param database [String] Database configuration name
|
|
90
|
+
# @param limit [Integer] Maximum rows to return (1-10000), default 100
|
|
91
|
+
# @param timeout [Integer] Query timeout in seconds (1-300), default 30
|
|
92
|
+
# @param params [Array] Parameters for parameterized query
|
|
93
|
+
#
|
|
94
|
+
# @return [Hash] Query results with success status
|
|
95
|
+
def execute(query:, database: "default", limit: 100, timeout: 30, params: [])
|
|
96
|
+
@logger.info("DatabaseQueryTool#execute database=#{database} limit=#{limit} timeout=#{timeout}")
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
# Validate and sanitize inputs
|
|
100
|
+
validate_query(query)
|
|
101
|
+
limit = validate_limit(limit)
|
|
102
|
+
timeout = validate_timeout(timeout)
|
|
103
|
+
|
|
104
|
+
# Get or create database connection (cached for in-memory databases)
|
|
105
|
+
db = get_connection(database, timeout)
|
|
106
|
+
|
|
107
|
+
# Add LIMIT clause if not present
|
|
108
|
+
limited_query = add_limit_to_query(query, limit)
|
|
109
|
+
|
|
110
|
+
@logger.debug("Executing query: #{limited_query}")
|
|
111
|
+
@logger.debug("With parameters: #{params.inspect}") if params && !params.empty?
|
|
112
|
+
|
|
113
|
+
# Execute query with parameters
|
|
114
|
+
start_time = Time.now
|
|
115
|
+
results = if params && !params.empty?
|
|
116
|
+
db[limited_query, *params].all
|
|
117
|
+
else
|
|
118
|
+
db[limited_query].all
|
|
119
|
+
end
|
|
120
|
+
execution_time = Time.now - start_time
|
|
121
|
+
|
|
122
|
+
@logger.info("Query executed successfully: #{results.length} rows in #{execution_time.round(3)}s")
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
success: true,
|
|
126
|
+
query: limited_query,
|
|
127
|
+
row_count: results.length,
|
|
128
|
+
data: results,
|
|
129
|
+
database: database,
|
|
130
|
+
execution_time: execution_time.round(3),
|
|
131
|
+
executed_at: Time.now.iso8601
|
|
132
|
+
}
|
|
133
|
+
rescue Sequel::DatabaseError => e
|
|
134
|
+
@logger.error("Database error: #{e.message}")
|
|
135
|
+
{
|
|
136
|
+
success: false,
|
|
137
|
+
error: "Database error: #{e.message}",
|
|
138
|
+
error_type: "database_error",
|
|
139
|
+
query: query,
|
|
140
|
+
database: database
|
|
141
|
+
}
|
|
142
|
+
rescue => e
|
|
143
|
+
@logger.error("Query execution failed: #{e.message}")
|
|
144
|
+
{
|
|
145
|
+
success: false,
|
|
146
|
+
error: e.message,
|
|
147
|
+
error_type: e.class.name,
|
|
148
|
+
query: query,
|
|
149
|
+
database: database
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Validate that query is a SELECT statement
|
|
157
|
+
#
|
|
158
|
+
# @param query [String] SQL query to validate
|
|
159
|
+
# @raise [ArgumentError] if query is not a SELECT statement
|
|
160
|
+
def validate_query(query)
|
|
161
|
+
raise ArgumentError, "Query cannot be empty" if query.nil? || query.strip.empty?
|
|
162
|
+
|
|
163
|
+
# Remove comments and normalize whitespace
|
|
164
|
+
normalized_query = query.gsub(/--.*$/, '').gsub(/\/\*.*?\*\//m, '').strip.downcase
|
|
165
|
+
|
|
166
|
+
# Check for dangerous keywords FIRST (more specific error messages)
|
|
167
|
+
dangerous_keywords = %w[insert update delete drop alter truncate grant revoke]
|
|
168
|
+
dangerous_keywords.each do |keyword|
|
|
169
|
+
if normalized_query.match?(/\b#{keyword}\b/)
|
|
170
|
+
raise ArgumentError, "Query contains forbidden keyword: #{keyword.upcase}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Then check for SELECT at the start (allowing WITH clauses)
|
|
175
|
+
unless normalized_query.start_with?('select') || normalized_query.start_with?('with')
|
|
176
|
+
raise ArgumentError, "Only SELECT queries are allowed for security. Got: #{query[0..50]}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
@logger.debug("Query validation passed")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Validate and normalize limit parameter
|
|
183
|
+
#
|
|
184
|
+
# @param limit [Integer] Requested limit
|
|
185
|
+
# @return [Integer] Validated limit (1-10000)
|
|
186
|
+
def validate_limit(limit)
|
|
187
|
+
limit = limit.to_i
|
|
188
|
+
|
|
189
|
+
if limit < 1
|
|
190
|
+
@logger.warn("Limit #{limit} is too low, adjusting to 1")
|
|
191
|
+
return 1
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if limit > 10000
|
|
195
|
+
@logger.warn("Limit #{limit} exceeds maximum, adjusting to 10000")
|
|
196
|
+
return 10000
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
limit
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Validate and normalize timeout parameter
|
|
203
|
+
#
|
|
204
|
+
# @param timeout [Integer] Requested timeout in seconds
|
|
205
|
+
# @return [Integer] Validated timeout (1-300)
|
|
206
|
+
def validate_timeout(timeout)
|
|
207
|
+
timeout = timeout.to_i
|
|
208
|
+
|
|
209
|
+
if timeout < 1
|
|
210
|
+
@logger.warn("Timeout #{timeout} is too low, adjusting to 1")
|
|
211
|
+
return 1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
if timeout > 300
|
|
215
|
+
@logger.warn("Timeout #{timeout} exceeds maximum, adjusting to 300")
|
|
216
|
+
return 300
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
timeout
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Get or create cached database connection
|
|
223
|
+
#
|
|
224
|
+
# @param database_name [String] Database configuration name
|
|
225
|
+
# @param timeout [Integer] Query timeout in seconds
|
|
226
|
+
# @return [Sequel::Database] Database connection
|
|
227
|
+
def get_connection(database_name, timeout)
|
|
228
|
+
connection_string = find_connection_string(database_name)
|
|
229
|
+
|
|
230
|
+
unless connection_string
|
|
231
|
+
error_msg = "Database connection not configured for '#{database_name}'. " \
|
|
232
|
+
"Please set #{database_name.upcase}_DATABASE_URL environment variable."
|
|
233
|
+
@logger.error(error_msg)
|
|
234
|
+
raise ArgumentError, error_msg
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Cache connections for in-memory databases to prevent data loss
|
|
238
|
+
# This allows multiple execute calls to share the same in-memory database
|
|
239
|
+
is_memory_db = connection_string.match?(/sqlite:(:|\/\/file:.*mode=memory)/)
|
|
240
|
+
if is_memory_db
|
|
241
|
+
cache_key = "#{database_name}:#{connection_string}"
|
|
242
|
+
@connection_cache[cache_key] ||= connect_to_database(connection_string, timeout)
|
|
243
|
+
else
|
|
244
|
+
# For regular databases, create new connection each time (don't cache)
|
|
245
|
+
connect_to_database(connection_string, timeout)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Connect to database with proper error handling
|
|
250
|
+
#
|
|
251
|
+
# @param connection_string [String] Database connection string
|
|
252
|
+
# @param timeout [Integer] Query timeout in seconds
|
|
253
|
+
# @return [Sequel::Database] Database connection
|
|
254
|
+
def connect_to_database(connection_string, timeout)
|
|
255
|
+
@logger.debug("Connecting to database")
|
|
256
|
+
|
|
257
|
+
# Create connection with timeout
|
|
258
|
+
db = Sequel.connect(connection_string)
|
|
259
|
+
|
|
260
|
+
# Set query timeout if supported by the database
|
|
261
|
+
begin
|
|
262
|
+
case db.database_type
|
|
263
|
+
when :postgres
|
|
264
|
+
db.execute("SET statement_timeout = #{timeout * 1000}") # milliseconds
|
|
265
|
+
when :mysql
|
|
266
|
+
db.execute("SET SESSION max_execution_time = #{timeout * 1000}") # milliseconds
|
|
267
|
+
when :sqlite
|
|
268
|
+
# SQLite timeout is set at connection time via busy_timeout pragma
|
|
269
|
+
# The timeout parameter controls how long SQLite waits for locks
|
|
270
|
+
db.execute("PRAGMA busy_timeout = #{timeout * 1000}") # milliseconds
|
|
271
|
+
end
|
|
272
|
+
rescue => e
|
|
273
|
+
@logger.warn("Could not set query timeout: #{e.message}")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
db
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Find connection string from environment variables
|
|
280
|
+
#
|
|
281
|
+
# @param database_name [String] Database configuration name
|
|
282
|
+
# @return [String, nil] Connection string or nil if not found
|
|
283
|
+
def find_connection_string(database_name)
|
|
284
|
+
# Try with database name prefix
|
|
285
|
+
connection_string = ENV["#{database_name.upcase}_DATABASE_URL"]
|
|
286
|
+
return connection_string if connection_string
|
|
287
|
+
|
|
288
|
+
# For 'default', also try without prefix
|
|
289
|
+
if database_name.downcase == 'default'
|
|
290
|
+
connection_string = ENV['DATABASE_URL']
|
|
291
|
+
return connection_string if connection_string
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Add LIMIT clause to query if not present
|
|
298
|
+
#
|
|
299
|
+
# @param query [String] SQL query
|
|
300
|
+
# @param limit [Integer] Maximum rows to return
|
|
301
|
+
# @return [String] Query with LIMIT clause
|
|
302
|
+
def add_limit_to_query(query, limit)
|
|
303
|
+
# Use regex to detect existing LIMIT clause (case-insensitive, handles various formats)
|
|
304
|
+
return query if query.match?(/\bLIMIT\s+\d+/i)
|
|
305
|
+
|
|
306
|
+
# Add LIMIT at the end of the query
|
|
307
|
+
# Handle queries that might end with semicolon
|
|
308
|
+
query = query.sub(/;\s*\z/, '')
|
|
309
|
+
"#{query} LIMIT #{limit}"
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../shared_tools'
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
# @example
|
|
8
|
+
# db = Sqlite3::Database.new("./db.sqlite")
|
|
9
|
+
# driver = SharedTools::Tools::Database::SqliteDriver.new(db:)
|
|
10
|
+
# tool = SharedTools::Tools::DatabaseTool.new(driver:)
|
|
11
|
+
# tool.execute(statements: ["SELECT * FROM people"])
|
|
12
|
+
class DatabaseTool < ::RubyLLM::Tool
|
|
13
|
+
def self.name = 'database_tool'
|
|
14
|
+
description <<~TEXT
|
|
15
|
+
Executes SQL commands (INSERT / UPDATE / SELECT / etc) on a database.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
|
|
19
|
+
STATEMENTS:
|
|
20
|
+
|
|
21
|
+
[
|
|
22
|
+
'CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)',
|
|
23
|
+
'INSERT INTO people (name) VALUES ('John')',
|
|
24
|
+
'INSERT INTO people (name) VALUES ('Paul')',
|
|
25
|
+
'SELECT * FROM people',
|
|
26
|
+
'DROP TABLE people'
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
RESULT:
|
|
30
|
+
|
|
31
|
+
[
|
|
32
|
+
{
|
|
33
|
+
"status": "OK",
|
|
34
|
+
"statement": "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
|
|
35
|
+
"result": "..."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"status": "OK",
|
|
39
|
+
"statement": "INSERT INTO people (name) VALUES ('John')"
|
|
40
|
+
"result": "..."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"status": "OK",
|
|
44
|
+
"statement": "INSERT INTO people (name) VALUES ('Paul')",
|
|
45
|
+
"result": "..."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"status": "OK",
|
|
49
|
+
"statement": "SELECT * FROM people",
|
|
50
|
+
"result": "..."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"status": "OK",
|
|
54
|
+
"statement": "DROP TABLE people",
|
|
55
|
+
"result": "..."
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
TEXT
|
|
59
|
+
|
|
60
|
+
params do
|
|
61
|
+
array :statements, of: :string, description: "A list of SQL statements to run sequentially (e.g. ['SELECT * FROM users', 'INSERT INTO ...'])"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# @param driver [SharedTools::Tools::Database::BaseDriver] required database driver (SqliteDriver, PostgresDriver, etc.)
|
|
66
|
+
# @param logger [Logger] optional logger
|
|
67
|
+
def initialize(driver:, logger: nil)
|
|
68
|
+
raise ArgumentError, "driver is required for DatabaseTool" if driver.nil?
|
|
69
|
+
@driver = driver
|
|
70
|
+
@logger = logger || RubyLLM.logger
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @example
|
|
74
|
+
# tool = SharedTools::Tools::Database::BaseTool.new
|
|
75
|
+
# tool.execute(statements: ["SELECT * FROM people"])
|
|
76
|
+
#
|
|
77
|
+
# @param statements [Array<String>]
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<Hash>]
|
|
80
|
+
def execute(statements:)
|
|
81
|
+
[].tap do |executions|
|
|
82
|
+
statements.map do |statement|
|
|
83
|
+
execution = perform(statement:).merge(statement:)
|
|
84
|
+
executions << execution
|
|
85
|
+
break unless execution[:status].eql?(:ok)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def perform(statement:)
|
|
91
|
+
@logger&.info("#perform statement=#{statement.inspect}")
|
|
92
|
+
|
|
93
|
+
@driver.perform(statement:).tap do |result|
|
|
94
|
+
@logger&.info(JSON.generate(result))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|