rcrewai 0.2.0 → 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/.rubocop.yml +1 -0
- data/.rubocop_todo.yml +99 -0
- data/CHANGELOG.md +24 -0
- data/README.md +33 -1
- data/Rakefile +53 -53
- data/bin/rcrewai +3 -3
- data/docs/mcp.md +109 -0
- data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
- data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
- data/docs/upgrading-to-0.3.md +163 -0
- data/examples/async_execution_example.rb +82 -81
- data/examples/hierarchical_crew_example.rb +68 -72
- data/examples/human_in_the_loop_example.rb +73 -74
- data/examples/mcp_example.rb +48 -0
- data/examples/native_tools_example.rb +64 -0
- data/examples/streaming_example.rb +56 -0
- data/lib/rcrewai/agent.rb +148 -287
- data/lib/rcrewai/async_executor.rb +43 -43
- data/lib/rcrewai/cli.rb +11 -11
- data/lib/rcrewai/configuration.rb +14 -9
- data/lib/rcrewai/crew.rb +56 -39
- data/lib/rcrewai/events.rb +30 -0
- data/lib/rcrewai/human_input.rb +104 -114
- data/lib/rcrewai/legacy_react_runner.rb +172 -0
- data/lib/rcrewai/llm_client.rb +1 -1
- data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
- data/lib/rcrewai/llm_clients/azure.rb +23 -128
- data/lib/rcrewai/llm_clients/base.rb +11 -7
- data/lib/rcrewai/llm_clients/google.rb +159 -95
- data/lib/rcrewai/llm_clients/ollama.rb +150 -106
- data/lib/rcrewai/llm_clients/openai.rb +140 -63
- data/lib/rcrewai/mcp/client.rb +101 -0
- data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
- data/lib/rcrewai/mcp/transport/http.rb +53 -0
- data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
- data/lib/rcrewai/mcp.rb +8 -0
- data/lib/rcrewai/memory.rb +45 -37
- data/lib/rcrewai/pricing.rb +34 -0
- data/lib/rcrewai/process.rb +86 -95
- data/lib/rcrewai/provider_schema.rb +38 -0
- data/lib/rcrewai/sse_parser.rb +55 -0
- data/lib/rcrewai/task.rb +56 -64
- data/lib/rcrewai/tool_runner.rb +132 -0
- data/lib/rcrewai/tool_schema.rb +97 -0
- data/lib/rcrewai/tools/base.rb +98 -37
- data/lib/rcrewai/tools/code_executor.rb +71 -74
- data/lib/rcrewai/tools/email_sender.rb +70 -78
- data/lib/rcrewai/tools/file_reader.rb +38 -30
- data/lib/rcrewai/tools/file_writer.rb +40 -38
- data/lib/rcrewai/tools/pdf_processor.rb +115 -130
- data/lib/rcrewai/tools/sql_database.rb +58 -55
- data/lib/rcrewai/tools/web_search.rb +26 -25
- data/lib/rcrewai/version.rb +2 -2
- data/lib/rcrewai.rb +18 -10
- data/rcrewai.gemspec +55 -36
- metadata +86 -50
|
@@ -7,11 +7,21 @@ require 'pathname'
|
|
|
7
7
|
module RCrewAI
|
|
8
8
|
module Tools
|
|
9
9
|
class PdfProcessor < Base
|
|
10
|
+
tool_name 'pdf_processor'
|
|
11
|
+
description 'Extract text from a PDF file'
|
|
12
|
+
param :file_path, type: :string, required: true, description: 'Path to the PDF'
|
|
13
|
+
param :pages, type: :string, required: false,
|
|
14
|
+
description: "Page selector: 'all', a range like '1-5', or comma-separated like '1,3,5'"
|
|
15
|
+
param :extract_text, type: :boolean, default: true,
|
|
16
|
+
description: 'Whether to extract page text'
|
|
17
|
+
param :extract_metadata, type: :boolean, default: true,
|
|
18
|
+
description: 'Whether to extract PDF metadata (title, author, etc.)'
|
|
19
|
+
param :output_format, type: :enum, default: 'text', values: %w[text json markdown],
|
|
20
|
+
description: "Output format: 'text', 'json', or 'markdown'"
|
|
21
|
+
|
|
10
22
|
def initialize(**options)
|
|
11
23
|
super()
|
|
12
|
-
@
|
|
13
|
-
@description = 'Read and extract text from PDF files'
|
|
14
|
-
@max_file_size = options.fetch(:max_file_size, 50_000_000) # 50MB
|
|
24
|
+
@max_file_size = options.fetch(:max_file_size, 50_000_000) # 50MB
|
|
15
25
|
@max_pages = options.fetch(:max_pages, 100)
|
|
16
26
|
@extract_metadata = options.fetch(:extract_metadata, true)
|
|
17
27
|
@working_directory = options[:working_directory] || Dir.pwd
|
|
@@ -19,22 +29,22 @@ module RCrewAI
|
|
|
19
29
|
|
|
20
30
|
def execute(**params)
|
|
21
31
|
validate_params!(
|
|
22
|
-
params,
|
|
23
|
-
required: [:file_path],
|
|
24
|
-
optional: [
|
|
32
|
+
params,
|
|
33
|
+
required: [:file_path],
|
|
34
|
+
optional: %i[pages extract_text extract_metadata output_format]
|
|
25
35
|
)
|
|
26
|
-
|
|
36
|
+
|
|
27
37
|
file_path = params[:file_path]
|
|
28
|
-
pages = params[:pages]
|
|
38
|
+
pages = params[:pages] # Can be array [1,2,3] or range "1-5" or "all"
|
|
29
39
|
extract_text = params.fetch(:extract_text, true)
|
|
30
40
|
extract_metadata = params.fetch(:extract_metadata, @extract_metadata)
|
|
31
|
-
output_format = params.fetch(:output_format, 'text')
|
|
32
|
-
|
|
41
|
+
output_format = params.fetch(:output_format, 'text') # 'text', 'json', 'markdown'
|
|
42
|
+
|
|
33
43
|
begin
|
|
34
44
|
validate_pdf_file!(file_path)
|
|
35
45
|
result = process_pdf(file_path, pages, extract_text, extract_metadata)
|
|
36
46
|
format_pdf_result(result, output_format)
|
|
37
|
-
rescue => e
|
|
47
|
+
rescue StandardError => e
|
|
38
48
|
"PDF processing failed: #{e.message}"
|
|
39
49
|
end
|
|
40
50
|
end
|
|
@@ -43,45 +53,33 @@ module RCrewAI
|
|
|
43
53
|
|
|
44
54
|
def validate_pdf_file!(file_path)
|
|
45
55
|
path = Pathname.new(file_path)
|
|
46
|
-
|
|
56
|
+
|
|
47
57
|
# Security and existence checks
|
|
48
|
-
unless path.exist?
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
unless path.
|
|
53
|
-
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
unless path.readable?
|
|
57
|
-
raise ToolError, "PDF file is not readable: #{file_path}"
|
|
58
|
-
end
|
|
59
|
-
|
|
58
|
+
raise ToolError, "PDF file does not exist: #{file_path}" unless path.exist?
|
|
59
|
+
|
|
60
|
+
raise ToolError, "Path is not a file: #{file_path}" unless path.file?
|
|
61
|
+
|
|
62
|
+
raise ToolError, "PDF file is not readable: #{file_path}" unless path.readable?
|
|
63
|
+
|
|
60
64
|
# Check file extension
|
|
61
|
-
unless path.extname.downcase == '.pdf'
|
|
62
|
-
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
+
raise ToolError, "File is not a PDF: #{file_path}" unless path.extname.downcase == '.pdf'
|
|
66
|
+
|
|
65
67
|
# Check file size
|
|
66
68
|
file_size = path.size
|
|
67
|
-
if file_size > @max_file_size
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if file_size == 0
|
|
72
|
-
raise ToolError, "PDF file is empty: #{file_path}"
|
|
73
|
-
end
|
|
74
|
-
|
|
69
|
+
raise ToolError, "PDF file too large: #{file_size} bytes (max: #{@max_file_size})" if file_size > @max_file_size
|
|
70
|
+
|
|
71
|
+
raise ToolError, "PDF file is empty: #{file_path}" if file_size.zero?
|
|
72
|
+
|
|
75
73
|
# Prevent directory traversal
|
|
76
74
|
resolved_path = path.realpath.to_s
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
return if resolved_path.start_with?(@working_directory)
|
|
76
|
+
|
|
77
|
+
raise ToolError, 'Access denied: file outside working directory'
|
|
80
78
|
end
|
|
81
79
|
|
|
82
80
|
def process_pdf(file_path, pages_param, extract_text, extract_metadata)
|
|
83
81
|
reader = PDF::Reader.new(file_path)
|
|
84
|
-
|
|
82
|
+
|
|
85
83
|
result = {
|
|
86
84
|
file_path: file_path,
|
|
87
85
|
total_pages: reader.page_count,
|
|
@@ -90,32 +88,28 @@ module RCrewAI
|
|
|
90
88
|
metadata: {},
|
|
91
89
|
processing_errors: []
|
|
92
90
|
}
|
|
93
|
-
|
|
91
|
+
|
|
94
92
|
# Check page count limit
|
|
95
93
|
if reader.page_count > @max_pages
|
|
96
94
|
raise ToolError, "PDF has too many pages: #{reader.page_count} (max: #{@max_pages})"
|
|
97
95
|
end
|
|
98
|
-
|
|
96
|
+
|
|
99
97
|
# Extract metadata if requested
|
|
100
|
-
if extract_metadata
|
|
101
|
-
|
|
102
|
-
end
|
|
103
|
-
|
|
98
|
+
result[:metadata] = extract_pdf_metadata(reader) if extract_metadata
|
|
99
|
+
|
|
104
100
|
# Determine which pages to process
|
|
105
101
|
pages_to_process = determine_pages_to_process(pages_param, reader.page_count)
|
|
106
|
-
|
|
102
|
+
|
|
107
103
|
# Extract text if requested
|
|
108
|
-
if extract_text
|
|
109
|
-
|
|
110
|
-
end
|
|
111
|
-
|
|
104
|
+
result[:text_content] = extract_text_from_pages(reader, pages_to_process, result) if extract_text
|
|
105
|
+
|
|
112
106
|
result[:processed_pages] = pages_to_process.length
|
|
113
107
|
result
|
|
114
108
|
end
|
|
115
109
|
|
|
116
110
|
def extract_pdf_metadata(reader)
|
|
117
111
|
metadata = {}
|
|
118
|
-
|
|
112
|
+
|
|
119
113
|
begin
|
|
120
114
|
info = reader.info
|
|
121
115
|
metadata[:title] = info[:Title] if info[:Title]
|
|
@@ -126,24 +120,24 @@ module RCrewAI
|
|
|
126
120
|
metadata[:producer] = info[:Producer] if info[:Producer]
|
|
127
121
|
metadata[:creation_date] = info[:CreationDate] if info[:CreationDate]
|
|
128
122
|
metadata[:modification_date] = info[:ModDate] if info[:ModDate]
|
|
129
|
-
rescue => e
|
|
123
|
+
rescue StandardError => e
|
|
130
124
|
metadata[:extraction_error] = "Failed to extract metadata: #{e.message}"
|
|
131
125
|
end
|
|
132
|
-
|
|
126
|
+
|
|
133
127
|
# Add PDF version and security info
|
|
134
128
|
begin
|
|
135
129
|
metadata[:pdf_version] = reader.pdf_version
|
|
136
130
|
metadata[:encrypted] = reader.encrypted?
|
|
137
|
-
rescue
|
|
131
|
+
rescue StandardError
|
|
138
132
|
# Ignore errors for version/security info
|
|
139
133
|
end
|
|
140
|
-
|
|
134
|
+
|
|
141
135
|
metadata
|
|
142
136
|
end
|
|
143
137
|
|
|
144
138
|
def determine_pages_to_process(pages_param, total_pages)
|
|
145
139
|
return (1..total_pages).to_a if pages_param.nil? || pages_param == 'all'
|
|
146
|
-
|
|
140
|
+
|
|
147
141
|
case pages_param
|
|
148
142
|
when Array
|
|
149
143
|
# Array of page numbers: [1, 3, 5]
|
|
@@ -164,14 +158,14 @@ module RCrewAI
|
|
|
164
158
|
|
|
165
159
|
def parse_page_range_string(range_string, total_pages)
|
|
166
160
|
pages = []
|
|
167
|
-
|
|
161
|
+
|
|
168
162
|
range_string.split(',').each do |part|
|
|
169
163
|
part = part.strip
|
|
170
|
-
|
|
164
|
+
|
|
171
165
|
if part.include?('-')
|
|
172
166
|
# Range like "1-5"
|
|
173
167
|
start_page, end_page = part.split('-').map(&:strip).map(&:to_i)
|
|
174
|
-
if start_page
|
|
168
|
+
if start_page.positive? && end_page.positive? && start_page <= end_page
|
|
175
169
|
(start_page..end_page).each do |p|
|
|
176
170
|
pages << p if p <= total_pages
|
|
177
171
|
end
|
|
@@ -179,42 +173,39 @@ module RCrewAI
|
|
|
179
173
|
else
|
|
180
174
|
# Single page number
|
|
181
175
|
page_num = part.to_i
|
|
182
|
-
pages << page_num if page_num
|
|
176
|
+
pages << page_num if page_num.positive? && page_num <= total_pages
|
|
183
177
|
end
|
|
184
178
|
end
|
|
185
|
-
|
|
179
|
+
|
|
186
180
|
pages.uniq.sort
|
|
187
181
|
end
|
|
188
182
|
|
|
189
183
|
def extract_text_from_pages(reader, pages_to_process, result)
|
|
190
184
|
text_content = []
|
|
191
|
-
|
|
185
|
+
|
|
192
186
|
pages_to_process.each do |page_num|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
error: error_msg
|
|
214
|
-
}
|
|
215
|
-
end
|
|
187
|
+
page = reader.page(page_num)
|
|
188
|
+
page_text = page.text.strip
|
|
189
|
+
|
|
190
|
+
text_content << {
|
|
191
|
+
page_number: page_num,
|
|
192
|
+
text: page_text,
|
|
193
|
+
character_count: page_text.length,
|
|
194
|
+
word_count: page_text.split(/\s+/).length
|
|
195
|
+
}
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
error_msg = "Failed to extract text from page #{page_num}: #{e.message}"
|
|
198
|
+
result[:processing_errors] << error_msg
|
|
199
|
+
|
|
200
|
+
text_content << {
|
|
201
|
+
page_number: page_num,
|
|
202
|
+
text: '',
|
|
203
|
+
character_count: 0,
|
|
204
|
+
word_count: 0,
|
|
205
|
+
error: error_msg
|
|
206
|
+
}
|
|
216
207
|
end
|
|
217
|
-
|
|
208
|
+
|
|
218
209
|
text_content
|
|
219
210
|
end
|
|
220
211
|
|
|
@@ -231,105 +222,99 @@ module RCrewAI
|
|
|
231
222
|
|
|
232
223
|
def format_as_text(result)
|
|
233
224
|
output = []
|
|
234
|
-
output <<
|
|
235
|
-
output <<
|
|
225
|
+
output << 'PDF Processing Results'
|
|
226
|
+
output << '=' * 30
|
|
236
227
|
output << "File: #{File.basename(result[:file_path])}"
|
|
237
228
|
output << "Total Pages: #{result[:total_pages]}"
|
|
238
229
|
output << "Processed Pages: #{result[:processed_pages]}"
|
|
239
|
-
|
|
230
|
+
|
|
240
231
|
if result[:metadata].any?
|
|
241
|
-
output <<
|
|
242
|
-
output <<
|
|
232
|
+
output << ''
|
|
233
|
+
output << 'Metadata:'
|
|
243
234
|
result[:metadata].each do |key, value|
|
|
244
235
|
output << " #{key.to_s.capitalize}: #{value}"
|
|
245
236
|
end
|
|
246
237
|
end
|
|
247
|
-
|
|
238
|
+
|
|
248
239
|
if result[:processing_errors].any?
|
|
249
|
-
output <<
|
|
250
|
-
output <<
|
|
240
|
+
output << ''
|
|
241
|
+
output << 'Processing Errors:'
|
|
251
242
|
result[:processing_errors].each do |error|
|
|
252
243
|
output << " - #{error}"
|
|
253
244
|
end
|
|
254
245
|
end
|
|
255
|
-
|
|
246
|
+
|
|
256
247
|
if result[:text_content].any?
|
|
257
|
-
output <<
|
|
258
|
-
output <<
|
|
259
|
-
output <<
|
|
260
|
-
|
|
248
|
+
output << ''
|
|
249
|
+
output << 'Extracted Text:'
|
|
250
|
+
output << '-' * 20
|
|
251
|
+
|
|
261
252
|
result[:text_content].each do |page_content|
|
|
253
|
+
output << ''
|
|
262
254
|
if page_content[:error]
|
|
263
|
-
output << ""
|
|
264
255
|
output << "Page #{page_content[:page_number]} (ERROR): #{page_content[:error]}"
|
|
265
256
|
elsif page_content[:text].empty?
|
|
266
|
-
output << ""
|
|
267
257
|
output << "Page #{page_content[:page_number]}: [No text content]"
|
|
268
258
|
else
|
|
269
|
-
output << ""
|
|
270
259
|
output << "Page #{page_content[:page_number]} (#{page_content[:word_count]} words):"
|
|
271
|
-
|
|
260
|
+
|
|
272
261
|
# Truncate very long pages for readability
|
|
273
262
|
text = page_content[:text]
|
|
274
|
-
if text.length > 2000
|
|
275
|
-
|
|
276
|
-
end
|
|
277
|
-
|
|
263
|
+
text = "#{text[0..1997]}..." if text.length > 2000
|
|
264
|
+
|
|
278
265
|
output << text
|
|
279
266
|
end
|
|
280
267
|
end
|
|
281
268
|
end
|
|
282
|
-
|
|
269
|
+
|
|
283
270
|
output.join("\n")
|
|
284
271
|
end
|
|
285
272
|
|
|
286
273
|
def format_as_markdown(result)
|
|
287
274
|
output = []
|
|
288
|
-
output <<
|
|
289
|
-
output <<
|
|
275
|
+
output << '# PDF Processing Results'
|
|
276
|
+
output << ''
|
|
290
277
|
output << "**File:** `#{File.basename(result[:file_path])}`"
|
|
291
278
|
output << "**Total Pages:** #{result[:total_pages]}"
|
|
292
279
|
output << "**Processed Pages:** #{result[:processed_pages]}"
|
|
293
|
-
|
|
280
|
+
|
|
294
281
|
if result[:metadata].any?
|
|
295
|
-
output <<
|
|
296
|
-
output <<
|
|
282
|
+
output << ''
|
|
283
|
+
output << '## Metadata'
|
|
297
284
|
result[:metadata].each do |key, value|
|
|
298
285
|
output << "- **#{key.to_s.capitalize}:** #{value}"
|
|
299
286
|
end
|
|
300
287
|
end
|
|
301
|
-
|
|
288
|
+
|
|
302
289
|
if result[:text_content].any?
|
|
303
|
-
output <<
|
|
304
|
-
output <<
|
|
305
|
-
|
|
290
|
+
output << ''
|
|
291
|
+
output << '## Extracted Text'
|
|
292
|
+
|
|
306
293
|
result[:text_content].each do |page_content|
|
|
307
|
-
output <<
|
|
294
|
+
output << ''
|
|
308
295
|
output << "### Page #{page_content[:page_number]}"
|
|
309
|
-
|
|
296
|
+
|
|
297
|
+
output << ''
|
|
310
298
|
if page_content[:error]
|
|
311
|
-
output << ""
|
|
312
299
|
output << "> ⚠️ **Error:** #{page_content[:error]}"
|
|
313
300
|
elsif page_content[:text].empty?
|
|
314
|
-
output <<
|
|
315
|
-
output << "> ℹ️ No text content found on this page"
|
|
301
|
+
output << '> ℹ️ No text content found on this page'
|
|
316
302
|
else
|
|
317
|
-
output << ""
|
|
318
303
|
output << "**Word Count:** #{page_content[:word_count]}"
|
|
319
|
-
output <<
|
|
304
|
+
output << ''
|
|
320
305
|
output << page_content[:text]
|
|
321
306
|
end
|
|
322
307
|
end
|
|
323
308
|
end
|
|
324
|
-
|
|
309
|
+
|
|
325
310
|
if result[:processing_errors].any?
|
|
326
|
-
output <<
|
|
327
|
-
output <<
|
|
311
|
+
output << ''
|
|
312
|
+
output << '## Processing Errors'
|
|
328
313
|
result[:processing_errors].each do |error|
|
|
329
314
|
output << "- #{error}"
|
|
330
315
|
end
|
|
331
316
|
end
|
|
332
|
-
|
|
317
|
+
|
|
333
318
|
output.join("\n")
|
|
334
319
|
end
|
|
335
320
|
|
|
@@ -339,4 +324,4 @@ module RCrewAI
|
|
|
339
324
|
end
|
|
340
325
|
end
|
|
341
326
|
end
|
|
342
|
-
end
|
|
327
|
+
end
|
|
@@ -6,10 +6,15 @@ require 'uri'
|
|
|
6
6
|
module RCrewAI
|
|
7
7
|
module Tools
|
|
8
8
|
class SqlDatabase < Base
|
|
9
|
+
tool_name 'sql_database'
|
|
10
|
+
description 'Execute a read-only SQL query and return rows as JSON'
|
|
11
|
+
param :query, type: :string, required: true,
|
|
12
|
+
description: 'SQL query (SELECT only; write keywords are rejected)'
|
|
13
|
+
param :limit, type: :integer, required: false,
|
|
14
|
+
description: 'Optional maximum number of rows to return'
|
|
15
|
+
|
|
9
16
|
def initialize(**options)
|
|
10
17
|
super()
|
|
11
|
-
@name = 'sqldatabase'
|
|
12
|
-
@description = 'Execute SQL queries against databases (PostgreSQL, MySQL, SQLite)'
|
|
13
18
|
@connection_string = options[:connection_string]
|
|
14
19
|
@database_type = detect_database_type
|
|
15
20
|
@max_results = options.fetch(:max_results, 100)
|
|
@@ -19,15 +24,15 @@ module RCrewAI
|
|
|
19
24
|
|
|
20
25
|
def execute(**params)
|
|
21
26
|
validate_params!(params, required: [:query], optional: [:limit])
|
|
22
|
-
|
|
27
|
+
|
|
23
28
|
query = params[:query].strip
|
|
24
29
|
limit = params[:limit] || @max_results
|
|
25
|
-
|
|
30
|
+
|
|
26
31
|
begin
|
|
27
32
|
validate_query_safety!(query)
|
|
28
33
|
result = execute_query(query, limit)
|
|
29
34
|
format_query_result(query, result)
|
|
30
|
-
rescue => e
|
|
35
|
+
rescue StandardError => e
|
|
31
36
|
"SQL execution failed: #{e.message}"
|
|
32
37
|
end
|
|
33
38
|
end
|
|
@@ -36,7 +41,7 @@ module RCrewAI
|
|
|
36
41
|
|
|
37
42
|
def detect_database_type
|
|
38
43
|
return :sqlite unless @connection_string
|
|
39
|
-
|
|
44
|
+
|
|
40
45
|
uri = URI.parse(@connection_string)
|
|
41
46
|
case uri.scheme
|
|
42
47
|
when 'postgres', 'postgresql'
|
|
@@ -67,28 +72,26 @@ module RCrewAI
|
|
|
67
72
|
|
|
68
73
|
def validate_query_safety!(query)
|
|
69
74
|
query_lower = query.downcase.strip
|
|
70
|
-
|
|
75
|
+
|
|
71
76
|
# Block dangerous operations
|
|
72
77
|
dangerous_keywords = %w[
|
|
73
78
|
drop truncate delete update insert create alter
|
|
74
79
|
grant revoke exec execute call load_file
|
|
75
80
|
]
|
|
76
|
-
|
|
81
|
+
|
|
77
82
|
dangerous_keywords.each do |keyword|
|
|
78
|
-
if query_lower.include?(keyword)
|
|
79
|
-
raise ToolError, "Unsafe SQL operation detected: #{keyword.upcase}"
|
|
80
|
-
end
|
|
83
|
+
raise ToolError, "Unsafe SQL operation detected: #{keyword.upcase}" if query_lower.include?(keyword)
|
|
81
84
|
end
|
|
82
|
-
|
|
85
|
+
|
|
83
86
|
# Only allow SELECT statements and safe functions
|
|
84
87
|
unless query_lower.start_with?('select', 'show', 'describe', 'explain')
|
|
85
|
-
raise ToolError,
|
|
88
|
+
raise ToolError, 'Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are allowed'
|
|
86
89
|
end
|
|
87
|
-
|
|
90
|
+
|
|
88
91
|
# Check for suspicious patterns
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
return unless query_lower.include?('--') || query_lower.include?('/*')
|
|
93
|
+
|
|
94
|
+
raise ToolError, 'SQL comments not allowed for security'
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def execute_query(query, limit)
|
|
@@ -106,14 +109,14 @@ module RCrewAI
|
|
|
106
109
|
|
|
107
110
|
def execute_postgresql_query(query, limit)
|
|
108
111
|
connection = PG.connect(@connection_string)
|
|
109
|
-
|
|
112
|
+
|
|
110
113
|
# Add LIMIT if not present
|
|
111
114
|
limited_query = add_limit_to_query(query, limit)
|
|
112
|
-
|
|
115
|
+
|
|
113
116
|
result = connection.exec(limited_query)
|
|
114
117
|
rows = result.to_a
|
|
115
118
|
columns = result.fields
|
|
116
|
-
|
|
119
|
+
|
|
117
120
|
{ rows: rows, columns: columns, row_count: rows.length }
|
|
118
121
|
ensure
|
|
119
122
|
connection&.close
|
|
@@ -121,14 +124,14 @@ module RCrewAI
|
|
|
121
124
|
|
|
122
125
|
def execute_mysql_query(query, limit)
|
|
123
126
|
connection = Mysql2::Client.new(@connection_string)
|
|
124
|
-
|
|
127
|
+
|
|
125
128
|
# Add LIMIT if not present
|
|
126
129
|
limited_query = add_limit_to_query(query, limit)
|
|
127
|
-
|
|
130
|
+
|
|
128
131
|
result = connection.query(limited_query)
|
|
129
132
|
rows = result.to_a
|
|
130
133
|
columns = result.fields
|
|
131
|
-
|
|
134
|
+
|
|
132
135
|
{ rows: rows, columns: columns, row_count: rows.length }
|
|
133
136
|
ensure
|
|
134
137
|
connection&.close
|
|
@@ -138,13 +141,13 @@ module RCrewAI
|
|
|
138
141
|
db_path = @connection_string || ':memory:'
|
|
139
142
|
db = SQLite3::Database.new(db_path)
|
|
140
143
|
db.results_as_hash = true
|
|
141
|
-
|
|
144
|
+
|
|
142
145
|
# Add LIMIT if not present
|
|
143
146
|
limited_query = add_limit_to_query(query, limit)
|
|
144
|
-
|
|
147
|
+
|
|
145
148
|
rows = db.execute(limited_query)
|
|
146
149
|
columns = rows.first&.keys || []
|
|
147
|
-
|
|
150
|
+
|
|
148
151
|
{ rows: rows, columns: columns, row_count: rows.length }
|
|
149
152
|
ensure
|
|
150
153
|
db&.close
|
|
@@ -152,10 +155,10 @@ module RCrewAI
|
|
|
152
155
|
|
|
153
156
|
def add_limit_to_query(query, limit)
|
|
154
157
|
query_lower = query.downcase
|
|
155
|
-
|
|
158
|
+
|
|
156
159
|
# If query already has LIMIT, don't modify
|
|
157
160
|
return query if query_lower.include?('limit')
|
|
158
|
-
|
|
161
|
+
|
|
159
162
|
# Add LIMIT clause
|
|
160
163
|
"#{query.chomp(';')} LIMIT #{limit}"
|
|
161
164
|
end
|
|
@@ -164,63 +167,63 @@ module RCrewAI
|
|
|
164
167
|
output = []
|
|
165
168
|
output << "SQL Query: #{query}"
|
|
166
169
|
output << "Rows returned: #{result[:row_count]}"
|
|
167
|
-
output <<
|
|
168
|
-
|
|
169
|
-
if result[:rows].empty?
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
output << ''
|
|
171
|
+
|
|
172
|
+
output << if result[:rows].empty?
|
|
173
|
+
'No results found.'
|
|
174
|
+
else
|
|
175
|
+
# Create simple table format
|
|
176
|
+
format_table(result[:columns], result[:rows])
|
|
177
|
+
end
|
|
178
|
+
|
|
176
179
|
output.join("\n")
|
|
177
180
|
end
|
|
178
181
|
|
|
179
182
|
def format_table(columns, rows)
|
|
180
|
-
return
|
|
181
|
-
|
|
183
|
+
return 'No data' if rows.empty?
|
|
184
|
+
|
|
182
185
|
# Calculate column widths
|
|
183
186
|
col_widths = {}
|
|
184
187
|
columns.each do |col|
|
|
185
|
-
col_widths[col] = [col.length, 20].max
|
|
188
|
+
col_widths[col] = [col.length, 20].max # Minimum width of 20
|
|
186
189
|
end
|
|
187
|
-
|
|
188
|
-
rows.first(10).each do |row|
|
|
190
|
+
|
|
191
|
+
rows.first(10).each do |row| # Only check first 10 rows for width
|
|
189
192
|
row.each do |key, value|
|
|
190
193
|
value_str = value.to_s
|
|
191
|
-
col_widths[key] = [col_widths[key], value_str.length].min(50)
|
|
194
|
+
col_widths[key] = [col_widths[key], value_str.length].min(50) # Max width of 50
|
|
192
195
|
end
|
|
193
196
|
end
|
|
194
|
-
|
|
197
|
+
|
|
195
198
|
# Build table
|
|
196
199
|
table = []
|
|
197
|
-
|
|
200
|
+
|
|
198
201
|
# Header
|
|
199
|
-
header = columns.map { |col| col.ljust(col_widths[col]) }.join(
|
|
202
|
+
header = columns.map { |col| col.ljust(col_widths[col]) }.join(' | ')
|
|
200
203
|
table << header
|
|
201
|
-
table <<
|
|
202
|
-
|
|
204
|
+
table << '-' * header.length
|
|
205
|
+
|
|
203
206
|
# Rows (limit display to first 20 rows)
|
|
204
207
|
display_rows = rows.first(20)
|
|
205
208
|
display_rows.each do |row|
|
|
206
209
|
row_str = columns.map do |col|
|
|
207
|
-
value = row[col] || row[col.to_s] ||
|
|
210
|
+
value = row[col] || row[col.to_s] || ''
|
|
208
211
|
value_str = value.to_s
|
|
209
212
|
# Truncate long values
|
|
210
|
-
value_str = value_str[0..47]
|
|
213
|
+
value_str = "#{value_str[0..47]}..." if value_str.length > 50
|
|
211
214
|
value_str.ljust(col_widths[col])
|
|
212
|
-
end.join(
|
|
215
|
+
end.join(' | ')
|
|
213
216
|
table << row_str
|
|
214
217
|
end
|
|
215
|
-
|
|
218
|
+
|
|
216
219
|
# Add truncation notice if needed
|
|
217
220
|
if rows.length > 20
|
|
218
|
-
table <<
|
|
221
|
+
table << ''
|
|
219
222
|
table << "(Showing first 20 rows of #{rows.length} total rows)"
|
|
220
223
|
end
|
|
221
|
-
|
|
224
|
+
|
|
222
225
|
table.join("\n")
|
|
223
226
|
end
|
|
224
227
|
end
|
|
225
228
|
end
|
|
226
|
-
end
|
|
229
|
+
end
|