rcrewai 0.7.1 → 0.8.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.
@@ -21,8 +21,8 @@ module RCrewAI
21
21
  firefunction-v2
22
22
  ].freeze
23
23
 
24
- def initialize(config = RCrewAI.configuration)
25
- super
24
+ def initialize(config = RCrewAI.configuration, **hooks)
25
+ super(config, **hooks)
26
26
  @base_url = config.base_url || ollama_url || DEFAULT_URL
27
27
  end
28
28
 
@@ -78,17 +78,25 @@ module RCrewAI
78
78
  handle_response(response)
79
79
  end
80
80
 
81
+ def provider_name
82
+ :ollama
83
+ end
84
+
81
85
  private
82
86
 
83
87
  def plain_chat(url, payload)
88
+ payload = apply_before_request(payload)
89
+ started_at = Time.now
84
90
  log_request(:post, url, payload)
85
91
  response = http_client.post(url, payload, build_headers)
86
92
  log_response(response)
87
93
  body = handle_response(response)
88
- normalize_non_streaming(body)
94
+ apply_after_response(normalize_non_streaming(body), started_at)
89
95
  end
90
96
 
91
97
  def stream_chat(url, payload, sink)
98
+ payload = apply_before_request(payload)
99
+ started_at = Time.now
92
100
  log_request(:post, url, payload)
93
101
 
94
102
  assembled_text = +''
@@ -145,8 +153,14 @@ module RCrewAI
145
153
  ))
146
154
  end
147
155
 
156
+ result = stream_result(assembled_text, tool_calls, finish_reason,
157
+ prompt_tokens, completion_tokens)
158
+ apply_after_response(result, started_at)
159
+ end
160
+
161
+ def stream_result(text, tool_calls, finish_reason, prompt_tokens, completion_tokens)
148
162
  {
149
- content: assembled_text.empty? ? nil : assembled_text,
163
+ content: text.empty? ? nil : text,
150
164
  tool_calls: tool_calls,
151
165
  usage: {
152
166
  prompt_tokens: prompt_tokens,
@@ -13,8 +13,8 @@ module RCrewAI
13
13
  class OpenAI < Base
14
14
  BASE_URL = 'https://api.openai.com/v1'
15
15
 
16
- def initialize(config = RCrewAI.configuration)
17
- super
16
+ def initialize(config = RCrewAI.configuration, **hooks)
17
+ super(config, **hooks)
18
18
  @base_url = BASE_URL
19
19
  end
20
20
 
@@ -49,6 +49,10 @@ module RCrewAI
49
49
  true
50
50
  end
51
51
 
52
+ def provider_name
53
+ :openai
54
+ end
55
+
52
56
  def models
53
57
  url = "#{@base_url}/models"
54
58
  response = http_client.get(url, {}, build_headers.merge(auth_header))
@@ -64,15 +68,19 @@ module RCrewAI
64
68
 
65
69
  def plain_chat(payload)
66
70
  url = chat_url
71
+ payload = apply_before_request(payload)
72
+ started_at = Time.now
67
73
  log_request(:post, url, payload)
68
74
  response = http_client.post(url, payload, build_headers.merge(auth_header))
69
75
  log_response(response)
70
76
  body = handle_response(response)
71
- normalize_non_streaming(body)
77
+ apply_after_response(normalize_non_streaming(body), started_at)
72
78
  end
73
79
 
74
80
  def stream_chat(payload, sink) # rubocop:disable Metrics/AbcSize
75
81
  url = chat_url
82
+ payload = apply_before_request(payload)
83
+ started_at = Time.now
76
84
  log_request(:post, url, payload)
77
85
 
78
86
  assembled_text = +''
@@ -137,18 +145,14 @@ module RCrewAI
137
145
  ))
138
146
  end
139
147
 
140
- {
141
- content: assembled_text.empty? ? nil : assembled_text,
142
- tool_calls: tool_calls,
143
- usage: final_usage || {},
144
- finish_reason: finish_reason || :stop,
145
- model: config.model,
146
- provider: provider_name
147
- }
148
- end
149
-
150
- def provider_name
151
- :openai
148
+ apply_after_response({
149
+ content: assembled_text.empty? ? nil : assembled_text,
150
+ tool_calls: tool_calls,
151
+ usage: final_usage || {},
152
+ finish_reason: finish_reason || :stop,
153
+ model: config.model,
154
+ provider: provider_name
155
+ }, started_at)
152
156
  end
153
157
 
154
158
  def streaming_post(url, payload, &on_chunk)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'openai'
4
+
5
+ module RCrewAI
6
+ module LLMClients
7
+ # Any endpoint speaking the OpenAI Chat Completions wire format: Together,
8
+ # Groq, Fireworks, vLLM, LiteLLM, OpenRouter, a self-hosted gateway. Only
9
+ # the base URL differs, so this is OpenAI with the endpoint made explicit
10
+ # and required.
11
+ class OpenAICompatible < OpenAI
12
+ def initialize(config = RCrewAI.configuration, **hooks)
13
+ super
14
+ @base_url = config.base_url.chomp('/')
15
+ end
16
+
17
+ def provider_name
18
+ :openai_compatible
19
+ end
20
+
21
+ private
22
+
23
+ def validate_config!
24
+ raise ConfigurationError, 'API key is required' unless config.api_key
25
+ raise ConfigurationError, 'A base url is required for an OpenAI-compatible provider' unless config.base_url
26
+ raise ConfigurationError, 'Model is required' unless config.model
27
+ end
28
+
29
+ def auth_header
30
+ { 'Authorization' => "Bearer #{config.api_key}" }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'openai'
4
+
5
+ module RCrewAI
6
+ module LLMClients
7
+ # OpenAI's Responses API. Same host and auth as Chat Completions, but a
8
+ # different request and response shape:
9
+ #
10
+ # - messages go under :input, and the system prompt under :instructions
11
+ # - :max_tokens becomes :max_output_tokens
12
+ # - the reply is an :output array of typed items (message, function_call,
13
+ # reasoning, ...) rather than a single choice
14
+ # - usage is input_tokens / output_tokens
15
+ #
16
+ # Only the non-streaming path is implemented; Responses streams a distinct
17
+ # set of semantic events that the SSE assembly here does not model.
18
+ class OpenAIResponses < OpenAI
19
+ def provider_name
20
+ :openai_responses
21
+ end
22
+
23
+ def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options) # rubocop:disable Lint/UnusedMethodArgument
24
+ payload = {
25
+ model: config.model,
26
+ input: format_input(messages),
27
+ temperature: options[:temperature] || config.temperature,
28
+ max_output_tokens: options[:max_tokens] || config.max_tokens
29
+ }.compact
30
+
31
+ instructions = extract_instructions(messages)
32
+ payload[:instructions] = instructions if instructions
33
+
34
+ if tools && !tools.empty?
35
+ payload[:tools] = format_tools(tools)
36
+ payload[:tool_choice] = tool_choice if tool_choice != :auto
37
+ end
38
+
39
+ plain_chat(payload)
40
+ end
41
+
42
+ private
43
+
44
+ def chat_url
45
+ "#{@base_url}/responses"
46
+ end
47
+
48
+ # Responses carries the system prompt out-of-band as :instructions.
49
+ def extract_instructions(messages)
50
+ systems = messages.select { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }
51
+ return nil if systems.empty?
52
+
53
+ systems.map { |m| m[:content] }.join("\n\n")
54
+ end
55
+
56
+ def format_input(messages)
57
+ messages.reject { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }
58
+ .map { |m| { role: m[:role].to_s, content: m[:content] } }
59
+ end
60
+
61
+ # Responses flattens the function definition instead of nesting it under
62
+ # a :function key the way Chat Completions does.
63
+ def format_tools(tools)
64
+ tools.map do |t|
65
+ { type: 'function', name: t[:name], description: t[:description],
66
+ parameters: t[:parameters] }
67
+ end
68
+ end
69
+
70
+ def normalize_non_streaming(body)
71
+ output = Array(body['output'])
72
+ text = extract_text(output)
73
+ tool_calls = extract_tool_calls(output)
74
+
75
+ {
76
+ content: text.empty? ? nil : text,
77
+ tool_calls: tool_calls,
78
+ usage: {
79
+ prompt_tokens: body.dig('usage', 'input_tokens'),
80
+ completion_tokens: body.dig('usage', 'output_tokens'),
81
+ total_tokens: body.dig('usage', 'total_tokens')
82
+ },
83
+ finish_reason: finish_reason_for(body, tool_calls),
84
+ model: body['model'] || config.model,
85
+ provider: provider_name
86
+ }
87
+ end
88
+
89
+ def extract_text(output)
90
+ output.select { |item| item['type'] == 'message' }
91
+ .flat_map { |item| Array(item['content']) }
92
+ .select { |part| part['type'] == 'output_text' }
93
+ .map { |part| part['text'] }
94
+ .join
95
+ end
96
+
97
+ def extract_tool_calls(output)
98
+ output.select { |item| item['type'] == 'function_call' }.map do |item|
99
+ {
100
+ id: item['call_id'] || item['id'],
101
+ name: item['name'],
102
+ arguments: parse_arguments(item['arguments'])
103
+ }
104
+ end
105
+ end
106
+
107
+ def parse_arguments(raw)
108
+ return {} if raw.nil? || raw.empty?
109
+
110
+ JSON.parse(raw)
111
+ rescue JSON::ParserError
112
+ {}
113
+ end
114
+
115
+ def finish_reason_for(body, tool_calls)
116
+ return :tool_calls if tool_calls.any?
117
+
118
+ if body['status'] == 'incomplete'
119
+ reason = body.dig('incomplete_details', 'reason')
120
+ return reason == 'max_output_tokens' ? :length : :incomplete
121
+ end
122
+
123
+ :stop
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'openai'
4
+
5
+ module RCrewAI
6
+ module LLMClients
7
+ # Snowflake Cortex inference. The request/response bodies follow the
8
+ # OpenAI Chat Completions shape, so only the endpoint and the auth headers
9
+ # differ -- Cortex wants the token type declared alongside the bearer
10
+ # token.
11
+ class SnowflakeCortex < OpenAI
12
+ def initialize(config = RCrewAI.configuration, **hooks)
13
+ super
14
+ @account = config.snowflake_account
15
+ @base_url = "https://#{@account}.snowflakecomputing.com"
16
+ end
17
+
18
+ def provider_name
19
+ :snowflake
20
+ end
21
+
22
+ private
23
+
24
+ def chat_url
25
+ "#{@base_url}/api/v2/cortex/inference:complete"
26
+ end
27
+
28
+ def auth_header
29
+ {
30
+ 'Authorization' => "Bearer #{config.api_key}",
31
+ 'X-Snowflake-Authorization-Token-Type' => 'KEYPAIR_JWT'
32
+ }
33
+ end
34
+
35
+ def validate_config!
36
+ raise ConfigurationError, 'Snowflake token is required' unless config.api_key
37
+ raise ConfigurationError, 'A Snowflake account identifier is required' unless config.snowflake_account
38
+ raise ConfigurationError, 'Model is required' unless config.model
39
+ end
40
+ end
41
+ end
42
+ end
@@ -17,6 +17,18 @@ module RCrewAI
17
17
 
18
18
  protected
19
19
 
20
+ # True when this task's result was replayed from a checkpoint and so must
21
+ # not be executed again.
22
+ def restored?(task)
23
+ crew.respond_to?(:restored_task_names) &&
24
+ crew.restored_task_names.include?(task.name)
25
+ end
26
+
27
+ # Records a settled task in the crew's checkpoint, if one is active.
28
+ def checkpoint(task, status)
29
+ crew.checkpoint_task(task, status) if crew.respond_to?(:checkpoint_task)
30
+ end
31
+
20
32
  def log_execution_start
21
33
  @logger.info "Starting #{self.class.name.split('::').last.downcase} process execution"
22
34
  @logger.info "Crew: #{crew.name} with #{crew.agents.length} agents, #{crew.tasks.length} tasks"
@@ -34,13 +46,21 @@ module RCrewAI
34
46
  results = []
35
47
 
36
48
  crew.tasks.each do |task|
49
+ if restored?(task)
50
+ @logger.info "Skipping task (restored from checkpoint): #{task.name}"
51
+ results << { task: task, result: task.result, status: :completed }
52
+ next
53
+ end
54
+
37
55
  @logger.info "Executing task: #{task.name}"
38
56
  begin
39
57
  result = task.execute
40
58
  results << { task: task, result: result, status: :completed }
59
+ checkpoint(task, :completed)
41
60
  rescue StandardError => e
42
61
  @logger.error "Task #{task.name} failed: #{e.message}"
43
62
  results << { task: task, result: e.message, status: :failed }
63
+ checkpoint(task, :failed)
44
64
  end
45
65
  end
46
66
 
@@ -241,6 +261,15 @@ module RCrewAI
241
261
  tasks.each do |task|
242
262
  assigned_agent = hierarchy[:task_assignments][task]
243
263
 
264
+ if restored?(task)
265
+ @logger.info "Skipping task (restored from checkpoint): #{task.name}"
266
+ phase_results << {
267
+ task: task, result: task.result, status: :completed,
268
+ assigned_agent: assigned_agent, phase: phase_number
269
+ }
270
+ next
271
+ end
272
+
244
273
  @logger.info "Manager delegating '#{task.name}' to #{assigned_agent.name}"
245
274
 
246
275
  begin
@@ -258,8 +287,10 @@ module RCrewAI
258
287
  phase: phase_number
259
288
  }
260
289
 
290
+ checkpoint(task, :completed)
261
291
  @logger.info "Task '#{task.name}' completed successfully"
262
292
  rescue StandardError => e
293
+ checkpoint(task, :failed)
263
294
  @logger.error "Delegated task '#{task.name}' failed: #{e.message}"
264
295
 
265
296
  phase_results << {
@@ -390,11 +421,17 @@ module RCrewAI
390
421
  private
391
422
 
392
423
  def execute_with_consensus(task)
424
+ if restored?(task)
425
+ @logger.info "Skipping task (restored from checkpoint): #{task.name}"
426
+ return { task: task, result: task.result, status: :completed }
427
+ end
428
+
393
429
  @logger.info "Consensus for task: #{task.name}"
394
430
  participants = select_participants(task)
395
431
  candidates = gather_proposals(task, participants)
396
432
 
397
433
  if candidates.empty?
434
+ checkpoint(task, :failed)
398
435
  return { task: task, result: 'All agents failed to produce a proposal', status: :failed }
399
436
  end
400
437
 
@@ -402,9 +439,11 @@ module RCrewAI
402
439
  winner = pick_winner(task, scored)
403
440
  task.result = winner[:content] if task.respond_to?(:result=)
404
441
 
442
+ checkpoint(task, :completed)
405
443
  { task: task, result: winner[:content], status: :completed }
406
444
  rescue StandardError => e
407
445
  @logger.error "Consensus failed for #{task.name}: #{e.message}"
446
+ checkpoint(task, :failed)
408
447
  { task: task, result: e.message, status: :failed }
409
448
  end
410
449
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
3
4
  require_relative 'events'
4
5
  require_relative 'provider_schema'
5
6
 
@@ -16,7 +17,13 @@ module RCrewAI
16
17
  @sink = event_sink || ->(_) {}
17
18
  end
18
19
 
19
- def run(messages:) # rubocop:disable Metrics/AbcSize
20
+ def run(messages:)
21
+ Events.with_parent(@run_span_id ||= SecureRandom.uuid) { run_loop(messages: messages) }
22
+ end
23
+
24
+ private
25
+
26
+ def run_loop(messages:) # rubocop:disable Metrics/AbcSize
20
27
  msgs = messages.dup
21
28
  history = []
22
29
  iter = 0
@@ -78,8 +85,6 @@ module RCrewAI
78
85
  finish_reason: :max_iterations, usage: total_usage)
79
86
  end
80
87
 
81
- private
82
-
83
88
  # Trims the message list to the model's context window when the agent
84
89
  # supports it; a no-op otherwise.
85
90
  def fit_context(messages)
@@ -94,13 +99,13 @@ module RCrewAI
94
99
  type_sym = klass.name.split('::').last
95
100
  .gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
96
101
  .sub(/^_/, '').to_sym
97
- @sink.call(klass.new(
98
- type: type_sym,
99
- timestamp: Time.now,
100
- agent: agent_name,
101
- iteration: iteration,
102
- **attrs
103
- ))
102
+ Events.emit(@sink, klass.new(
103
+ type: type_sym,
104
+ timestamp: Time.now,
105
+ agent: agent_name,
106
+ iteration: iteration,
107
+ **attrs
108
+ ))
104
109
  end
105
110
 
106
111
  def agent_name
@@ -110,6 +115,7 @@ module RCrewAI
110
115
  def retag(event, iter)
111
116
  event.agent = agent_name if event.respond_to?(:agent=) && event.agent.nil?
112
117
  event.iteration = iter if event.respond_to?(:iteration=) && event.iteration.nil?
118
+ event.parent_id ||= Events.current_parent
113
119
  event
114
120
  end
115
121
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RCrewAI
4
- VERSION = '0.7.1'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/rcrewai.rb CHANGED
@@ -19,6 +19,7 @@ end
19
19
  require_relative 'rcrewai/version'
20
20
  require_relative 'rcrewai/configuration'
21
21
  require_relative 'rcrewai/events'
22
+ require_relative 'rcrewai/checkpoint'
22
23
  require_relative 'rcrewai/sse_parser'
23
24
  require_relative 'rcrewai/pricing'
24
25
  require_relative 'rcrewai/llm_client'
@@ -56,3 +57,5 @@ require_relative 'rcrewai/task'
56
57
  require_relative 'rcrewai/crew'
57
58
  require_relative 'rcrewai/flow'
58
59
  require_relative 'rcrewai/mcp'
60
+ require_relative 'rcrewai/checkpoint/cli'
61
+ require_relative 'rcrewai/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rcrewai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gkosmo
@@ -389,6 +389,8 @@ files:
389
389
  - lib/rcrewai/agent.rb
390
390
  - lib/rcrewai/agent_augmentations.rb
391
391
  - lib/rcrewai/async_executor.rb
392
+ - lib/rcrewai/checkpoint.rb
393
+ - lib/rcrewai/checkpoint/cli.rb
392
394
  - lib/rcrewai/cli.rb
393
395
  - lib/rcrewai/configuration.rb
394
396
  - lib/rcrewai/context_window.rb
@@ -409,9 +411,13 @@ files:
409
411
  - lib/rcrewai/llm_clients/anthropic.rb
410
412
  - lib/rcrewai/llm_clients/azure.rb
411
413
  - lib/rcrewai/llm_clients/base.rb
414
+ - lib/rcrewai/llm_clients/bedrock.rb
412
415
  - lib/rcrewai/llm_clients/google.rb
413
416
  - lib/rcrewai/llm_clients/ollama.rb
414
417
  - lib/rcrewai/llm_clients/openai.rb
418
+ - lib/rcrewai/llm_clients/openai_compatible.rb
419
+ - lib/rcrewai/llm_clients/openai_responses.rb
420
+ - lib/rcrewai/llm_clients/snowflake_cortex.rb
415
421
  - lib/rcrewai/mcp.rb
416
422
  - lib/rcrewai/mcp/client.rb
417
423
  - lib/rcrewai/mcp/tool_adapter.rb