rcrewai 0.2.1 → 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 +2 -2
- 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 +39 -39
- metadata +65 -47
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
module Pricing
|
|
5
|
+
# Prices in USD per 1M tokens. List prices as of 2026-05; users can override.
|
|
6
|
+
DEFAULT_PRICES = {
|
|
7
|
+
# OpenAI
|
|
8
|
+
'gpt-4o' => { input: 2.50, output: 10.00 },
|
|
9
|
+
'gpt-4o-mini' => { input: 0.15, output: 0.60 },
|
|
10
|
+
'gpt-4-turbo' => { input: 10.00, output: 30.00 },
|
|
11
|
+
'gpt-4' => { input: 30.00, output: 60.00 },
|
|
12
|
+
'gpt-3.5-turbo' => { input: 0.50, output: 1.50 },
|
|
13
|
+
# Anthropic
|
|
14
|
+
'claude-opus-4-7' => { input: 15.00, output: 75.00 },
|
|
15
|
+
'claude-sonnet-4-6' => { input: 3.00, output: 15.00 },
|
|
16
|
+
'claude-haiku-4-5' => { input: 0.80, output: 4.00 },
|
|
17
|
+
'claude-3-5-sonnet-20241022' => { input: 3.00, output: 15.00 },
|
|
18
|
+
'claude-3-haiku-20240307' => { input: 0.25, output: 1.25 },
|
|
19
|
+
# Google
|
|
20
|
+
'gemini-1.5-pro' => { input: 1.25, output: 5.00 },
|
|
21
|
+
'gemini-1.5-flash' => { input: 0.075, output: 0.30 }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def cost_for(model, prompt_tokens:, completion_tokens:)
|
|
27
|
+
table = RCrewAI.configuration.pricing || {}
|
|
28
|
+
entry = table[model] || DEFAULT_PRICES[model]
|
|
29
|
+
return nil unless entry
|
|
30
|
+
|
|
31
|
+
((prompt_tokens * entry[:input]) + (completion_tokens * entry[:output])) / 1_000_000.0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/rcrewai/process.rb
CHANGED
|
@@ -12,7 +12,7 @@ module RCrewAI
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def execute
|
|
15
|
-
raise NotImplementedError,
|
|
15
|
+
raise NotImplementedError, 'Subclasses must implement #execute method'
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
protected
|
|
@@ -38,7 +38,7 @@ module RCrewAI
|
|
|
38
38
|
begin
|
|
39
39
|
result = task.execute
|
|
40
40
|
results << { task: task, result: result, status: :completed }
|
|
41
|
-
rescue => e
|
|
41
|
+
rescue StandardError => e
|
|
42
42
|
@logger.error "Task #{task.name} failed: #{e.message}"
|
|
43
43
|
results << { task: task, result: e.message, status: :failed }
|
|
44
44
|
end
|
|
@@ -75,14 +75,14 @@ module RCrewAI
|
|
|
75
75
|
|
|
76
76
|
def find_or_create_manager
|
|
77
77
|
# Look for an agent marked as manager
|
|
78
|
-
manager = crew.agents.find
|
|
79
|
-
|
|
78
|
+
manager = crew.agents.find(&:is_manager?)
|
|
79
|
+
|
|
80
80
|
# If no explicit manager, find agent with delegation capabilities
|
|
81
|
-
manager ||= crew.agents.find
|
|
82
|
-
|
|
81
|
+
manager ||= crew.agents.find(&:allow_delegation)
|
|
82
|
+
|
|
83
83
|
# Create a default manager if none found
|
|
84
84
|
if manager.nil?
|
|
85
|
-
@logger.warn
|
|
85
|
+
@logger.warn 'No manager agent found, creating default manager'
|
|
86
86
|
manager = create_default_manager
|
|
87
87
|
crew.add_agent(manager)
|
|
88
88
|
end
|
|
@@ -92,10 +92,10 @@ module RCrewAI
|
|
|
92
92
|
|
|
93
93
|
def create_default_manager
|
|
94
94
|
RCrewAI::Agent.new(
|
|
95
|
-
name:
|
|
96
|
-
role:
|
|
97
|
-
goal:
|
|
98
|
-
backstory:
|
|
95
|
+
name: 'crew_manager',
|
|
96
|
+
role: 'Crew Manager',
|
|
97
|
+
goal: 'Coordinate team efforts and delegate tasks effectively',
|
|
98
|
+
backstory: 'You are an experienced project manager who coordinates team efforts, delegates tasks appropriately, and ensures deliverables meet requirements.',
|
|
99
99
|
allow_delegation: true,
|
|
100
100
|
manager: true,
|
|
101
101
|
verbose: crew.verbose
|
|
@@ -127,7 +127,7 @@ module RCrewAI
|
|
|
127
127
|
def find_best_agent_for_task(task, available_agents)
|
|
128
128
|
# Simple heuristic: match task keywords with agent role/goal
|
|
129
129
|
task_keywords = extract_keywords(task.description.downcase)
|
|
130
|
-
|
|
130
|
+
|
|
131
131
|
best_agent = available_agents.max_by do |agent|
|
|
132
132
|
agent_keywords = extract_keywords("#{agent.role} #{agent.goal}".downcase)
|
|
133
133
|
common_keywords = (task_keywords & agent_keywords).length
|
|
@@ -140,14 +140,15 @@ module RCrewAI
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def extract_keywords(text)
|
|
143
|
-
stopwords = %w[the a an and or but in on at to for of with by is are was were be been being have has had do
|
|
143
|
+
stopwords = %w[the a an and or but in on at to for of with by is are was were be been being have has had do
|
|
144
|
+
does did will would could should]
|
|
144
145
|
text.split(/\W+/).reject { |w| w.length < 3 || stopwords.include?(w) }
|
|
145
146
|
end
|
|
146
147
|
|
|
147
148
|
def validate_hierarchy!
|
|
148
|
-
raise ProcessError,
|
|
149
|
-
raise ProcessError,
|
|
150
|
-
raise ProcessError,
|
|
149
|
+
raise ProcessError, 'No manager agent available' unless manager_agent
|
|
150
|
+
raise ProcessError, 'No subordinate agents available' if hierarchy[:subordinates].empty?
|
|
151
|
+
raise ProcessError, 'No tasks to execute' if crew.tasks.empty?
|
|
151
152
|
end
|
|
152
153
|
|
|
153
154
|
def create_execution_plan
|
|
@@ -191,7 +192,7 @@ module RCrewAI
|
|
|
191
192
|
|
|
192
193
|
if ready_tasks.empty?
|
|
193
194
|
# Circular dependency or other issue
|
|
194
|
-
@logger.warn
|
|
195
|
+
@logger.warn 'Circular dependency detected, executing remaining tasks in order'
|
|
195
196
|
phases << remaining_tasks
|
|
196
197
|
break
|
|
197
198
|
end
|
|
@@ -206,24 +207,24 @@ module RCrewAI
|
|
|
206
207
|
|
|
207
208
|
def execute_with_manager(plan)
|
|
208
209
|
results = []
|
|
209
|
-
|
|
210
|
+
|
|
210
211
|
plan[:phases].each_with_index do |phase_tasks, phase_index|
|
|
211
212
|
@logger.info "Executing phase #{phase_index + 1}: #{phase_tasks.length} tasks"
|
|
212
|
-
|
|
213
|
+
|
|
213
214
|
# Manager delegates tasks in this phase
|
|
214
215
|
phase_results = execute_phase(phase_tasks, phase_index + 1)
|
|
215
216
|
results.concat(phase_results)
|
|
216
|
-
|
|
217
|
+
|
|
217
218
|
# Check if we should continue to next phase
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
next unless phase_results.any? { |r| r[:status] == :failed }
|
|
220
|
+
|
|
221
|
+
failed_tasks = phase_results.select { |r| r[:status] == :failed }
|
|
222
|
+
@logger.warn "Phase #{phase_index + 1} had #{failed_tasks.length} failures"
|
|
223
|
+
|
|
224
|
+
# Manager decides whether to continue
|
|
225
|
+
if should_abort_execution?(failed_tasks, phase_index + 1, plan)
|
|
226
|
+
@logger.error 'Manager decided to abort execution due to critical failures'
|
|
227
|
+
break
|
|
227
228
|
end
|
|
228
229
|
end
|
|
229
230
|
|
|
@@ -232,39 +233,38 @@ module RCrewAI
|
|
|
232
233
|
|
|
233
234
|
def execute_phase(tasks, phase_number)
|
|
234
235
|
phase_results = []
|
|
235
|
-
|
|
236
|
+
|
|
236
237
|
# Manager creates delegation plan for this phase
|
|
237
238
|
delegation_plan = create_delegation_plan(tasks, phase_number)
|
|
238
|
-
|
|
239
|
+
|
|
239
240
|
# Execute delegated tasks
|
|
240
241
|
tasks.each do |task|
|
|
241
242
|
assigned_agent = hierarchy[:task_assignments][task]
|
|
242
|
-
|
|
243
|
+
|
|
243
244
|
@logger.info "Manager delegating '#{task.name}' to #{assigned_agent.name}"
|
|
244
|
-
|
|
245
|
+
|
|
245
246
|
begin
|
|
246
247
|
# Manager provides delegation context
|
|
247
248
|
delegation_context = create_delegation_context(task, assigned_agent, delegation_plan)
|
|
248
|
-
|
|
249
|
+
|
|
249
250
|
# Execute task with delegation
|
|
250
251
|
result = execute_delegated_task(task, assigned_agent, delegation_context)
|
|
251
|
-
|
|
252
|
-
phase_results << {
|
|
253
|
-
task: task,
|
|
254
|
-
result: result,
|
|
252
|
+
|
|
253
|
+
phase_results << {
|
|
254
|
+
task: task,
|
|
255
|
+
result: result,
|
|
255
256
|
status: :completed,
|
|
256
257
|
assigned_agent: assigned_agent,
|
|
257
258
|
phase: phase_number
|
|
258
259
|
}
|
|
259
|
-
|
|
260
|
+
|
|
260
261
|
@logger.info "Task '#{task.name}' completed successfully"
|
|
261
|
-
|
|
262
|
-
rescue => e
|
|
262
|
+
rescue StandardError => e
|
|
263
263
|
@logger.error "Delegated task '#{task.name}' failed: #{e.message}"
|
|
264
|
-
|
|
265
|
-
phase_results << {
|
|
266
|
-
task: task,
|
|
267
|
-
result: e.message,
|
|
264
|
+
|
|
265
|
+
phase_results << {
|
|
266
|
+
task: task,
|
|
267
|
+
result: e.message,
|
|
268
268
|
status: :failed,
|
|
269
269
|
assigned_agent: assigned_agent,
|
|
270
270
|
phase: phase_number,
|
|
@@ -272,7 +272,7 @@ module RCrewAI
|
|
|
272
272
|
}
|
|
273
273
|
end
|
|
274
274
|
end
|
|
275
|
-
|
|
275
|
+
|
|
276
276
|
phase_results
|
|
277
277
|
end
|
|
278
278
|
|
|
@@ -288,42 +288,34 @@ module RCrewAI
|
|
|
288
288
|
def assign_task_priorities(tasks)
|
|
289
289
|
# Simple priority assignment based on dependencies and complexity
|
|
290
290
|
priorities = {}
|
|
291
|
-
|
|
291
|
+
|
|
292
292
|
tasks.each do |task|
|
|
293
293
|
priority = :normal
|
|
294
|
-
|
|
294
|
+
|
|
295
295
|
# High priority if other tasks depend on this one
|
|
296
|
-
if crew.tasks.any? { |t| t.context&.include?(task) }
|
|
297
|
-
|
|
298
|
-
end
|
|
299
|
-
|
|
296
|
+
priority = :high if crew.tasks.any? { |t| t.context&.include?(task) }
|
|
297
|
+
|
|
300
298
|
# Low priority if task is optional or has many dependencies
|
|
301
|
-
if task.context&.length.to_i > 2
|
|
302
|
-
|
|
303
|
-
end
|
|
304
|
-
|
|
299
|
+
priority = :low if task.context&.length.to_i > 2
|
|
300
|
+
|
|
305
301
|
priorities[task] = priority
|
|
306
302
|
end
|
|
307
|
-
|
|
303
|
+
|
|
308
304
|
priorities
|
|
309
305
|
end
|
|
310
306
|
|
|
311
307
|
def generate_coordination_notes(tasks)
|
|
312
308
|
notes = []
|
|
313
|
-
|
|
314
|
-
if tasks.length > 1
|
|
315
|
-
|
|
316
|
-
end
|
|
317
|
-
|
|
309
|
+
|
|
310
|
+
notes << 'Multiple tasks in this phase - coordinate timing if needed' if tasks.length > 1
|
|
311
|
+
|
|
318
312
|
if tasks.any? { |t| t.context&.any? }
|
|
319
|
-
notes <<
|
|
313
|
+
notes << 'Some tasks depend on previous results - ensure context is available'
|
|
320
314
|
end
|
|
321
|
-
|
|
322
|
-
if tasks.any? { |t| t.tools&.any? }
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
notes.join(". ") + "."
|
|
315
|
+
|
|
316
|
+
notes << 'Tasks require external tools - monitor for failures' if tasks.any? { |t| t.tools&.any? }
|
|
317
|
+
|
|
318
|
+
"#{notes.join('. ')}."
|
|
327
319
|
end
|
|
328
320
|
|
|
329
321
|
def create_delegation_context(task, assigned_agent, delegation_plan)
|
|
@@ -332,51 +324,51 @@ module RCrewAI
|
|
|
332
324
|
assignment_reason: generate_assignment_reason(task, assigned_agent),
|
|
333
325
|
phase_context: delegation_plan,
|
|
334
326
|
expectations: generate_task_expectations(task),
|
|
335
|
-
escalation_notes:
|
|
327
|
+
escalation_notes: 'Contact manager if issues arise or guidance needed'
|
|
336
328
|
}
|
|
337
329
|
end
|
|
338
330
|
|
|
339
|
-
def generate_assignment_reason(
|
|
331
|
+
def generate_assignment_reason(_task, agent)
|
|
340
332
|
"Assigned to #{agent.name} based on role '#{agent.role}' and expertise alignment with task requirements"
|
|
341
333
|
end
|
|
342
334
|
|
|
343
335
|
def generate_task_expectations(task)
|
|
344
336
|
expectations = []
|
|
345
337
|
expectations << "Expected output: #{task.expected_output}" if task.expected_output
|
|
346
|
-
expectations <<
|
|
347
|
-
expectations <<
|
|
348
|
-
expectations.join(
|
|
338
|
+
expectations << 'Quality: Professional and thorough'
|
|
339
|
+
expectations << 'Communication: Report progress and any blockers'
|
|
340
|
+
"#{expectations.join('. ')}."
|
|
349
341
|
end
|
|
350
342
|
|
|
351
343
|
def execute_delegated_task(task, agent, delegation_context)
|
|
352
344
|
# Enhance task with delegation context
|
|
353
345
|
enhanced_task = task.dup
|
|
354
346
|
enhanced_task.instance_variable_set(:@delegation_context, delegation_context)
|
|
355
|
-
|
|
347
|
+
|
|
356
348
|
# Define method to access delegation context
|
|
357
349
|
def enhanced_task.delegation_context
|
|
358
350
|
@delegation_context
|
|
359
351
|
end
|
|
360
|
-
|
|
352
|
+
|
|
361
353
|
# Execute the task
|
|
362
354
|
agent.execute_task(enhanced_task)
|
|
363
355
|
end
|
|
364
356
|
|
|
365
|
-
def should_abort_execution?(failed_tasks, phase_number,
|
|
357
|
+
def should_abort_execution?(failed_tasks, phase_number, _plan)
|
|
366
358
|
# Abort if more than 50% of critical tasks failed
|
|
367
359
|
critical_failures = failed_tasks.count { |r| r[:task].context&.any? || r[:task].expected_output }
|
|
368
|
-
|
|
360
|
+
|
|
369
361
|
if critical_failures > (failed_tasks.length * 0.5)
|
|
370
362
|
@logger.error "Too many critical task failures (#{critical_failures}/#{failed_tasks.length})"
|
|
371
363
|
return true
|
|
372
364
|
end
|
|
373
|
-
|
|
365
|
+
|
|
374
366
|
# Abort if we're early in execution and having major issues
|
|
375
367
|
if phase_number <= 2 && failed_tasks.length > 1
|
|
376
|
-
@logger.error
|
|
368
|
+
@logger.error 'Multiple failures in early phases indicate systemic issues'
|
|
377
369
|
return true
|
|
378
370
|
end
|
|
379
|
-
|
|
371
|
+
|
|
380
372
|
false
|
|
381
373
|
end
|
|
382
374
|
end
|
|
@@ -384,38 +376,37 @@ module RCrewAI
|
|
|
384
376
|
class Consensual < Base
|
|
385
377
|
def execute
|
|
386
378
|
log_execution_start
|
|
387
|
-
@logger.info
|
|
388
|
-
|
|
379
|
+
@logger.info 'Consensual execution - agents collaborate on decisions'
|
|
380
|
+
|
|
389
381
|
# For now, implement as enhanced sequential with collaboration
|
|
390
382
|
# Full consensual process would involve agent voting/discussion
|
|
391
383
|
results = []
|
|
392
|
-
|
|
384
|
+
|
|
393
385
|
crew.tasks.each do |task|
|
|
394
386
|
@logger.info "Collaborative execution of task: #{task.name}"
|
|
395
|
-
|
|
387
|
+
|
|
396
388
|
# Simple consensus: let multiple agents provide input
|
|
397
389
|
consensus_result = execute_with_consensus(task)
|
|
398
390
|
results << consensus_result
|
|
399
391
|
end
|
|
400
|
-
|
|
392
|
+
|
|
401
393
|
log_execution_end(results)
|
|
402
394
|
results
|
|
403
395
|
end
|
|
404
|
-
|
|
396
|
+
|
|
405
397
|
private
|
|
406
|
-
|
|
398
|
+
|
|
407
399
|
def execute_with_consensus(task)
|
|
408
400
|
# For now, just execute normally
|
|
409
401
|
# Future: implement actual consensus mechanisms
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
end
|
|
402
|
+
|
|
403
|
+
result = task.execute
|
|
404
|
+
{ task: task, result: result, status: :completed }
|
|
405
|
+
rescue StandardError => e
|
|
406
|
+
{ task: task, result: e.message, status: :failed }
|
|
416
407
|
end
|
|
417
408
|
end
|
|
418
409
|
|
|
419
410
|
class ProcessError < RCrewAI::Error; end
|
|
420
411
|
end
|
|
421
|
-
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
module ProviderSchema
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def for(provider, canonical)
|
|
8
|
+
case provider.to_sym
|
|
9
|
+
when :openai, :azure, :ollama
|
|
10
|
+
{ type: 'function', function: canonical }
|
|
11
|
+
when :anthropic
|
|
12
|
+
{
|
|
13
|
+
name: canonical[:name],
|
|
14
|
+
description: canonical[:description],
|
|
15
|
+
input_schema: canonical[:parameters]
|
|
16
|
+
}
|
|
17
|
+
when :google
|
|
18
|
+
{
|
|
19
|
+
function_declarations: [{
|
|
20
|
+
name: canonical[:name],
|
|
21
|
+
description: canonical[:description],
|
|
22
|
+
parameters: canonical[:parameters]
|
|
23
|
+
}]
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "unknown provider #{provider.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def for_many(provider, canonicals)
|
|
31
|
+
if provider.to_sym == :google
|
|
32
|
+
{ function_declarations: canonicals.map { |c| self.for(:google, c)[:function_declarations].first } }
|
|
33
|
+
else
|
|
34
|
+
canonicals.map { |c| self.for(provider, c) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
# Minimal Server-Sent Events line parser.
|
|
5
|
+
# Supports LF and CRLF line terminators (sufficient for OpenAI, Anthropic,
|
|
6
|
+
# Google, and well-behaved MCP HTTP servers). Lone-CR terminators are NOT
|
|
7
|
+
# handled — see https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
8
|
+
# if that becomes a requirement.
|
|
9
|
+
# Feed bytes via #feed(chunk); yields { event: String, data: String } per complete event.
|
|
10
|
+
class SSEParser
|
|
11
|
+
def initialize(&block)
|
|
12
|
+
@on_event = block
|
|
13
|
+
@buffer = String.new(encoding: Encoding::UTF_8)
|
|
14
|
+
@event = 'message'
|
|
15
|
+
@data_lines = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def feed(chunk)
|
|
19
|
+
chunk = chunk.dup.force_encoding(Encoding::UTF_8) unless chunk.encoding == Encoding::UTF_8
|
|
20
|
+
@buffer << chunk
|
|
21
|
+
while (idx = @buffer.index("\n"))
|
|
22
|
+
line = @buffer.slice!(0, idx + 1).chomp
|
|
23
|
+
if line.empty?
|
|
24
|
+
dispatch
|
|
25
|
+
elsif line.start_with?(':')
|
|
26
|
+
# comment line, ignore
|
|
27
|
+
elsif (colon = line.index(':'))
|
|
28
|
+
field = line[0...colon]
|
|
29
|
+
value = line[(colon + 1)..]
|
|
30
|
+
value = value[1..] if value.start_with?(' ')
|
|
31
|
+
handle_field(field, value)
|
|
32
|
+
else
|
|
33
|
+
handle_field(line, '')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def handle_field(field, value)
|
|
41
|
+
case field
|
|
42
|
+
when 'event' then @event = value
|
|
43
|
+
when 'data' then @data_lines << value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def dispatch
|
|
48
|
+
return if @data_lines.empty?
|
|
49
|
+
|
|
50
|
+
@on_event.call(event: @event, data: @data_lines.join("\n"))
|
|
51
|
+
@event = 'message'
|
|
52
|
+
@data_lines = []
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|