rcrewai 0.7.0 → 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/ROADMAP.md +144 -75
  4. data/docs/api/agent.md +8 -0
  5. data/docs/api/crew.md +53 -3
  6. data/docs/api/index.md +14 -13
  7. data/docs/api/task.md +7 -0
  8. data/docs/examples/async-execution.md +8 -5
  9. data/docs/examples/tool-composition.md +3 -3
  10. data/docs/index.md +22 -7
  11. data/docs/tutorials/agent-options.md +128 -0
  12. data/docs/tutorials/consensual-process.md +58 -0
  13. data/docs/tutorials/flows.md +135 -0
  14. data/docs/tutorials/index.md +45 -0
  15. data/docs/tutorials/knowledge.md +80 -0
  16. data/docs/tutorials/memory.md +90 -0
  17. data/lib/rcrewai/checkpoint/cli.rb +98 -0
  18. data/lib/rcrewai/checkpoint.rb +145 -0
  19. data/lib/rcrewai/cli.rb +12 -1
  20. data/lib/rcrewai/configuration.rb +7 -0
  21. data/lib/rcrewai/crew.rb +88 -2
  22. data/lib/rcrewai/events.rb +71 -5
  23. data/lib/rcrewai/legacy_react_runner.rb +14 -9
  24. data/lib/rcrewai/llm_client.rb +20 -15
  25. data/lib/rcrewai/llm_clients/anthropic.rb +13 -4
  26. data/lib/rcrewai/llm_clients/azure.rb +2 -2
  27. data/lib/rcrewai/llm_clients/base.rb +55 -1
  28. data/lib/rcrewai/llm_clients/bedrock.rb +134 -0
  29. data/lib/rcrewai/llm_clients/google.rb +13 -4
  30. data/lib/rcrewai/llm_clients/ollama.rb +18 -4
  31. data/lib/rcrewai/llm_clients/openai.rb +19 -15
  32. data/lib/rcrewai/llm_clients/openai_compatible.rb +34 -0
  33. data/lib/rcrewai/llm_clients/openai_responses.rb +127 -0
  34. data/lib/rcrewai/llm_clients/snowflake_cortex.rb +42 -0
  35. data/lib/rcrewai/process.rb +41 -2
  36. data/lib/rcrewai/task.rb +3 -2
  37. data/lib/rcrewai/tool_runner.rb +16 -10
  38. data/lib/rcrewai/version.rb +1 -1
  39. data/lib/rcrewai.rb +3 -0
  40. metadata +12 -1
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'faraday'
5
+ require 'json'
6
+ require_relative 'base'
7
+ require_relative '../events'
8
+ require_relative '../pricing'
9
+
10
+ module RCrewAI
11
+ module LLMClients
12
+ # AWS Bedrock via the Converse API (v4). Converse gives every Bedrock model
13
+ # one request/response shape regardless of the underlying vendor, so this
14
+ # client speaks Converse rather than each model's native format.
15
+ #
16
+ # Authentication: the configured api_key is sent as a bearer token, which
17
+ # covers Bedrock API keys and any gateway fronting Bedrock. Full SigV4
18
+ # request signing is not implemented -- it needs the aws-sigv4 gem, and
19
+ # adding a hard AWS dependency for one provider is not worth it. Users
20
+ # needing SigV4 can sign via a before_request hook.
21
+ class Bedrock < Base
22
+ STOP_REASONS = {
23
+ 'end_turn' => :stop,
24
+ 'stop_sequence' => :stop,
25
+ 'max_tokens' => :length,
26
+ 'tool_use' => :tool_calls,
27
+ 'content_filtered' => :content_filter
28
+ }.freeze
29
+
30
+ def initialize(config = RCrewAI.configuration, **hooks)
31
+ super
32
+ @region = config.aws_region
33
+ end
34
+
35
+ def provider_name
36
+ :bedrock
37
+ end
38
+
39
+ def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options) # rubocop:disable Lint/UnusedMethodArgument
40
+ system_text = extract_system(messages)
41
+ payload = {
42
+ messages: format_messages(messages),
43
+ inferenceConfig: {
44
+ temperature: options[:temperature] || config.temperature,
45
+ maxTokens: options[:max_tokens] || config.max_tokens
46
+ }.compact
47
+ }
48
+ payload[:system] = [{ text: system_text }] if system_text
49
+ payload[:toolConfig] = { tools: format_tools(tools) } if tools && !tools.empty?
50
+
51
+ plain_chat(payload)
52
+ end
53
+
54
+ # Converse exposes tool use uniformly, but streaming uses a separate
55
+ # endpoint and event-stream framing that this client does not implement.
56
+ def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
57
+ true
58
+ end
59
+
60
+ private
61
+
62
+ def plain_chat(payload)
63
+ url = converse_url
64
+ payload = apply_before_request(payload)
65
+ started_at = Time.now
66
+ log_request(:post, url, payload)
67
+ response = http_client.post(url, payload, build_headers.merge(auth_header))
68
+ log_response(response)
69
+ body = handle_response(response)
70
+ apply_after_response(normalize(body), started_at)
71
+ end
72
+
73
+ def converse_url
74
+ "https://bedrock-runtime.#{@region}.amazonaws.com/model/#{CGI.escape(config.model)}/converse"
75
+ end
76
+
77
+ # Converse carries the system prompt at the top level, not in messages.
78
+ def extract_system(messages)
79
+ systems = messages.select { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }
80
+ return nil if systems.empty?
81
+
82
+ systems.map { |m| m[:content] }.join("\n\n")
83
+ end
84
+
85
+ # Every message content is a list of typed blocks.
86
+ def format_messages(messages)
87
+ messages.reject { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }.map do |m|
88
+ { role: m[:role].to_s, content: [{ text: m[:content].to_s }] }
89
+ end
90
+ end
91
+
92
+ def format_tools(tools)
93
+ tools.map do |t|
94
+ { toolSpec: { name: t[:name], description: t[:description],
95
+ inputSchema: { json: t[:parameters] } } }
96
+ end
97
+ end
98
+
99
+ def normalize(body)
100
+ blocks = body.dig('output', 'message', 'content') || []
101
+ text = blocks.filter_map { |b| b['text'] }.join
102
+ tool_calls = blocks.filter_map do |b|
103
+ use = b['toolUse']
104
+ next unless use
105
+
106
+ { id: use['toolUseId'], name: use['name'], arguments: use['input'] || {} }
107
+ end
108
+
109
+ {
110
+ content: text.empty? ? nil : text,
111
+ tool_calls: tool_calls,
112
+ usage: {
113
+ prompt_tokens: body.dig('usage', 'inputTokens'),
114
+ completion_tokens: body.dig('usage', 'outputTokens'),
115
+ total_tokens: body.dig('usage', 'totalTokens')
116
+ },
117
+ finish_reason: STOP_REASONS.fetch(body['stopReason'], :stop),
118
+ model: config.model,
119
+ provider: provider_name
120
+ }
121
+ end
122
+
123
+ def auth_header
124
+ { 'Authorization' => "Bearer #{config.api_key}" }
125
+ end
126
+
127
+ def validate_config!
128
+ raise ConfigurationError, 'Bedrock API key is required' unless config.api_key
129
+ raise ConfigurationError, 'An AWS region is required for Bedrock' unless config.aws_region
130
+ raise ConfigurationError, 'Model is required' unless config.model
131
+ end
132
+ end
133
+ end
134
+ end
@@ -20,8 +20,8 @@ module RCrewAI
20
20
  'RECITATION' => :stop
21
21
  }.freeze
22
22
 
23
- def initialize(config = RCrewAI.configuration)
24
- super
23
+ def initialize(config = RCrewAI.configuration, **hooks)
24
+ super(config, **hooks)
25
25
  @base_url = BASE_URL
26
26
  end
27
27
 
@@ -62,17 +62,25 @@ module RCrewAI
62
62
  %w[gemini-pro gemini-1.5-pro gemini-1.5-flash gemini-pro-vision]
63
63
  end
64
64
 
65
+ def provider_name
66
+ :google
67
+ end
68
+
65
69
  private
66
70
 
67
71
  def plain_chat(url, payload)
72
+ payload = apply_before_request(payload)
73
+ started_at = Time.now
68
74
  log_request(:post, url, payload)
69
75
  response = http_client.post(url, payload, build_headers)
70
76
  log_response(response)
71
77
  body = handle_response(response)
72
- normalize_non_streaming(body)
78
+ apply_after_response(normalize_non_streaming(body), started_at)
73
79
  end
74
80
 
75
81
  def stream_chat(url, payload, sink)
82
+ payload = apply_before_request(payload)
83
+ started_at = Time.now
76
84
  log_request(:post, url, payload)
77
85
 
78
86
  assembled_text = +''
@@ -123,7 +131,7 @@ module RCrewAI
123
131
 
124
132
  finish_reason = :tool_calls if tool_calls.any?
125
133
 
126
- {
134
+ result = {
127
135
  content: assembled_text.empty? ? nil : assembled_text,
128
136
  tool_calls: tool_calls,
129
137
  usage: usage || {},
@@ -131,6 +139,7 @@ module RCrewAI
131
139
  model: config.model,
132
140
  provider: :google
133
141
  }
142
+ apply_after_response(result, started_at)
134
143
  end
135
144
 
136
145
  def streaming_post(url, payload, &on_chunk)
@@ -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 << {
@@ -351,7 +382,7 @@ module RCrewAI
351
382
  end
352
383
 
353
384
  # Execute the task
354
- agent.execute_task(enhanced_task)
385
+ agent.execute_task(enhanced_task, stream: crew.stream_sink)
355
386
  end
356
387
 
357
388
  def should_abort_execution?(failed_tasks, phase_number, _plan)
@@ -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
 
@@ -421,7 +460,7 @@ module RCrewAI
421
460
 
422
461
  def gather_proposals(task, participants)
423
462
  participants.filter_map do |agent|
424
- content = extract_content(agent.execute_task(task))
463
+ content = extract_content(agent.execute_task(task, stream: crew.stream_sink))
425
464
  { agent: agent, content: content }
426
465
  rescue StandardError => e
427
466
  @logger.warn "Agent #{agent.name} failed to propose: #{e.message}"
data/lib/rcrewai/task.rb CHANGED
@@ -10,7 +10,7 @@ module RCrewAI
10
10
  include HumanInteractionExtensions
11
11
  attr_reader :name, :description, :agent, :context, :expected_output, :tools, :async,
12
12
  :raw_result, :structured_output, :attachments
13
- attr_accessor :result, :status, :start_time, :end_time, :execution_time
13
+ attr_accessor :result, :status, :start_time, :end_time, :execution_time, :stream_sink
14
14
 
15
15
  def initialize(name:, description:, agent: nil, **options)
16
16
  @name = name
@@ -44,6 +44,7 @@ module RCrewAI
44
44
  @start_time = nil
45
45
  @end_time = nil
46
46
  @execution_time = nil
47
+ @stream_sink = nil
47
48
  @retry_count = 0
48
49
  @max_retries = options.fetch(:max_retries, 2)
49
50
  end
@@ -218,7 +219,7 @@ module RCrewAI
218
219
 
219
220
  loop do
220
221
  attempts += 1
221
- raw = extract_content(agent.execute_task(self))
222
+ raw = extract_content(agent.execute_task(self, stream: @stream_sink))
222
223
  @raw_result = raw
223
224
 
224
225
  begin