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
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'faraday'
4
+ require 'json'
3
5
  require_relative 'base'
6
+ require_relative '../events'
7
+ require_relative '../sse_parser'
8
+ require_relative '../provider_schema'
9
+ require_relative '../pricing'
4
10
 
5
11
  module RCrewAI
6
12
  module LLMClients
@@ -8,101 +14,215 @@ module RCrewAI
8
14
  BASE_URL = 'https://api.anthropic.com/v1'
9
15
  API_VERSION = '2023-06-01'
10
16
 
17
+ STOP_REASON_MAP = {
18
+ 'tool_use' => :tool_calls,
19
+ 'end_turn' => :stop,
20
+ 'stop_sequence' => :stop,
21
+ 'max_tokens' => :length
22
+ }.freeze
23
+
11
24
  def initialize(config = RCrewAI.configuration)
12
25
  super
13
26
  @base_url = BASE_URL
14
27
  end
15
28
 
16
- def chat(messages:, **options)
17
- # Convert messages to Anthropic format
29
+ def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
18
30
  system_message = extract_system_message(messages)
19
- formatted_messages = format_messages(messages.reject { |m| m.is_a?(Hash) && m[:role] == 'system' })
31
+ non_system = messages.reject { |m| m.is_a?(Hash) && m[:role] == 'system' }
20
32
 
21
33
  payload = {
22
34
  model: config.model,
23
- messages: formatted_messages,
35
+ messages: format_messages(non_system),
24
36
  max_tokens: options[:max_tokens] || config.max_tokens || 1000,
25
37
  temperature: options[:temperature] || config.temperature
26
- }
38
+ }.compact
39
+
40
+ if system_message
41
+ payload[:system] = if options[:cache_system]
42
+ [{ type: 'text', text: system_message,
43
+ cache_control: { type: 'ephemeral' } }]
44
+ else
45
+ system_message
46
+ end
47
+ end
27
48
 
28
- payload[:system] = system_message if system_message
49
+ if tools && !tools.empty?
50
+ payload[:tools] = ProviderSchema.for_many(:anthropic, tools)
51
+ payload[:tool_choice] = { type: tool_choice.to_s } if tool_choice != :auto && tool_choice.is_a?(Symbol)
52
+ end
29
53
 
30
- # Add Anthropic-specific options
31
54
  payload[:top_p] = options[:top_p] if options[:top_p]
32
55
  payload[:top_k] = options[:top_k] if options[:top_k]
33
56
  payload[:stop_sequences] = options[:stop_sequences] if options[:stop_sequences]
34
57
 
35
- url = "#{@base_url}/messages"
36
- log_request(:post, url, payload)
37
-
38
- response = http_client.post(url, payload, build_headers.merge(authorization_header))
39
- log_response(response)
58
+ if stream
59
+ payload[:stream] = true
60
+ stream_chat(payload, stream)
61
+ else
62
+ plain_chat(payload)
63
+ end
64
+ end
40
65
 
41
- result = handle_response(response)
42
- format_response(result)
66
+ def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
67
+ true
43
68
  end
44
69
 
45
70
  def models
46
- # Anthropic doesn't have a models endpoint, return known models
47
- [
48
- 'claude-3-opus-20240229',
49
- 'claude-3-sonnet-20240229',
50
- 'claude-3-haiku-20240307',
51
- 'claude-2.1',
52
- 'claude-2.0',
53
- 'claude-instant-1.2'
71
+ %w[
72
+ claude-opus-4-7 claude-sonnet-4-6 claude-haiku-4-5
73
+ claude-3-5-sonnet-20241022 claude-3-haiku-20240307
54
74
  ]
55
75
  end
56
76
 
57
77
  private
58
78
 
59
- def authorization_header
79
+ def plain_chat(payload)
80
+ url = "#{@base_url}/messages"
81
+ log_request(:post, url, payload)
82
+ response = http_client.post(url, payload, build_headers.merge(auth_header))
83
+ log_response(response)
84
+ body = handle_response(response)
85
+ normalize_non_streaming(body)
86
+ end
87
+
88
+ def stream_chat(payload, sink) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
89
+ url = "#{@base_url}/messages"
90
+ log_request(:post, url, payload)
91
+
92
+ assembled_text = +''
93
+ # tool_use blocks keyed by content-block index
94
+ blocks = {}
95
+ finish_reason = nil
96
+ prompt_tokens = nil
97
+ completion_tokens = nil
98
+
99
+ parser = SSEParser.new do |sse|
100
+ data = JSON.parse(sse[:data])
101
+ case data['type']
102
+ when 'message_start'
103
+ prompt_tokens = data.dig('message', 'usage', 'input_tokens')
104
+ when 'content_block_start'
105
+ cb = data['content_block'] || {}
106
+ blocks[data['index']] = { id: cb['id'], name: cb['name'], arguments: +'' } if cb['type'] == 'tool_use'
107
+ when 'content_block_delta'
108
+ delta = data['delta'] || {}
109
+ case delta['type']
110
+ when 'text_delta'
111
+ text = delta['text'].to_s
112
+ assembled_text << text
113
+ sink.call(Events::TextDelta.new(type: :text_delta, timestamp: Time.now,
114
+ agent: nil, iteration: nil, text: text))
115
+ when 'input_json_delta'
116
+ block = blocks[data['index']]
117
+ block[:arguments] << delta['partial_json'].to_s if block
118
+ end
119
+ when 'message_delta'
120
+ finish_reason ||= STOP_REASON_MAP[data.dig('delta', 'stop_reason')] ||
121
+ data.dig('delta', 'stop_reason')&.to_sym
122
+ completion_tokens = data.dig('usage', 'output_tokens') || completion_tokens
123
+ end
124
+ end
125
+
126
+ streaming_post(url, payload) { |chunk| parser.feed(chunk) }
127
+
128
+ tool_calls = blocks.values.map do |b|
129
+ {
130
+ id: b[:id],
131
+ name: b[:name],
132
+ arguments: b[:arguments].empty? ? {} : JSON.parse(b[:arguments])
133
+ }
134
+ end
135
+
136
+ usage = {
137
+ prompt_tokens: prompt_tokens,
138
+ completion_tokens: completion_tokens,
139
+ total_tokens: (prompt_tokens || 0) + (completion_tokens || 0)
140
+ }
141
+
142
+ if prompt_tokens || completion_tokens
143
+ sink.call(Events::Usage.new(
144
+ type: :usage, timestamp: Time.now, agent: nil, iteration: nil,
145
+ prompt_tokens: prompt_tokens, completion_tokens: completion_tokens,
146
+ total_tokens: usage[:total_tokens],
147
+ cost_usd: Pricing.cost_for(config.model,
148
+ prompt_tokens: prompt_tokens || 0,
149
+ completion_tokens: completion_tokens || 0)
150
+ ))
151
+ end
152
+
60
153
  {
61
- 'x-api-key' => config.api_key,
154
+ content: assembled_text.empty? ? nil : assembled_text,
155
+ tool_calls: tool_calls,
156
+ usage: usage,
157
+ finish_reason: finish_reason || :stop,
158
+ model: config.model,
159
+ provider: :anthropic
160
+ }
161
+ end
162
+
163
+ def streaming_post(url, payload, &on_chunk)
164
+ conn = Faraday.new do |f|
165
+ f.request :json
166
+ f.options.timeout = config.timeout
167
+ f.adapter Faraday.default_adapter
168
+ end
169
+ conn.post(url) do |req|
170
+ req.headers = build_headers.merge(auth_header)
171
+ req.body = payload.to_json
172
+ req.options.on_data = proc { |chunk, _| on_chunk.call(chunk) }
173
+ end
174
+ end
175
+
176
+ def normalize_non_streaming(body)
177
+ content_blocks = Array(body['content'])
178
+ text = content_blocks.select { |b| b['type'] == 'text' }.map { |b| b['text'] }.join
179
+ tool_calls = content_blocks.select { |b| b['type'] == 'tool_use' }.map do |b|
180
+ { id: b['id'], name: b['name'], arguments: b['input'] || {} }
181
+ end
182
+ prompt_tokens = body.dig('usage', 'input_tokens')
183
+ completion_tokens = body.dig('usage', 'output_tokens')
184
+
185
+ {
186
+ content: text.empty? ? nil : text,
187
+ tool_calls: tool_calls,
188
+ usage: {
189
+ prompt_tokens: prompt_tokens,
190
+ completion_tokens: completion_tokens,
191
+ total_tokens: (prompt_tokens || 0) + (completion_tokens || 0)
192
+ },
193
+ finish_reason: STOP_REASON_MAP[body['stop_reason']] || body['stop_reason']&.to_sym || :stop,
194
+ model: body['model'] || config.model,
195
+ provider: :anthropic
196
+ }
197
+ end
198
+
199
+ def auth_header
200
+ {
201
+ 'x-api-key' => config.anthropic_api_key || config.api_key,
62
202
  'anthropic-version' => API_VERSION
63
203
  }
64
204
  end
65
205
 
66
206
  def extract_system_message(messages)
67
207
  return nil unless messages.is_a?(Array)
68
- system_msg = messages.find { |m| m.is_a?(Hash) && m[:role] == 'system' }
69
- system_msg&.dig(:content)
208
+
209
+ msg = messages.find { |m| m.is_a?(Hash) && m[:role] == 'system' }
210
+ msg&.dig(:content)
70
211
  end
71
212
 
72
213
  def format_messages(messages)
73
214
  messages.map do |msg|
74
215
  if msg.is_a?(Hash)
75
- {
76
- role: msg[:role] == 'assistant' ? 'assistant' : 'user',
77
- content: msg[:content]
78
- }
216
+ { role: msg[:role] == 'assistant' ? 'assistant' : 'user', content: msg[:content] }
79
217
  else
80
218
  { role: 'user', content: msg.to_s }
81
219
  end
82
220
  end
83
221
  end
84
222
 
85
- def format_response(response)
86
- content = response.dig('content', 0, 'text') if response['content']&.any?
87
-
88
- {
89
- content: content,
90
- role: 'assistant',
91
- finish_reason: response['stop_reason'],
92
- usage: {
93
- 'prompt_tokens' => response.dig('usage', 'input_tokens'),
94
- 'completion_tokens' => response.dig('usage', 'output_tokens'),
95
- 'total_tokens' => (response.dig('usage', 'input_tokens') || 0) +
96
- (response.dig('usage', 'output_tokens') || 0)
97
- },
98
- model: response['model'],
99
- provider: :anthropic
100
- }
101
- end
102
-
103
223
  def validate_config!
104
- raise ConfigurationError, "Anthropic API key is required" unless config.anthropic_api_key || config.api_key
105
- raise ConfigurationError, "Model is required" unless config.model
224
+ raise ConfigurationError, 'Anthropic API key is required' unless config.anthropic_api_key || config.api_key
225
+ raise ConfigurationError, 'Model is required' unless config.model
106
226
  end
107
227
 
108
228
  def handle_response(response)
@@ -110,12 +230,12 @@ module RCrewAI
110
230
  when 200..299
111
231
  response.body
112
232
  when 400
113
- error_details = response.body.dig('error', 'message') || response.body
233
+ error_details = response.body.is_a?(Hash) ? response.body.dig('error', 'message') : response.body
114
234
  raise APIError, "Bad request: #{error_details}"
115
235
  when 401
116
- raise AuthenticationError, "Invalid API key"
236
+ raise AuthenticationError, 'Invalid API key'
117
237
  when 429
118
- raise RateLimitError, "Rate limit exceeded"
238
+ raise RateLimitError, 'Rate limit exceeded'
119
239
  when 500..599
120
240
  raise APIError, "Server error: #{response.status}"
121
241
  else
@@ -124,4 +244,4 @@ module RCrewAI
124
244
  end
125
245
  end
126
246
  end
127
- end
247
+ end
@@ -1,158 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'base'
3
+ require_relative 'openai'
4
4
 
5
5
  module RCrewAI
6
6
  module LLMClients
7
- class Azure < Base
7
+ # Azure OpenAI: same wire format as OpenAI but routes through a deployment
8
+ # path with an api-version query param, and authenticates with an api-key
9
+ # header instead of Authorization: Bearer.
10
+ class Azure < OpenAI
8
11
  def initialize(config = RCrewAI.configuration)
9
12
  super
10
- @base_url = config.base_url || build_azure_url
11
13
  @api_version = config.api_version || '2024-02-01'
12
14
  @deployment_name = config.deployment_name || config.model
13
- end
14
-
15
- def chat(messages:, **options)
16
- payload = {
17
- messages: format_messages(messages),
18
- temperature: options[:temperature] || config.temperature,
19
- max_tokens: options[:max_tokens] || config.max_tokens
20
- }
21
-
22
- # Add additional OpenAI-compatible options
23
- payload[:top_p] = options[:top_p] if options[:top_p]
24
- payload[:frequency_penalty] = options[:frequency_penalty] if options[:frequency_penalty]
25
- payload[:presence_penalty] = options[:presence_penalty] if options[:presence_penalty]
26
- payload[:stop] = options[:stop] if options[:stop]
27
-
28
- url = "#{@base_url}/openai/deployments/#{@deployment_name}/chat/completions?api-version=#{@api_version}"
29
- log_request(:post, url, payload)
30
-
31
- response = http_client.post(url, payload, build_headers.merge(authorization_header))
32
- log_response(response)
33
-
34
- result = handle_response(response)
35
- format_response(result)
36
- end
37
-
38
- def complete(prompt:, **options)
39
- # For older models that use completions endpoint
40
- payload = {
41
- prompt: prompt,
42
- temperature: options[:temperature] || config.temperature,
43
- max_tokens: options[:max_tokens] || config.max_tokens
44
- }
45
-
46
- url = "#{@base_url}/openai/deployments/#{@deployment_name}/completions?api-version=#{@api_version}"
47
- log_request(:post, url, payload)
48
-
49
- response = http_client.post(url, payload, build_headers.merge(authorization_header))
50
- log_response(response)
51
-
52
- result = handle_response(response)
53
- format_completion_response(result)
15
+ @base_url = build_endpoint_url
54
16
  end
55
17
 
56
18
  def models
57
- # Azure OpenAI uses deployments instead of models
58
19
  url = "#{@base_url}/openai/deployments?api-version=#{@api_version}"
59
- response = http_client.get(url, {}, build_headers.merge(authorization_header))
20
+ response = http_client.get(url, {}, build_headers.merge(auth_header))
60
21
  result = handle_response(response)
61
-
62
- if result['data']
63
- result['data'].map { |deployment| deployment['id'] }
64
- else
65
- [@deployment_name].compact
66
- end
22
+ Array(result['data']).map { |d| d['id'] }
23
+ rescue StandardError
24
+ [@deployment_name].compact
67
25
  end
68
26
 
69
27
  private
70
28
 
71
- def authorization_header
72
- { 'api-key' => config.api_key }
29
+ def chat_url
30
+ "#{@base_url}/openai/deployments/#{@deployment_name}/chat/completions?api-version=#{@api_version}"
73
31
  end
74
32
 
75
- def format_messages(messages)
76
- messages.map do |msg|
77
- if msg.is_a?(Hash)
78
- msg
79
- else
80
- { role: 'user', content: msg.to_s }
81
- end
82
- end
33
+ def build_endpoint_url
34
+ endpoint = config.base_url || ENV['AZURE_OPENAI_ENDPOINT'] || ENV['AZURE_ENDPOINT']
35
+ endpoint&.chomp('/')
83
36
  end
84
37
 
85
- def format_response(response)
86
- choice = response.dig('choices', 0)
87
- return nil unless choice
88
-
89
- {
90
- content: choice.dig('message', 'content'),
91
- role: choice.dig('message', 'role'),
92
- finish_reason: choice['finish_reason'],
93
- usage: response['usage'],
94
- model: @deployment_name,
95
- provider: :azure
96
- }
38
+ def provider_name
39
+ :azure
97
40
  end
98
41
 
99
- def format_completion_response(response)
100
- choice = response.dig('choices', 0)
101
- return nil unless choice
102
-
103
- {
104
- content: choice['text'],
105
- finish_reason: choice['finish_reason'],
106
- usage: response['usage'],
107
- model: @deployment_name,
108
- provider: :azure
109
- }
42
+ def auth_header
43
+ { 'api-key' => config.azure_api_key || config.api_key }
110
44
  end
111
45
 
112
46
  def validate_config!
113
- super
114
- raise ConfigurationError, "Azure API key is required" unless config.azure_api_key || config.api_key
115
- raise ConfigurationError, "Azure base URL or endpoint is required" unless config.base_url || azure_endpoint
116
- raise ConfigurationError, "Azure deployment name is required" unless config.deployment_name || config.model
117
- end
118
-
119
- def build_azure_url
120
- endpoint = azure_endpoint
121
- return nil unless endpoint
122
-
123
- # Remove trailing slash and add proper path
124
- endpoint = endpoint.chomp('/')
125
- "#{endpoint}"
126
- end
127
-
128
- def azure_endpoint
129
- # Try multiple environment variable names
130
- ENV['AZURE_OPENAI_ENDPOINT'] ||
131
- ENV['AZURE_ENDPOINT'] ||
132
- config.instance_variable_get(:@azure_endpoint)
133
- end
134
-
135
- def handle_response(response)
136
- case response.status
137
- when 200..299
138
- response.body
139
- when 400
140
- error_details = response.body.dig('error', 'message') || response.body
141
- raise APIError, "Bad request: #{error_details}"
142
- when 401
143
- raise AuthenticationError, "Invalid API key or authentication failed"
144
- when 403
145
- raise AuthenticationError, "Access denied - check your API key and permissions"
146
- when 404
147
- raise ModelNotFoundError, "Deployment '#{@deployment_name}' not found"
148
- when 429
149
- raise RateLimitError, "Rate limit exceeded or quota exhausted"
150
- when 500..599
151
- raise APIError, "Azure OpenAI service error: #{response.status}"
152
- else
153
- raise APIError, "Unexpected response: #{response.status}"
154
- end
47
+ raise ConfigurationError, 'Azure API key is required' unless config.azure_api_key || config.api_key
48
+ raise ConfigurationError, 'Azure endpoint is required' unless config.base_url || ENV['AZURE_OPENAI_ENDPOINT'] || ENV['AZURE_ENDPOINT']
49
+ raise ConfigurationError, 'Azure deployment name is required' unless config.deployment_name || config.model
155
50
  end
156
51
  end
157
52
  end
158
- end
53
+ end
@@ -16,8 +16,12 @@ module RCrewAI
16
16
  validate_config!
17
17
  end
18
18
 
19
- def chat(messages:, **options)
20
- raise NotImplementedError, "Subclasses must implement #chat method"
19
+ def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
20
+ raise NotImplementedError, 'Subclasses must implement #chat method'
21
+ end
22
+
23
+ def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
24
+ true
21
25
  end
22
26
 
23
27
  def complete(prompt:, **options)
@@ -27,8 +31,8 @@ module RCrewAI
27
31
  protected
28
32
 
29
33
  def validate_config!
30
- raise ConfigurationError, "API key is required" unless config.api_key
31
- raise ConfigurationError, "Model is required" unless config.model
34
+ raise ConfigurationError, 'API key is required' unless config.api_key
35
+ raise ConfigurationError, 'Model is required' unless config.model
32
36
  end
33
37
 
34
38
  def build_headers
@@ -54,9 +58,9 @@ module RCrewAI
54
58
  when 400
55
59
  raise APIError, "Bad request: #{response.body}"
56
60
  when 401
57
- raise AuthenticationError, "Invalid API key"
61
+ raise AuthenticationError, 'Invalid API key'
58
62
  when 429
59
- raise RateLimitError, "Rate limit exceeded"
63
+ raise RateLimitError, 'Rate limit exceeded'
60
64
  when 500..599
61
65
  raise APIError, "Server error: #{response.status}"
62
66
  else
@@ -79,4 +83,4 @@ module RCrewAI
79
83
  class RateLimitError < APIError; end
80
84
  class ModelNotFoundError < APIError; end
81
85
  end
82
- end
86
+ end