rcrewai 0.2.1 → 0.4.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -0
  3. data/.rubocop_todo.yml +99 -0
  4. data/CHANGELOG.md +64 -1
  5. data/README.md +170 -2
  6. data/ROADMAP.md +84 -0
  7. data/Rakefile +53 -53
  8. data/bin/rcrewai +3 -3
  9. data/docs/mcp.md +109 -0
  10. data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
  11. data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
  12. data/docs/upgrading-to-0.3.md +163 -0
  13. data/examples/async_execution_example.rb +82 -81
  14. data/examples/hierarchical_crew_example.rb +68 -72
  15. data/examples/human_in_the_loop_example.rb +73 -74
  16. data/examples/mcp_example.rb +48 -0
  17. data/examples/native_tools_example.rb +64 -0
  18. data/examples/streaming_example.rb +56 -0
  19. data/lib/rcrewai/agent.rb +181 -286
  20. data/lib/rcrewai/async_executor.rb +43 -43
  21. data/lib/rcrewai/cli.rb +11 -11
  22. data/lib/rcrewai/configuration.rb +34 -9
  23. data/lib/rcrewai/crew.rb +134 -39
  24. data/lib/rcrewai/events.rb +30 -0
  25. data/lib/rcrewai/flow/state.rb +47 -0
  26. data/lib/rcrewai/flow/state_store.rb +50 -0
  27. data/lib/rcrewai/flow.rb +243 -0
  28. data/lib/rcrewai/human_input.rb +104 -114
  29. data/lib/rcrewai/knowledge/base.rb +52 -0
  30. data/lib/rcrewai/knowledge/chunker.rb +31 -0
  31. data/lib/rcrewai/knowledge/embedder.rb +48 -0
  32. data/lib/rcrewai/knowledge/sources.rb +83 -0
  33. data/lib/rcrewai/knowledge/store.rb +58 -0
  34. data/lib/rcrewai/knowledge.rb +13 -0
  35. data/lib/rcrewai/legacy_react_runner.rb +172 -0
  36. data/lib/rcrewai/llm_client.rb +24 -1
  37. data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
  38. data/lib/rcrewai/llm_clients/azure.rb +23 -128
  39. data/lib/rcrewai/llm_clients/base.rb +11 -7
  40. data/lib/rcrewai/llm_clients/google.rb +159 -95
  41. data/lib/rcrewai/llm_clients/ollama.rb +150 -106
  42. data/lib/rcrewai/llm_clients/openai.rb +140 -63
  43. data/lib/rcrewai/mcp/client.rb +101 -0
  44. data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
  45. data/lib/rcrewai/mcp/transport/http.rb +53 -0
  46. data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
  47. data/lib/rcrewai/mcp.rb +8 -0
  48. data/lib/rcrewai/memory.rb +45 -37
  49. data/lib/rcrewai/output_schema.rb +79 -0
  50. data/lib/rcrewai/planning.rb +65 -0
  51. data/lib/rcrewai/pricing.rb +34 -0
  52. data/lib/rcrewai/process.rb +86 -95
  53. data/lib/rcrewai/provider_schema.rb +38 -0
  54. data/lib/rcrewai/sse_parser.rb +55 -0
  55. data/lib/rcrewai/task.rb +145 -66
  56. data/lib/rcrewai/tool_runner.rb +132 -0
  57. data/lib/rcrewai/tool_schema.rb +97 -0
  58. data/lib/rcrewai/tools/base.rb +98 -37
  59. data/lib/rcrewai/tools/code_executor.rb +71 -74
  60. data/lib/rcrewai/tools/email_sender.rb +70 -78
  61. data/lib/rcrewai/tools/file_reader.rb +38 -30
  62. data/lib/rcrewai/tools/file_writer.rb +40 -38
  63. data/lib/rcrewai/tools/pdf_processor.rb +115 -130
  64. data/lib/rcrewai/tools/sql_database.rb +58 -55
  65. data/lib/rcrewai/tools/web_search.rb +26 -25
  66. data/lib/rcrewai/version.rb +2 -2
  67. data/lib/rcrewai.rb +20 -10
  68. data/rcrewai.gemspec +39 -39
  69. metadata +77 -47
data/lib/rcrewai/task.rb CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  require_relative 'async_executor'
4
4
  require_relative 'human_input'
5
+ require_relative 'output_schema'
5
6
 
6
7
  module RCrewAI
7
8
  class Task
8
9
  include AsyncExtensions
9
10
  include HumanInteractionExtensions
10
- attr_reader :name, :description, :agent, :context, :expected_output, :tools, :async
11
+ attr_reader :name, :description, :agent, :context, :expected_output, :tools, :async,
12
+ :raw_result, :structured_output
11
13
  attr_accessor :result, :status, :start_time, :end_time, :execution_time
12
14
 
13
15
  def initialize(name:, description:, agent: nil, **options)
@@ -19,13 +21,23 @@ module RCrewAI
19
21
  @tools = options[:tools] || [] # Additional tools for this specific task
20
22
  @async = options[:async] || false # Whether task can run asynchronously
21
23
  @callback = options[:callback] # Callback function after completion
22
-
24
+
25
+ # Output processing (0.4.0)
26
+ @output_schema = options[:output_schema] # JSON-schema for structured output
27
+ @guardrail = options[:guardrail] # ->(output) { [ok, value_or_error] }
28
+ @guardrail_max_retries = options.fetch(:guardrail_max_retries, 3)
29
+ @output_file = options[:output_file] # Path to write result to
30
+ @create_directory = options.fetch(:create_directory, true)
31
+ @markdown = options.fetch(:markdown, false)
32
+ @raw_result = nil # Unprocessed string content
33
+ @structured_output = nil # Parsed object when output_schema set
34
+
23
35
  # Human interaction options
24
36
  @human_input_enabled = options[:human_input] || false
25
37
  @require_human_confirmation = options[:require_confirmation] || false
26
38
  @allow_human_guidance = options[:allow_guidance] || false
27
39
  @human_review_points = options[:human_review_points] || []
28
-
40
+
29
41
  @result = nil
30
42
  @status = :pending
31
43
  @start_time = nil
@@ -53,7 +65,7 @@ module RCrewAI
53
65
  if agent
54
66
  validate_dependencies!
55
67
  provide_context_to_agent
56
-
68
+
57
69
  # Enable human input for agent if task allows it
58
70
  if @human_input_enabled && agent.respond_to?(:enable_human_input)
59
71
  agent.enable_human_input(
@@ -61,31 +73,28 @@ module RCrewAI
61
73
  require_approval_for_final_answer: @allow_human_guidance
62
74
  )
63
75
  end
64
-
65
- @result = agent.execute_task(self)
66
-
76
+
77
+ @result = run_agent_with_output_processing
78
+
67
79
  # Post-execution human review if configured
68
80
  if @human_input_enabled && @human_review_points.include?(:completion)
69
81
  review_result = request_task_completion_review
70
- if review_result && !review_result[:approved]
71
- @result = handle_completion_review_feedback(review_result)
72
- end
82
+ @result = handle_completion_review_feedback(review_result) if review_result && !review_result[:approved]
73
83
  end
74
-
84
+
75
85
  @status = :completed
76
86
  else
77
- @result = "Task requires an agent"
87
+ @result = 'Task requires an agent'
78
88
  @status = :failed
79
89
  end
80
90
 
81
91
  @end_time = Time.now
82
92
  @execution_time = @end_time - @start_time
83
-
93
+
84
94
  # Execute callback if provided
85
95
  @callback&.call(self, @result)
86
-
87
- @result
88
96
 
97
+ @result
89
98
  rescue TaskDependencyError => e
90
99
  # Don't retry dependency errors - they need dependencies to be completed first
91
100
  @status = :failed
@@ -93,16 +102,16 @@ module RCrewAI
93
102
  @execution_time = @end_time - @start_time if @start_time
94
103
  @result = "Task dependencies not met: #{e.message}"
95
104
  raise e
96
- rescue => e
105
+ rescue StandardError => e
97
106
  @status = :failed
98
107
  @end_time = Time.now
99
108
  @execution_time = @end_time - @start_time if @start_time
100
-
109
+
101
110
  # Retry logic with human intervention
102
111
  if @retry_count < @max_retries
103
112
  @retry_count += 1
104
113
  puts "Task #{name} failed, retrying (#{@retry_count}/#{@max_retries})"
105
-
114
+
106
115
  # Ask human for retry guidance if enabled
107
116
  if @human_input_enabled
108
117
  retry_decision = request_retry_guidance(e)
@@ -113,15 +122,13 @@ module RCrewAI
113
122
  when 'modify'
114
123
  # Allow human to modify task parameters
115
124
  modification_result = request_task_modification
116
- if modification_result[:modified]
117
- apply_task_modifications(modification_result[:changes])
118
- end
119
- # 'retry' is the default - continue with retry logic
125
+ apply_task_modifications(modification_result[:changes]) if modification_result[:modified]
126
+ # 'retry' is the default - continue with retry logic
120
127
  end
121
128
  end
122
-
129
+
123
130
  @status = :pending
124
- sleep(2 ** @retry_count) # Exponential backoff
131
+ sleep(2**@retry_count) # Exponential backoff
125
132
  return execute
126
133
  end
127
134
 
@@ -129,14 +136,12 @@ module RCrewAI
129
136
  raise TaskExecutionError, "Task '#{name}' failed: #{e.message}"
130
137
  ensure
131
138
  # Disable human input for agent after task completion
132
- if @human_input_enabled && agent.respond_to?(:disable_human_input)
133
- agent.disable_human_input
134
- end
139
+ agent.disable_human_input if @human_input_enabled && agent.respond_to?(:disable_human_input)
135
140
  end
136
141
  end
137
142
 
138
143
  def context_data
139
- return "" if context.empty?
144
+ return '' if context.empty?
140
145
 
141
146
  context_results = context.map do |task|
142
147
  if task.completed?
@@ -173,6 +178,12 @@ module RCrewAI
173
178
  @context << task unless @context.include?(task)
174
179
  end
175
180
 
181
+ # Appends supplementary guidance (e.g. a planning step) to the task's
182
+ # description without discarding the original instructions.
183
+ def enrich_description(text)
184
+ @description = "#{@description}\n\n#{text}"
185
+ end
186
+
176
187
  def add_tool(tool)
177
188
  @tools << tool unless @tools.include?(tool)
178
189
  end
@@ -197,16 +208,83 @@ module RCrewAI
197
208
 
198
209
  private
199
210
 
211
+ # Runs the agent, then applies guardrail validation and schema coercion.
212
+ # Guardrail/schema failures re-run the agent (up to @guardrail_max_retries)
213
+ # with the failure fed back in, rather than raising immediately.
214
+ def run_agent_with_output_processing
215
+ attempts = 0
216
+ feedback = nil
217
+
218
+ loop do
219
+ attempts += 1
220
+ raw = extract_content(agent.execute_task(self))
221
+ @raw_result = raw
222
+
223
+ begin
224
+ apply_guardrail!(raw)
225
+ @structured_output = OutputSchema.coerce(raw, @output_schema) if @output_schema
226
+ rescue OutputProcessingError, OutputSchemaError => e
227
+ if attempts <= @guardrail_max_retries
228
+ feedback = e.message
229
+ append_feedback_to_description(feedback)
230
+ next
231
+ end
232
+ raise
233
+ end
234
+
235
+ write_output_file(guardrail_value_or(raw)) if @output_file
236
+ return guardrail_value_or(raw)
237
+ end
238
+ end
239
+
240
+ # Accepts either the legacy plain-string return or the 0.3.0 result hash.
241
+ def extract_content(agent_result)
242
+ return agent_result[:content].to_s if agent_result.is_a?(Hash)
243
+
244
+ agent_result.to_s
245
+ end
246
+
247
+ def apply_guardrail!(raw)
248
+ return unless @guardrail
249
+
250
+ ok, value = @guardrail.call(raw)
251
+ raise OutputProcessingError, "guardrail rejected output: #{value}" unless ok
252
+
253
+ @guardrail_value = value
254
+ end
255
+
256
+ def guardrail_value_or(raw)
257
+ @guardrail ? @guardrail_value : raw
258
+ end
259
+
260
+ def append_feedback_to_description(feedback)
261
+ @description = "#{@description}\n\n[Retry] Previous attempt was rejected: #{feedback}. " \
262
+ 'Please correct the output.'
263
+ end
264
+
265
+ def write_output_file(content)
266
+ dir = File.dirname(@output_file)
267
+ if @create_directory
268
+ require 'fileutils'
269
+ FileUtils.mkdir_p(dir)
270
+ elsif !Dir.exist?(dir)
271
+ raise OutputProcessingError, "output directory does not exist: #{dir}"
272
+ end
273
+
274
+ body = content.to_s
275
+ body = "# #{name}\n\n#{body}" if @markdown && !body.lstrip.start_with?('#')
276
+ File.write(@output_file, body)
277
+ end
278
+
200
279
  def confirm_task_execution
201
280
  message = "Confirm execution of task: #{name}"
202
281
  context = "Description: #{description}\nExpected Output: #{expected_output || 'Not specified'}\nAssigned Agent: #{agent&.name || 'No agent'}"
203
- consequences = "The task will be executed with the specified agent and may use external tools."
204
-
282
+ consequences = 'The task will be executed with the specified agent and may use external tools.'
283
+
205
284
  request_human_approval(message,
206
- context: context,
207
- consequences: consequences,
208
- timeout: 60
209
- )
285
+ context: context,
286
+ consequences: consequences,
287
+ timeout: 60)
210
288
  end
211
289
 
212
290
  def request_task_completion_review
@@ -214,30 +292,30 @@ module RCrewAI
214
292
  Task: #{name}
215
293
  Description: #{description}
216
294
  Expected Output: #{expected_output || 'Not specified'}
217
-
295
+
218
296
  Actual Result:
219
297
  #{result}
220
-
298
+
221
299
  Execution Time: #{execution_time&.round(2)} seconds
222
300
  Agent: #{agent&.name || 'Unknown'}
223
301
  CONTENT
224
302
 
225
303
  request_human_review(
226
304
  review_content,
227
- review_criteria: ["Accuracy", "Completeness", "Meets expectations"],
305
+ review_criteria: ['Accuracy', 'Completeness', 'Meets expectations'],
228
306
  timeout: 120
229
307
  )
230
308
  end
231
309
 
232
310
  def handle_completion_review_feedback(review_result)
233
- if review_result[:suggested_changes] && review_result[:suggested_changes].any?
311
+ if review_result[:suggested_changes]&.any?
234
312
  # Ask human how to handle the feedback
235
313
  choice_result = request_human_choice(
236
- "Task completed but received feedback. How should I proceed?",
314
+ 'Task completed but received feedback. How should I proceed?',
237
315
  [
238
- "Accept result as-is despite feedback",
239
- "Request agent revision based on feedback",
240
- "Let me provide the correct result"
316
+ 'Accept result as-is despite feedback',
317
+ 'Request agent revision based on feedback',
318
+ 'Let me provide the correct result'
241
319
  ],
242
320
  timeout: 60
243
321
  )
@@ -247,13 +325,13 @@ module RCrewAI
247
325
  result # Return original result
248
326
  when 1
249
327
  # Request agent to revise (simplified)
250
- revised_result = "#{result}\n\nNote: Human feedback received: #{review_result[:feedback]}"
251
- revised_result
328
+ "#{result}\n\nNote: Human feedback received: #{review_result[:feedback]}"
329
+
252
330
  when 2
253
331
  # Get corrected result from human
254
332
  correction_result = request_human_input(
255
- "Please provide the corrected task result:",
256
- help_text: "Enter the complete, corrected result for this task"
333
+ 'Please provide the corrected task result:',
334
+ help_text: 'Enter the complete, corrected result for this task'
257
335
  )
258
336
  correction_result[:input] || result
259
337
  else
@@ -266,9 +344,9 @@ module RCrewAI
266
344
 
267
345
  def request_retry_guidance(error)
268
346
  choices = [
269
- "Retry with current settings",
270
- "Modify task parameters and retry",
271
- "Abort task execution"
347
+ 'Retry with current settings',
348
+ 'Modify task parameters and retry',
349
+ 'Abort task execution'
272
350
  ]
273
351
 
274
352
  choice_result = request_human_choice(
@@ -290,9 +368,9 @@ module RCrewAI
290
368
 
291
369
  def request_task_modification
292
370
  modification_input = request_human_input(
293
- "Please specify task modifications (JSON format):",
371
+ 'Please specify task modifications (JSON format):',
294
372
  type: :json,
295
- help_text: "Provide modifications as JSON, e.g. {\"description\": \"new description\", \"expected_output\": \"new output\"}"
373
+ help_text: 'Provide modifications as JSON, e.g. {"description": "new description", "expected_output": "new output"}'
296
374
  )
297
375
 
298
376
  if modification_input[:valid]
@@ -316,38 +394,38 @@ module RCrewAI
316
394
  when 'expected_output'
317
395
  @expected_output = value
318
396
  when 'max_retries'
319
- @max_retries = value.to_i if value.to_i > 0
397
+ @max_retries = value.to_i if value.to_i.positive?
320
398
  end
321
399
  end
322
400
  end
323
401
 
324
402
  def validate_dependencies!
325
- unless dependencies_met?
326
- incomplete_deps = context.reject(&:completed?).map(&:name)
327
- raise TaskDependencyError, "Dependencies not met: #{incomplete_deps.join(', ')}"
328
- end
403
+ return if dependencies_met?
404
+
405
+ incomplete_deps = context.reject(&:completed?).map(&:name)
406
+ raise TaskDependencyError, "Dependencies not met: #{incomplete_deps.join(', ')}"
329
407
  end
330
408
 
331
409
  def provide_context_to_agent
332
410
  # Temporarily add task-specific tools to agent
333
411
  original_tools = agent.tools.dup
334
412
  combined_tools = (agent.tools + @tools).uniq
335
-
413
+
336
414
  # Use metaprogramming to temporarily extend agent's tools
337
415
  agent.instance_variable_set(:@tools, combined_tools)
338
-
416
+
339
417
  # Restore original tools after execution (handled by ensure in execute method)
340
418
  at_exit { agent.instance_variable_set(:@tools, original_tools) }
341
419
  end
342
420
 
343
421
  class CLI < Thor
344
- desc "new NAME", "Create a new task"
422
+ desc 'new NAME', 'Create a new task'
345
423
  option :description, type: :string, required: true
346
424
  option :agent, type: :string
347
425
  option :expected_output, type: :string
348
426
  option :async, type: :boolean, default: false
349
427
  def new(name)
350
- task = Task.new(
428
+ Task.new(
351
429
  name: name,
352
430
  description: options[:description],
353
431
  agent: options[:agent],
@@ -361,16 +439,17 @@ module RCrewAI
361
439
  puts "Async: #{options[:async]}"
362
440
  end
363
441
 
364
- desc "list", "List all tasks"
442
+ desc 'list', 'List all tasks'
365
443
  def list
366
- puts "Available tasks:"
367
- puts " - research_topic (Agent: researcher)"
368
- puts " - write_article (Agent: writer)"
369
- puts " - analyze_data (Agent: analyst)"
444
+ puts 'Available tasks:'
445
+ puts ' - research_topic (Agent: researcher)'
446
+ puts ' - write_article (Agent: writer)'
447
+ puts ' - analyze_data (Agent: analyst)'
370
448
  end
371
449
  end
372
450
  end
373
451
 
374
452
  class TaskExecutionError < Error; end
375
453
  class TaskDependencyError < TaskExecutionError; end
376
- end
454
+ class OutputProcessingError < TaskExecutionError; end
455
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'events'
4
+ require_relative 'provider_schema'
5
+
6
+ module RCrewAI
7
+ class ToolRunner
8
+ DEFAULT_MAX_ITERATIONS = 10
9
+
10
+ def initialize(agent:, llm:, tools:, max_iterations: DEFAULT_MAX_ITERATIONS, event_sink: nil)
11
+ @agent = agent
12
+ @llm = llm
13
+ @tools = tools
14
+ @tools_by_name = tools.each_with_object({}) { |t, h| h[t.name] = t }
15
+ @max_iterations = max_iterations
16
+ @sink = event_sink || ->(_) {}
17
+ end
18
+
19
+ def run(messages:) # rubocop:disable Metrics/AbcSize
20
+ msgs = messages.dup
21
+ history = []
22
+ iter = 0
23
+ total_usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
24
+
25
+ while iter < @max_iterations
26
+ iter += 1
27
+ emit(Events::IterationStart, iteration: iter, iteration_index: iter)
28
+
29
+ response = @llm.chat(
30
+ messages: msgs,
31
+ tools: @tools.map(&:json_schema),
32
+ stream: ->(e) { @sink.call(retag(e, iter)) }
33
+ )
34
+ accumulate_usage(total_usage, response[:usage])
35
+
36
+ if response[:tool_calls].nil? || response[:tool_calls].empty?
37
+ emit(Events::IterationEnd, iteration: iter, finish_reason: response[:finish_reason])
38
+ return finalize(content: response[:content], history: history, iter: iter,
39
+ finish_reason: response[:finish_reason], usage: total_usage)
40
+ end
41
+
42
+ msgs << { role: 'assistant', content: response[:content], tool_calls: response[:tool_calls] }
43
+
44
+ response[:tool_calls].each do |tc|
45
+ tool = @tools_by_name[tc[:name]]
46
+ emit(Events::ToolCallStart, iteration: iter,
47
+ tool: tc[:name], args: tc[:arguments], call_id: tc[:id])
48
+
49
+ if tool.nil?
50
+ err = "tool not found: #{tc[:name]}"
51
+ emit(Events::ToolCallError, iteration: iter,
52
+ tool: tc[:name], call_id: tc[:id], error: err)
53
+ msgs << tool_result_message(tc[:id], "ERROR: #{err}")
54
+ next
55
+ end
56
+
57
+ started = monotonic_ms
58
+ begin
59
+ result = tool.execute_with_validation(tc[:arguments] || {})
60
+ duration = monotonic_ms - started
61
+ @agent.memory.add_tool_usage(tc[:name], tc[:arguments], result) if @agent.respond_to?(:memory) && @agent.memory
62
+ emit(Events::ToolCallResult, iteration: iter,
63
+ tool: tc[:name], call_id: tc[:id], result: result,
64
+ duration_ms: duration)
65
+ history << { tool: tc[:name], args: tc[:arguments], result: result, duration_ms: duration }
66
+ msgs << tool_result_message(tc[:id], result.to_s)
67
+ rescue StandardError => e
68
+ emit(Events::ToolCallError, iteration: iter,
69
+ tool: tc[:name], call_id: tc[:id], error: e.message)
70
+ msgs << tool_result_message(tc[:id], "ERROR: #{e.message}")
71
+ end
72
+ end
73
+
74
+ emit(Events::IterationEnd, iteration: iter, finish_reason: :tool_calls)
75
+ end
76
+
77
+ finalize(content: nil, history: history, iter: iter,
78
+ finish_reason: :max_iterations, usage: total_usage)
79
+ end
80
+
81
+ private
82
+
83
+ def tool_result_message(call_id, content)
84
+ { role: 'tool', tool_call_id: call_id, content: content }
85
+ end
86
+
87
+ def emit(klass, iteration:, **attrs)
88
+ type_sym = klass.name.split('::').last
89
+ .gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
90
+ .sub(/^_/, '').to_sym
91
+ @sink.call(klass.new(
92
+ type: type_sym,
93
+ timestamp: Time.now,
94
+ agent: agent_name,
95
+ iteration: iteration,
96
+ **attrs
97
+ ))
98
+ end
99
+
100
+ def agent_name
101
+ @agent.respond_to?(:name) ? @agent.name : nil
102
+ end
103
+
104
+ def retag(event, iter)
105
+ event.agent = agent_name if event.respond_to?(:agent=) && event.agent.nil?
106
+ event.iteration = iter if event.respond_to?(:iteration=) && event.iteration.nil?
107
+ event
108
+ end
109
+
110
+ def accumulate_usage(total, partial)
111
+ return unless partial.is_a?(Hash)
112
+
113
+ total[:prompt_tokens] += partial[:prompt_tokens] || 0
114
+ total[:completion_tokens] += partial[:completion_tokens] || 0
115
+ total[:total_tokens] += partial[:total_tokens] || 0
116
+ end
117
+
118
+ def finalize(content:, history:, iter:, finish_reason:, usage:)
119
+ {
120
+ content: content,
121
+ tool_calls_history: history,
122
+ usage: usage,
123
+ iterations: iter,
124
+ finish_reason: finish_reason
125
+ }
126
+ end
127
+
128
+ def monotonic_ms
129
+ (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) * 1000).to_i
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCrewAI
4
+ module ToolSchema
5
+ TYPE_MAP = {
6
+ string: 'string', integer: 'integer', number: 'number',
7
+ boolean: 'boolean', array: 'array', object: 'object', enum: 'string'
8
+ }.freeze
9
+
10
+ def self.extended(base)
11
+ base.instance_variable_set(:@params, [])
12
+ base.instance_variable_set(:@tool_name, nil)
13
+ base.instance_variable_set(:@description, nil)
14
+ end
15
+
16
+ def tool_name(name = nil)
17
+ return @tool_name || name_default if name.nil?
18
+
19
+ @tool_name = name.to_s
20
+ end
21
+
22
+ def description(desc = nil)
23
+ return @description || '' if desc.nil?
24
+
25
+ @description = desc.to_s
26
+ end
27
+
28
+ def param(name, type:, required: false, default: nil, description: nil, # rubocop:disable Metrics/ParameterLists
29
+ items: nil, values: nil, properties: nil)
30
+ @params ||= []
31
+ @params << {
32
+ name: name, type: type, required: required, default: default,
33
+ description: description, items: items, values: values, properties: properties
34
+ }
35
+ end
36
+
37
+ def params
38
+ @params || []
39
+ end
40
+
41
+ def json_schema
42
+ props = {}
43
+ required = []
44
+ params.each do |p|
45
+ entry = { type: TYPE_MAP.fetch(p[:type]) }
46
+ entry[:description] = p[:description] if p[:description]
47
+ entry[:default] = p[:default] unless p[:default].nil?
48
+ entry[:items] = stringify_type(p[:items]) if p[:items]
49
+ entry[:enum] = p[:values] if p[:type] == :enum
50
+ entry[:properties] = p[:properties] if p[:properties]
51
+ props[p[:name]] = entry
52
+ required << p[:name].to_s if p[:required]
53
+ end
54
+
55
+ if params.empty?
56
+ warn_once_no_dsl!
57
+ return {
58
+ name: tool_name,
59
+ description: description,
60
+ parameters: { type: 'object', additionalProperties: true }
61
+ }
62
+ end
63
+
64
+ {
65
+ name: tool_name,
66
+ description: description,
67
+ parameters: {
68
+ type: 'object',
69
+ properties: props,
70
+ required: required
71
+ }
72
+ }
73
+ end
74
+
75
+ private
76
+
77
+ def name_default
78
+ raw = name.to_s.split('::').last
79
+ return '' if raw.nil? || raw.empty?
80
+
81
+ raw.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
82
+ end
83
+
84
+ def stringify_type(hash)
85
+ return hash unless hash.is_a?(Hash) && hash[:type].is_a?(Symbol)
86
+
87
+ hash.merge(type: TYPE_MAP.fetch(hash[:type]))
88
+ end
89
+
90
+ def warn_once_no_dsl!
91
+ return if @warned_no_dsl
92
+
93
+ @warned_no_dsl = true
94
+ Kernel.warn "[rcrewai] Tool #{name} has no DSL declarations; using permissive schema. Declare tool_name/description/param to opt in to a strict JSON schema."
95
+ end
96
+ end
97
+ end