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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/.rubocop_todo.yml +99 -0
  4. data/CHANGELOG.md +24 -0
  5. data/README.md +33 -1
  6. data/Rakefile +53 -53
  7. data/bin/rcrewai +3 -3
  8. data/docs/mcp.md +109 -0
  9. data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
  10. data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
  11. data/docs/upgrading-to-0.3.md +163 -0
  12. data/examples/async_execution_example.rb +82 -81
  13. data/examples/hierarchical_crew_example.rb +68 -72
  14. data/examples/human_in_the_loop_example.rb +73 -74
  15. data/examples/mcp_example.rb +48 -0
  16. data/examples/native_tools_example.rb +64 -0
  17. data/examples/streaming_example.rb +56 -0
  18. data/lib/rcrewai/agent.rb +148 -287
  19. data/lib/rcrewai/async_executor.rb +43 -43
  20. data/lib/rcrewai/cli.rb +11 -11
  21. data/lib/rcrewai/configuration.rb +14 -9
  22. data/lib/rcrewai/crew.rb +56 -39
  23. data/lib/rcrewai/events.rb +30 -0
  24. data/lib/rcrewai/human_input.rb +104 -114
  25. data/lib/rcrewai/legacy_react_runner.rb +172 -0
  26. data/lib/rcrewai/llm_client.rb +1 -1
  27. data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
  28. data/lib/rcrewai/llm_clients/azure.rb +23 -128
  29. data/lib/rcrewai/llm_clients/base.rb +11 -7
  30. data/lib/rcrewai/llm_clients/google.rb +159 -95
  31. data/lib/rcrewai/llm_clients/ollama.rb +150 -106
  32. data/lib/rcrewai/llm_clients/openai.rb +140 -63
  33. data/lib/rcrewai/mcp/client.rb +101 -0
  34. data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
  35. data/lib/rcrewai/mcp/transport/http.rb +53 -0
  36. data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
  37. data/lib/rcrewai/mcp.rb +8 -0
  38. data/lib/rcrewai/memory.rb +45 -37
  39. data/lib/rcrewai/pricing.rb +34 -0
  40. data/lib/rcrewai/process.rb +86 -95
  41. data/lib/rcrewai/provider_schema.rb +38 -0
  42. data/lib/rcrewai/sse_parser.rb +55 -0
  43. data/lib/rcrewai/task.rb +56 -64
  44. data/lib/rcrewai/tool_runner.rb +132 -0
  45. data/lib/rcrewai/tool_schema.rb +97 -0
  46. data/lib/rcrewai/tools/base.rb +98 -37
  47. data/lib/rcrewai/tools/code_executor.rb +71 -74
  48. data/lib/rcrewai/tools/email_sender.rb +70 -78
  49. data/lib/rcrewai/tools/file_reader.rb +38 -30
  50. data/lib/rcrewai/tools/file_writer.rb +40 -38
  51. data/lib/rcrewai/tools/pdf_processor.rb +115 -130
  52. data/lib/rcrewai/tools/sql_database.rb +58 -55
  53. data/lib/rcrewai/tools/web_search.rb +26 -25
  54. data/lib/rcrewai/version.rb +2 -2
  55. data/lib/rcrewai.rb +18 -10
  56. data/rcrewai.gemspec +55 -36
  57. metadata +86 -50
@@ -7,10 +7,20 @@ require 'mail'
7
7
  module RCrewAI
8
8
  module Tools
9
9
  class EmailSender < Base
10
+ tool_name 'email_sender'
11
+ description 'Send an email via configured SMTP'
12
+ param :to, type: :string, required: true,
13
+ description: 'Recipient address. Multiple addresses may be separated by commas.'
14
+ param :subject, type: :string, required: true, description: 'Email subject'
15
+ param :body, type: :string, required: true, description: 'Email body (plain text or HTML)'
16
+ param :cc, type: :string, required: false, description: 'CC recipients (comma-separated)'
17
+ param :bcc, type: :string, required: false, description: 'BCC recipients (comma-separated)'
18
+ param :reply_to, type: :string, required: false, description: 'Reply-to address'
19
+ param :attachments, type: :array, required: false, items: { type: :string },
20
+ description: 'File paths to attach. Allowed extensions: pdf, txt, doc(x), xls(x), jpg, png, gif, zip.'
21
+
10
22
  def initialize(**options)
11
23
  super()
12
- @name = 'emailsender'
13
- @description = 'Send emails via SMTP'
14
24
  @smtp_server = options[:smtp_server] || 'localhost'
15
25
  @smtp_port = options[:smtp_port] || 587
16
26
  @username = options[:username]
@@ -23,20 +33,20 @@ module RCrewAI
23
33
 
24
34
  def execute(**params)
25
35
  validate_params!(
26
- params,
27
- required: [:to, :subject, :body],
28
- optional: [:cc, :bcc, :reply_to, :attachments]
36
+ params,
37
+ required: %i[to subject body],
38
+ optional: %i[cc bcc reply_to attachments]
29
39
  )
30
-
40
+
31
41
  to_addresses = normalize_email_addresses(params[:to])
32
42
  cc_addresses = normalize_email_addresses(params[:cc]) if params[:cc]
33
43
  bcc_addresses = normalize_email_addresses(params[:bcc]) if params[:bcc]
34
-
44
+
35
45
  begin
36
46
  validate_email_params!(to_addresses, cc_addresses, bcc_addresses, params)
37
47
  result = send_email(to_addresses, cc_addresses, bcc_addresses, params)
38
48
  format_email_result(result, to_addresses)
39
- rescue => e
49
+ rescue StandardError => e
40
50
  "Email sending failed: #{e.message}"
41
51
  end
42
52
  end
@@ -70,33 +80,27 @@ module RCrewAI
70
80
  def validate_email_params!(to_addresses, cc_addresses, bcc_addresses, params)
71
81
  # Validate email addresses
72
82
  all_addresses = to_addresses + (cc_addresses || []) + (bcc_addresses || [])
73
-
83
+
74
84
  all_addresses.each do |address|
75
- unless valid_email?(address)
76
- raise ToolError, "Invalid email address: #{address}"
77
- end
85
+ raise ToolError, "Invalid email address: #{address}" unless valid_email?(address)
78
86
  end
79
-
87
+
80
88
  # Check recipient limits
81
89
  if all_addresses.length > @max_recipients
82
90
  raise ToolError, "Too many recipients: #{all_addresses.length} (max: #{@max_recipients})"
83
91
  end
84
-
92
+
85
93
  # Validate subject and body
86
- if params[:subject].to_s.strip.empty?
87
- raise ToolError, "Email subject cannot be empty"
88
- end
89
-
90
- if params[:body].to_s.strip.empty?
91
- raise ToolError, "Email body cannot be empty"
92
- end
93
-
94
+ raise ToolError, 'Email subject cannot be empty' if params[:subject].to_s.strip.empty?
95
+
96
+ raise ToolError, 'Email body cannot be empty' if params[:body].to_s.strip.empty?
97
+
94
98
  # Check for spam-like content
95
99
  validate_content_safety!(params[:subject], params[:body])
96
100
  end
97
101
 
98
102
  def valid_email?(address)
99
- address.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
103
+ address.match?(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i)
100
104
  end
101
105
 
102
106
  def validate_content_safety!(subject, body)
@@ -105,20 +109,18 @@ module RCrewAI
105
109
  'viagra', 'casino', 'lottery', 'winner', 'congratulations',
106
110
  'million dollars', 'click here', 'urgent', 'act now'
107
111
  ]
108
-
112
+
109
113
  content = "#{subject} #{body}".downcase
110
-
114
+
111
115
  spam_indicators.each do |indicator|
112
- if content.include?(indicator)
113
- raise ToolError, "Potentially spam content detected: #{indicator}"
114
- end
116
+ raise ToolError, "Potentially spam content detected: #{indicator}" if content.include?(indicator)
115
117
  end
116
-
118
+
117
119
  # Check for excessive caps
118
120
  caps_ratio = content.gsub(/[^A-Z]/, '').length.to_f / content.gsub(/[^A-Za-z]/, '').length
119
- if caps_ratio > 0.5 && content.length > 50
120
- raise ToolError, "Excessive capitalization detected (possible spam)"
121
- end
121
+ return unless caps_ratio > 0.5 && content.length > 50
122
+
123
+ raise ToolError, 'Excessive capitalization detected (possible spam)'
122
124
  end
123
125
 
124
126
  def send_email(to_addresses, cc_addresses, bcc_addresses, params)
@@ -129,20 +131,16 @@ module RCrewAI
129
131
  bcc bcc_addresses if bcc_addresses&.any?
130
132
  subject params[:subject]
131
133
  body params[:body]
132
-
133
- if params[:reply_to]
134
- reply_to params[:reply_to]
135
- end
134
+
135
+ reply_to params[:reply_to] if params[:reply_to]
136
136
  end
137
-
137
+
138
138
  # Add attachments if provided
139
- if params[:attachments]
140
- add_attachments(mail, params[:attachments])
141
- end
142
-
139
+ add_attachments(mail, params[:attachments]) if params[:attachments]
140
+
143
141
  # Send the email
144
142
  mail.deliver!
145
-
143
+
146
144
  {
147
145
  message_id: mail.message_id,
148
146
  to: to_addresses,
@@ -156,55 +154,49 @@ module RCrewAI
156
154
 
157
155
  def add_attachments(mail, attachments)
158
156
  attachment_list = case attachments
159
- when String
160
- [attachments]
161
- when Array
162
- attachments
163
- else
164
- [attachments.to_s]
165
- end
166
-
157
+ when String
158
+ [attachments]
159
+ when Array
160
+ attachments
161
+ else
162
+ [attachments.to_s]
163
+ end
164
+
167
165
  attachment_list.each do |attachment_path|
168
- if File.exist?(attachment_path)
169
- # Security check - only allow certain file types
170
- allowed_extensions = %w[.pdf .txt .doc .docx .xls .xlsx .jpg .png .gif .zip]
171
- extension = File.extname(attachment_path).downcase
172
-
173
- unless allowed_extensions.include?(extension)
174
- raise ToolError, "Attachment file type not allowed: #{extension}"
175
- end
176
-
177
- # Size check (max 10MB per attachment)
178
- if File.size(attachment_path) > 10_000_000
179
- raise ToolError, "Attachment too large: #{File.basename(attachment_path)} (max 10MB)"
180
- end
181
-
182
- mail.add_file(attachment_path)
183
- else
184
- raise ToolError, "Attachment file not found: #{attachment_path}"
166
+ raise ToolError, "Attachment file not found: #{attachment_path}" unless File.exist?(attachment_path)
167
+
168
+ # Security check - only allow certain file types
169
+ allowed_extensions = %w[.pdf .txt .doc .docx .xls .xlsx .jpg .png .gif .zip]
170
+ extension = File.extname(attachment_path).downcase
171
+
172
+ unless allowed_extensions.include?(extension)
173
+ raise ToolError, "Attachment file type not allowed: #{extension}"
185
174
  end
175
+
176
+ # Size check (max 10MB per attachment)
177
+ if File.size(attachment_path) > 10_000_000
178
+ raise ToolError, "Attachment too large: #{File.basename(attachment_path)} (max 10MB)"
179
+ end
180
+
181
+ mail.add_file(attachment_path)
186
182
  end
187
183
  end
188
184
 
189
185
  def format_email_result(result, to_addresses)
190
186
  output = []
191
- output << "Email sent successfully!"
187
+ output << 'Email sent successfully!'
192
188
  output << "Message ID: #{result[:message_id]}"
193
189
  output << "Recipients: #{to_addresses.join(', ')}"
194
190
  output << "Subject: #{result[:subject]}"
195
191
  output << "Sent at: #{result[:sent_at].strftime('%Y-%m-%d %H:%M:%S')}"
196
192
  output << "Message size: #{result[:size]} bytes"
197
-
198
- if result[:cc]&.any?
199
- output << "CC: #{result[:cc].join(', ')}"
200
- end
201
-
202
- if result[:bcc]&.any?
203
- output << "BCC: #{result[:bcc].length} recipients"
204
- end
205
-
193
+
194
+ output << "CC: #{result[:cc].join(', ')}" if result[:cc]&.any?
195
+
196
+ output << "BCC: #{result[:bcc].length} recipients" if result[:bcc]&.any?
197
+
206
198
  output.join("\n")
207
199
  end
208
200
  end
209
201
  end
210
- end
202
+ end
@@ -6,24 +6,31 @@ require 'pathname'
6
6
  module RCrewAI
7
7
  module Tools
8
8
  class FileReader < Base
9
+ tool_name 'file_reader'
10
+ description 'Read the contents of a text file from disk'
11
+ param :file_path, type: :string, required: true,
12
+ description: 'Absolute or relative path to the file'
13
+ param :encoding, type: :string, default: 'utf-8',
14
+ description: 'Text encoding (e.g. utf-8, iso-8859-1)'
15
+ param :lines, type: :integer, required: false,
16
+ description: 'If set, read only the first N lines'
17
+
9
18
  def initialize(**options)
10
19
  super()
11
- @name = 'filereader'
12
- @description = 'Read contents from files'
13
20
  @max_file_size = options.fetch(:max_file_size, 10_000_000) # 10MB
14
21
  @allowed_extensions = options.fetch(:allowed_extensions, %w[.txt .md .json .yaml .yml .csv .log])
15
22
  end
16
23
 
17
24
  def execute(**params)
18
- validate_params!(params, required: [:file_path], optional: [:encoding, :lines])
19
-
25
+ validate_params!(params, required: [:file_path], optional: %i[encoding lines])
26
+
20
27
  file_path = params[:file_path]
21
28
  encoding = params[:encoding] || 'utf-8'
22
29
  lines = params[:lines] # Optional: read only N lines
23
-
30
+
24
31
  begin
25
32
  read_file(file_path, encoding, lines)
26
- rescue => e
33
+ rescue StandardError => e
27
34
  "File read failed: #{e.message}"
28
35
  end
29
36
  end
@@ -32,17 +39,17 @@ module RCrewAI
32
39
 
33
40
  def read_file(file_path, encoding, lines = nil)
34
41
  path = Pathname.new(file_path)
35
-
42
+
36
43
  # Security checks
37
44
  validate_file_path!(path)
38
45
  validate_file_size!(path)
39
46
  validate_file_extension!(path)
40
-
47
+
41
48
  content = if lines
42
- read_lines(path, encoding, lines)
43
- else
44
- read_full_file(path, encoding)
45
- end
49
+ read_lines(path, encoding, lines)
50
+ else
51
+ read_full_file(path, encoding)
52
+ end
46
53
 
47
54
  format_file_content(path, content, lines)
48
55
  end
@@ -51,27 +58,27 @@ module RCrewAI
51
58
  raise ToolError, "File does not exist: #{path}" unless path.exist?
52
59
  raise ToolError, "Path is not a file: #{path}" unless path.file?
53
60
  raise ToolError, "File is not readable: #{path}" unless path.readable?
54
-
61
+
55
62
  # Prevent directory traversal
56
63
  resolved_path = path.realpath.to_s
57
64
  working_dir = Dir.pwd
58
- unless resolved_path.start_with?(working_dir)
59
- raise ToolError, "Access denied: file outside working directory"
60
- end
65
+ return if resolved_path.start_with?(working_dir)
66
+
67
+ raise ToolError, 'Access denied: file outside working directory'
61
68
  end
62
69
 
63
70
  def validate_file_size!(path)
64
71
  size = path.size
65
- if size > @max_file_size
66
- raise ToolError, "File too large: #{size} bytes (max: #{@max_file_size})"
67
- end
72
+ return unless size > @max_file_size
73
+
74
+ raise ToolError, "File too large: #{size} bytes (max: #{@max_file_size})"
68
75
  end
69
76
 
70
77
  def validate_file_extension!(path)
71
78
  extension = path.extname.downcase
72
- unless @allowed_extensions.include?(extension) || @allowed_extensions.include?('*')
73
- raise ToolError, "File type not allowed: #{extension}"
74
- end
79
+ return if @allowed_extensions.include?(extension) || @allowed_extensions.include?('*')
80
+
81
+ raise ToolError, "File type not allowed: #{extension}"
75
82
  end
76
83
 
77
84
  def read_lines(path, encoding, line_count)
@@ -80,6 +87,7 @@ module RCrewAI
80
87
  line_count.times do
81
88
  line = file.gets
82
89
  break unless line
90
+
83
91
  lines << line.chomp
84
92
  end
85
93
  end
@@ -95,17 +103,17 @@ module RCrewAI
95
103
  header += " (first #{lines} lines)" if lines
96
104
  header += "\nSize: #{path.size} bytes"
97
105
  header += "\nModified: #{path.mtime.strftime('%Y-%m-%d %H:%M:%S')}"
98
- header += "\n" + "="*50 + "\n"
99
-
106
+ header += "\n#{'=' * 50}\n"
107
+
100
108
  # Truncate very long content for display
101
109
  display_content = if content.length > 5000
102
- content[0..4997] + "..."
103
- else
104
- content
105
- end
106
-
110
+ "#{content[0..4997]}..."
111
+ else
112
+ content
113
+ end
114
+
107
115
  header + display_content
108
116
  end
109
117
  end
110
118
  end
111
- end
119
+ end
@@ -7,26 +7,33 @@ require 'fileutils'
7
7
  module RCrewAI
8
8
  module Tools
9
9
  class FileWriter < Base
10
+ tool_name 'file_writer'
11
+ description 'Write content to a text file on disk'
12
+ param :file_path, type: :string, required: true, description: 'Path to write to'
13
+ param :content, type: :string, required: true, description: 'Content to write'
14
+ param :mode, type: :enum, default: 'w', values: %w[w a],
15
+ description: "Write mode: 'w' to overwrite, 'a' to append"
16
+ param :encoding, type: :string, default: 'utf-8',
17
+ description: 'Text encoding'
18
+
10
19
  def initialize(**options)
11
20
  super()
12
- @name = 'filewriter'
13
- @description = 'Write content to files'
14
21
  @max_file_size = options.fetch(:max_file_size, 10_000_000) # 10MB
15
22
  @allowed_extensions = options.fetch(:allowed_extensions, %w[.txt .md .json .yaml .yml .csv .log])
16
23
  @create_directories = options.fetch(:create_directories, true)
17
24
  end
18
25
 
19
26
  def execute(**params)
20
- validate_params!(params, required: [:file_path, :content], optional: [:mode, :encoding])
21
-
27
+ validate_params!(params, required: %i[file_path content], optional: %i[mode encoding])
28
+
22
29
  file_path = params[:file_path]
23
30
  content = params[:content]
24
- mode = params[:mode] || 'w' # 'w' for write, 'a' for append
31
+ mode = params[:mode] || 'w' # 'w' for write, 'a' for append
25
32
  encoding = params[:encoding] || 'utf-8'
26
-
33
+
27
34
  begin
28
35
  write_file(file_path, content, mode, encoding)
29
- rescue => e
36
+ rescue StandardError => e
30
37
  "File write failed: #{e.message}"
31
38
  end
32
39
  end
@@ -35,20 +42,20 @@ module RCrewAI
35
42
 
36
43
  def write_file(file_path, content, mode, encoding)
37
44
  path = Pathname.new(file_path)
38
-
45
+
39
46
  # Security checks
40
47
  validate_file_path!(path)
41
48
  validate_content!(content)
42
49
  validate_mode!(mode)
43
-
50
+
44
51
  # Create directory if needed
45
52
  create_parent_directories!(path) if @create_directories
46
-
53
+
47
54
  # Write the file
48
55
  File.open(path, mode, encoding: encoding) do |file|
49
56
  file.write(content)
50
57
  end
51
-
58
+
52
59
  format_write_result(path, content, mode)
53
60
  end
54
61
 
@@ -56,60 +63,55 @@ module RCrewAI
56
63
  # Prevent directory traversal
57
64
  resolved_path = path.expand_path.to_s
58
65
  working_dir = Dir.pwd
59
- unless resolved_path.start_with?(working_dir)
60
- raise ToolError, "Access denied: file outside working directory"
61
- end
62
-
66
+ raise ToolError, 'Access denied: file outside working directory' unless resolved_path.start_with?(working_dir)
67
+
63
68
  # Check file extension
64
69
  extension = path.extname.downcase
65
70
  unless @allowed_extensions.include?(extension) || @allowed_extensions.include?('*')
66
71
  raise ToolError, "File type not allowed: #{extension}"
67
72
  end
68
-
73
+
69
74
  # If file exists, check if it's writable
70
- if path.exist?
71
- raise ToolError, "Path is not a file: #{path}" unless path.file?
72
- raise ToolError, "File is not writable: #{path}" unless path.writable?
73
- end
75
+ return unless path.exist?
76
+ raise ToolError, "Path is not a file: #{path}" unless path.file?
77
+ raise ToolError, "File is not writable: #{path}" unless path.writable?
74
78
  end
75
79
 
76
80
  def validate_content!(content)
77
- raise ToolError, "Content cannot be nil" if content.nil?
78
-
81
+ raise ToolError, 'Content cannot be nil' if content.nil?
82
+
79
83
  content_size = content.bytesize
80
- if content_size > @max_file_size
81
- raise ToolError, "Content too large: #{content_size} bytes (max: #{@max_file_size})"
82
- end
84
+ return unless content_size > @max_file_size
85
+
86
+ raise ToolError, "Content too large: #{content_size} bytes (max: #{@max_file_size})"
83
87
  end
84
88
 
85
89
  def validate_mode!(mode)
86
90
  valid_modes = %w[w a w+ a+ wb ab]
87
- unless valid_modes.include?(mode)
88
- raise ToolError, "Invalid file mode: #{mode}. Valid modes: #{valid_modes.join(', ')}"
89
- end
91
+ return if valid_modes.include?(mode)
92
+
93
+ raise ToolError, "Invalid file mode: #{mode}. Valid modes: #{valid_modes.join(', ')}"
90
94
  end
91
95
 
92
96
  def create_parent_directories!(path)
93
97
  parent_dir = path.parent
94
- unless parent_dir.exist?
95
- FileUtils.mkdir_p(parent_dir)
96
- end
98
+ return if parent_dir.exist?
99
+
100
+ FileUtils.mkdir_p(parent_dir)
97
101
  end
98
102
 
99
103
  def format_write_result(path, content, mode)
100
104
  action = mode.start_with?('a') ? 'appended to' : 'written to'
101
105
  size = content.bytesize
102
-
106
+
103
107
  result = "Content successfully #{action} #{path.basename}\n"
104
108
  result += "File size: #{size} bytes\n"
105
109
  result += "Full path: #{path.expand_path}\n"
106
-
107
- if path.exist?
108
- result += "File modified: #{path.mtime.strftime('%Y-%m-%d %H:%M:%S')}"
109
- end
110
-
110
+
111
+ result += "File modified: #{path.mtime.strftime('%Y-%m-%d %H:%M:%S')}" if path.exist?
112
+
111
113
  result
112
114
  end
113
115
  end
114
116
  end
115
- end
117
+ end