rcrewai 0.1.0 → 0.2.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.
@@ -0,0 +1,1242 @@
1
+ ---
2
+ layout: tutorial
3
+ title: Custom Tools Development
4
+ description: Learn how to build custom tools to extend agent capabilities for specialized tasks
5
+ ---
6
+
7
+ # Custom Tools Development
8
+
9
+ This tutorial teaches you how to create custom tools that extend agent capabilities beyond the built-in tools. You'll learn tool architecture, implementation patterns, testing strategies, and best practices.
10
+
11
+ ## Table of Contents
12
+ 1. [Understanding Tool Architecture](#understanding-tool-architecture)
13
+ 2. [Creating Basic Custom Tools](#creating-basic-custom-tools)
14
+ 3. [Advanced Tool Features](#advanced-tool-features)
15
+ 4. [API Integration Tools](#api-integration-tools)
16
+ 5. [Database Tools](#database-tools)
17
+ 6. [File Processing Tools](#file-processing-tools)
18
+ 7. [Testing Custom Tools](#testing-custom-tools)
19
+ 8. [Tool Security and Validation](#tool-security-and-validation)
20
+
21
+ ## Understanding Tool Architecture
22
+
23
+ ### Tool Base Class
24
+
25
+ All tools in RCrewAI inherit from the base Tool class:
26
+
27
+ ```ruby
28
+ module RCrewAI
29
+ module Tools
30
+ class Base
31
+ attr_reader :name, :description
32
+
33
+ def initialize(**options)
34
+ @name = self.class.name.split('::').last.downcase
35
+ @description = "Base tool description"
36
+ @options = options
37
+ @logger = Logger.new($stdout)
38
+ end
39
+
40
+ def execute(**params)
41
+ raise NotImplementedError, "Subclasses must implement execute method"
42
+ end
43
+
44
+ def validate_params!(params, required: [], optional: [])
45
+ # Built-in parameter validation
46
+ required.each do |param|
47
+ unless params.key?(param)
48
+ raise ToolError, "Missing required parameter: #{param}"
49
+ end
50
+ end
51
+
52
+ # Check for unknown parameters
53
+ all_params = required + optional
54
+ params.keys.each do |key|
55
+ unless all_params.include?(key)
56
+ raise ToolError, "Unknown parameter: #{key}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ ```
64
+
65
+ ### Tool Lifecycle
66
+
67
+ 1. **Initialization**: Tool is created with configuration
68
+ 2. **Validation**: Parameters are validated before execution
69
+ 3. **Execution**: Tool performs its function
70
+ 4. **Result Formatting**: Output is formatted for agent consumption
71
+ 5. **Error Handling**: Exceptions are caught and handled
72
+
73
+ ## Creating Basic Custom Tools
74
+
75
+ ### Simple Calculator Tool
76
+
77
+ ```ruby
78
+ class CalculatorTool < RCrewAI::Tools::Base
79
+ def initialize(**options)
80
+ super
81
+ @name = 'calculator'
82
+ @description = 'Performs mathematical calculations'
83
+ @precision = options[:precision] || 2
84
+ end
85
+
86
+ def execute(**params)
87
+ validate_params!(params, required: [:operation, :operands])
88
+
89
+ operation = params[:operation].to_s.downcase
90
+ operands = params[:operands]
91
+
92
+ # Validate operands
93
+ unless operands.is_a?(Array) && operands.all? { |o| o.is_a?(Numeric) }
94
+ raise ToolError, "Operands must be an array of numbers"
95
+ end
96
+
97
+ result = case operation
98
+ when 'add', '+'
99
+ operands.sum
100
+ when 'subtract', '-'
101
+ operands.reduce(&:-)
102
+ when 'multiply', '*'
103
+ operands.reduce(&:*)
104
+ when 'divide', '/'
105
+ divide_with_check(operands)
106
+ when 'power', '^'
107
+ operands[0] ** operands[1]
108
+ when 'sqrt'
109
+ Math.sqrt(operands[0])
110
+ when 'average'
111
+ operands.sum.to_f / operands.length
112
+ else
113
+ raise ToolError, "Unknown operation: #{operation}"
114
+ end
115
+
116
+ format_result(result)
117
+ end
118
+
119
+ private
120
+
121
+ def divide_with_check(operands)
122
+ if operands[1..-1].any? { |o| o == 0 }
123
+ raise ToolError, "Division by zero"
124
+ end
125
+ operands.reduce(&:/)
126
+ end
127
+
128
+ def format_result(result)
129
+ if result.is_a?(Float)
130
+ "Result: #{result.round(@precision)}"
131
+ else
132
+ "Result: #{result}"
133
+ end
134
+ end
135
+ end
136
+
137
+ # Usage with an agent
138
+ calculator = CalculatorTool.new(precision: 4)
139
+
140
+ agent = RCrewAI::Agent.new(
141
+ name: "math_agent",
142
+ role: "Mathematics Specialist",
143
+ goal: "Solve mathematical problems",
144
+ tools: [calculator]
145
+ )
146
+
147
+ # Agent can now use: USE_TOOL[calculator](operation=multiply, operands=[5, 3, 2])
148
+ ```
149
+
150
+ ### Weather Information Tool
151
+
152
+ ```ruby
153
+ require 'net/http'
154
+ require 'json'
155
+
156
+ class WeatherTool < RCrewAI::Tools::Base
157
+ def initialize(**options)
158
+ super
159
+ @name = 'weather'
160
+ @description = 'Get current weather information for any location'
161
+ @api_key = options[:api_key] || ENV['WEATHER_API_KEY']
162
+ @base_url = 'https://api.openweathermap.org/data/2.5'
163
+ @units = options[:units] || 'metric' # metric, imperial, kelvin
164
+ end
165
+
166
+ def execute(**params)
167
+ validate_params!(params, required: [:location], optional: [:forecast])
168
+
169
+ location = params[:location]
170
+ forecast = params[:forecast] || false
171
+
172
+ begin
173
+ if forecast
174
+ get_forecast(location)
175
+ else
176
+ get_current_weather(location)
177
+ end
178
+ rescue => e
179
+ "Weather service error: #{e.message}"
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ def get_current_weather(location)
186
+ endpoint = "#{@base_url}/weather"
187
+ params = {
188
+ q: location,
189
+ appid: @api_key,
190
+ units: @units
191
+ }
192
+
193
+ response = make_api_request(endpoint, params)
194
+ format_weather_response(response)
195
+ end
196
+
197
+ def get_forecast(location)
198
+ endpoint = "#{@base_url}/forecast"
199
+ params = {
200
+ q: location,
201
+ appid: @api_key,
202
+ units: @units,
203
+ cnt: 5 # 5 day forecast
204
+ }
205
+
206
+ response = make_api_request(endpoint, params)
207
+ format_forecast_response(response)
208
+ end
209
+
210
+ def make_api_request(endpoint, params)
211
+ uri = URI(endpoint)
212
+ uri.query = URI.encode_www_form(params)
213
+
214
+ http = Net::HTTP.new(uri.host, uri.port)
215
+ http.use_ssl = true
216
+ http.read_timeout = 10
217
+
218
+ request = Net::HTTP::Get.new(uri)
219
+ response = http.request(request)
220
+
221
+ if response.code == '200'
222
+ JSON.parse(response.body)
223
+ else
224
+ raise "API error: #{response.code} - #{response.body}"
225
+ end
226
+ end
227
+
228
+ def format_weather_response(data)
229
+ temp = data['main']['temp']
230
+ feels_like = data['main']['feels_like']
231
+ description = data['weather'][0]['description']
232
+ humidity = data['main']['humidity']
233
+ wind_speed = data['wind']['speed']
234
+
235
+ unit_label = @units == 'metric' ? '°C' : '°F'
236
+
237
+ <<~WEATHER
238
+ Weather in #{data['name']}, #{data['sys']['country']}:
239
+ Temperature: #{temp}#{unit_label} (feels like #{feels_like}#{unit_label})
240
+ Conditions: #{description}
241
+ Humidity: #{humidity}%
242
+ Wind Speed: #{wind_speed} #{@units == 'metric' ? 'm/s' : 'mph'}
243
+ WEATHER
244
+ end
245
+
246
+ def format_forecast_response(data)
247
+ forecasts = data['list'].map do |item|
248
+ time = Time.at(item['dt']).strftime('%Y-%m-%d %H:%M')
249
+ temp = item['main']['temp']
250
+ desc = item['weather'][0]['description']
251
+ "#{time}: #{temp}°, #{desc}"
252
+ end
253
+
254
+ "5-Day Forecast for #{data['city']['name']}:\n" + forecasts.join("\n")
255
+ end
256
+ end
257
+ ```
258
+
259
+ ## Advanced Tool Features
260
+
261
+ ### Tool with State Management
262
+
263
+ ```ruby
264
+ class SessionMemoryTool < RCrewAI::Tools::Base
265
+ def initialize(**options)
266
+ super
267
+ @name = 'session_memory'
268
+ @description = 'Store and retrieve information during agent session'
269
+ @memory_store = {}
270
+ @max_entries = options[:max_entries] || 100
271
+ @ttl = options[:ttl] || 3600 # 1 hour default
272
+ end
273
+
274
+ def execute(**params)
275
+ validate_params!(params,
276
+ required: [:action],
277
+ optional: [:key, :value, :pattern]
278
+ )
279
+
280
+ action = params[:action].to_sym
281
+
282
+ case action
283
+ when :store
284
+ store_value(params[:key], params[:value])
285
+ when :retrieve
286
+ retrieve_value(params[:key])
287
+ when :delete
288
+ delete_value(params[:key])
289
+ when :list
290
+ list_keys(params[:pattern])
291
+ when :clear
292
+ clear_all
293
+ else
294
+ raise ToolError, "Unknown action: #{action}"
295
+ end
296
+ end
297
+
298
+ private
299
+
300
+ def store_value(key, value)
301
+ raise ToolError, "Key and value required for store action" unless key && value
302
+
303
+ # Enforce size limit
304
+ if @memory_store.size >= @max_entries && !@memory_store.key?(key)
305
+ evict_oldest
306
+ end
307
+
308
+ @memory_store[key] = {
309
+ value: value,
310
+ timestamp: Time.now,
311
+ access_count: 0
312
+ }
313
+
314
+ "Stored: #{key} = #{value}"
315
+ end
316
+
317
+ def retrieve_value(key)
318
+ raise ToolError, "Key required for retrieve action" unless key
319
+
320
+ if entry = @memory_store[key]
321
+ # Check TTL
322
+ if Time.now - entry[:timestamp] > @ttl
323
+ @memory_store.delete(key)
324
+ return "Key expired: #{key}"
325
+ end
326
+
327
+ entry[:access_count] += 1
328
+ entry[:last_accessed] = Time.now
329
+
330
+ "Retrieved: #{key} = #{entry[:value]} (accessed #{entry[:access_count]} times)"
331
+ else
332
+ "Key not found: #{key}"
333
+ end
334
+ end
335
+
336
+ def delete_value(key)
337
+ if @memory_store.delete(key)
338
+ "Deleted: #{key}"
339
+ else
340
+ "Key not found: #{key}"
341
+ end
342
+ end
343
+
344
+ def list_keys(pattern = nil)
345
+ keys = @memory_store.keys
346
+
347
+ if pattern
348
+ regex = Regexp.new(pattern)
349
+ keys = keys.select { |k| k.match?(regex) }
350
+ end
351
+
352
+ "Keys (#{keys.length}): #{keys.join(', ')}"
353
+ end
354
+
355
+ def clear_all
356
+ count = @memory_store.size
357
+ @memory_store.clear
358
+ "Cleared #{count} entries"
359
+ end
360
+
361
+ def evict_oldest
362
+ oldest = @memory_store.min_by { |k, v| v[:last_accessed] || v[:timestamp] }
363
+ @memory_store.delete(oldest[0]) if oldest
364
+ end
365
+ end
366
+ ```
367
+
368
+ ### Async Tool with Callbacks
369
+
370
+ ```ruby
371
+ class AsyncProcessingTool < RCrewAI::Tools::Base
372
+ def initialize(**options)
373
+ super
374
+ @name = 'async_processor'
375
+ @description = 'Process tasks asynchronously with progress tracking'
376
+ @callback = options[:callback]
377
+ @thread_pool = []
378
+ @max_threads = options[:max_threads] || 5
379
+ end
380
+
381
+ def execute(**params)
382
+ validate_params!(params,
383
+ required: [:task_type, :data],
384
+ optional: [:priority, :timeout]
385
+ )
386
+
387
+ task_id = SecureRandom.uuid
388
+ priority = params[:priority] || :normal
389
+ timeout = params[:timeout] || 60
390
+
391
+ # Start async processing
392
+ thread = Thread.new do
393
+ begin
394
+ process_async(task_id, params[:task_type], params[:data], timeout)
395
+ rescue => e
396
+ handle_async_error(task_id, e)
397
+ end
398
+ end
399
+
400
+ # Manage thread pool
401
+ @thread_pool << thread
402
+ cleanup_threads
403
+
404
+ "Task queued: #{task_id} (priority: #{priority})"
405
+ end
406
+
407
+ def get_status(task_id)
408
+ # Check task status
409
+ if result = check_result(task_id)
410
+ "Task #{task_id}: #{result[:status]} - #{result[:message]}"
411
+ else
412
+ "Task #{task_id}: Unknown or not started"
413
+ end
414
+ end
415
+
416
+ private
417
+
418
+ def process_async(task_id, task_type, data, timeout)
419
+ update_status(task_id, :processing, "Started at #{Time.now}")
420
+
421
+ result = Timeout::timeout(timeout) do
422
+ case task_type
423
+ when 'analysis'
424
+ perform_analysis(data)
425
+ when 'transformation'
426
+ perform_transformation(data)
427
+ when 'validation'
428
+ perform_validation(data)
429
+ else
430
+ raise "Unknown task type: #{task_type}"
431
+ end
432
+ end
433
+
434
+ update_status(task_id, :completed, result)
435
+
436
+ # Execute callback if provided
437
+ @callback.call(task_id, result) if @callback
438
+
439
+ rescue Timeout::Error
440
+ update_status(task_id, :timeout, "Task exceeded #{timeout}s limit")
441
+ end
442
+
443
+ def cleanup_threads
444
+ @thread_pool.reject!(&:alive?)
445
+
446
+ # Limit thread pool size
447
+ while @thread_pool.size > @max_threads
448
+ oldest = @thread_pool.shift
449
+ oldest.join(1) # Wait 1 second then continue
450
+ end
451
+ end
452
+
453
+ def update_status(task_id, status, message)
454
+ @status_store ||= {}
455
+ @status_store[task_id] = {
456
+ status: status,
457
+ message: message,
458
+ timestamp: Time.now
459
+ }
460
+ end
461
+
462
+ def check_result(task_id)
463
+ @status_store&.[](task_id)
464
+ end
465
+ end
466
+ ```
467
+
468
+ ## API Integration Tools
469
+
470
+ ### REST API Client Tool
471
+
472
+ ```ruby
473
+ require 'faraday'
474
+ require 'json'
475
+
476
+ class RestApiTool < RCrewAI::Tools::Base
477
+ def initialize(**options)
478
+ super
479
+ @name = 'rest_api'
480
+ @description = 'Make REST API calls with authentication and error handling'
481
+ @base_url = options[:base_url]
482
+ @api_key = options[:api_key]
483
+ @auth_type = options[:auth_type] || :header # :header, :query, :basic
484
+ @timeout = options[:timeout] || 30
485
+
486
+ setup_client
487
+ end
488
+
489
+ def execute(**params)
490
+ validate_params!(params,
491
+ required: [:method, :endpoint],
492
+ optional: [:data, :headers, :query]
493
+ )
494
+
495
+ method = params[:method].to_s.downcase.to_sym
496
+ endpoint = params[:endpoint]
497
+ data = params[:data]
498
+ headers = params[:headers] || {}
499
+ query = params[:query] || {}
500
+
501
+ # Add authentication
502
+ headers, query = add_authentication(headers, query)
503
+
504
+ # Make request
505
+ response = make_request(method, endpoint, data, headers, query)
506
+
507
+ # Format response
508
+ format_api_response(response)
509
+ rescue Faraday::Error => e
510
+ handle_api_error(e)
511
+ end
512
+
513
+ private
514
+
515
+ def setup_client
516
+ @client = Faraday.new(url: @base_url) do |f|
517
+ f.request :json
518
+ f.response :json
519
+ f.adapter Faraday.default_adapter
520
+ f.options.timeout = @timeout
521
+
522
+ # Add middleware for logging
523
+ f.response :logger if @options[:debug]
524
+
525
+ # Add retry logic
526
+ f.request :retry, {
527
+ max: 3,
528
+ interval: 0.5,
529
+ interval_randomness: 0.5,
530
+ backoff_factor: 2
531
+ }
532
+ end
533
+ end
534
+
535
+ def add_authentication(headers, query)
536
+ case @auth_type
537
+ when :header
538
+ headers['Authorization'] = "Bearer #{@api_key}" if @api_key
539
+ when :query
540
+ query['api_key'] = @api_key if @api_key
541
+ when :basic
542
+ headers['Authorization'] = "Basic #{Base64.encode64(@api_key)}" if @api_key
543
+ end
544
+
545
+ [headers, query]
546
+ end
547
+
548
+ def make_request(method, endpoint, data, headers, query)
549
+ case method
550
+ when :get
551
+ @client.get(endpoint, query, headers)
552
+ when :post
553
+ @client.post(endpoint, data, headers) do |req|
554
+ req.params = query
555
+ end
556
+ when :put
557
+ @client.put(endpoint, data, headers) do |req|
558
+ req.params = query
559
+ end
560
+ when :patch
561
+ @client.patch(endpoint, data, headers) do |req|
562
+ req.params = query
563
+ end
564
+ when :delete
565
+ @client.delete(endpoint, query, headers)
566
+ else
567
+ raise ToolError, "Unsupported HTTP method: #{method}"
568
+ end
569
+ end
570
+
571
+ def format_api_response(response)
572
+ status = response.status
573
+ body = response.body
574
+
575
+ if status >= 200 && status < 300
576
+ if body.is_a?(Hash) || body.is_a?(Array)
577
+ JSON.pretty_generate(body)
578
+ else
579
+ body.to_s
580
+ end
581
+ else
582
+ "API Error (#{status}): #{body}"
583
+ end
584
+ end
585
+
586
+ def handle_api_error(error)
587
+ case error
588
+ when Faraday::TimeoutError
589
+ "API request timed out after #{@timeout} seconds"
590
+ when Faraday::ConnectionFailed
591
+ "Failed to connect to API: #{error.message}"
592
+ else
593
+ "API error: #{error.class} - #{error.message}"
594
+ end
595
+ end
596
+ end
597
+
598
+ # Usage example
599
+ github_api = RestApiTool.new(
600
+ base_url: 'https://api.github.com',
601
+ api_key: ENV['GITHUB_TOKEN'],
602
+ auth_type: :header
603
+ )
604
+
605
+ # Agent can use: USE_TOOL[rest_api](method=get, endpoint=/user/repos, query={per_page: 10})
606
+ ```
607
+
608
+ ### GraphQL Client Tool
609
+
610
+ ```ruby
611
+ class GraphQLTool < RCrewAI::Tools::Base
612
+ def initialize(**options)
613
+ super
614
+ @name = 'graphql'
615
+ @description = 'Execute GraphQL queries and mutations'
616
+ @endpoint = options[:endpoint]
617
+ @api_key = options[:api_key]
618
+ @client = setup_graphql_client
619
+ end
620
+
621
+ def execute(**params)
622
+ validate_params!(params,
623
+ required: [:query],
624
+ optional: [:variables, :operation_name]
625
+ )
626
+
627
+ query = params[:query]
628
+ variables = params[:variables] || {}
629
+ operation_name = params[:operation_name]
630
+
631
+ response = @client.execute(
632
+ query,
633
+ variables: variables,
634
+ operation_name: operation_name
635
+ )
636
+
637
+ format_graphql_response(response)
638
+ rescue => e
639
+ "GraphQL error: #{e.message}"
640
+ end
641
+
642
+ private
643
+
644
+ def setup_graphql_client
645
+ Faraday.new(url: @endpoint) do |f|
646
+ f.request :json
647
+ f.response :json
648
+ f.adapter Faraday.default_adapter
649
+
650
+ # Add authentication
651
+ f.headers['Authorization'] = "Bearer #{@api_key}" if @api_key
652
+ end
653
+ end
654
+
655
+ def format_graphql_response(response)
656
+ if response['errors']
657
+ errors = response['errors'].map { |e| e['message'] }.join(', ')
658
+ "GraphQL errors: #{errors}"
659
+ elsif response['data']
660
+ JSON.pretty_generate(response['data'])
661
+ else
662
+ "Empty response"
663
+ end
664
+ end
665
+ end
666
+ ```
667
+
668
+ ## Database Tools
669
+
670
+ ### SQL Database Tool
671
+
672
+ ```ruby
673
+ require 'sequel'
674
+
675
+ class SqlDatabaseTool < RCrewAI::Tools::Base
676
+ def initialize(**options)
677
+ super
678
+ @name = 'sql_database'
679
+ @description = 'Execute SQL queries with safety checks'
680
+ @connection_string = options[:connection_string]
681
+ @read_only = options[:read_only] || true
682
+ @max_rows = options[:max_rows] || 100
683
+ @timeout = options[:timeout] || 30
684
+
685
+ setup_connection
686
+ end
687
+
688
+ def execute(**params)
689
+ validate_params!(params, required: [:query], optional: [:params])
690
+
691
+ query = params[:query]
692
+ query_params = params[:params] || []
693
+
694
+ # Safety checks
695
+ validate_query_safety(query) if @read_only
696
+
697
+ # Execute query
698
+ result = execute_query(query, query_params)
699
+
700
+ # Format result
701
+ format_query_result(result)
702
+ rescue => e
703
+ "Database error: #{e.message}"
704
+ end
705
+
706
+ private
707
+
708
+ def setup_connection
709
+ @db = Sequel.connect(@connection_string)
710
+ @db.extension :pg_json if @connection_string.include?('postgres')
711
+
712
+ # Set connection options
713
+ @db.pool.connection_validation_timeout = -1
714
+ @db.pool.max_connections = 5
715
+ end
716
+
717
+ def validate_query_safety(query)
718
+ unsafe_keywords = %w[
719
+ INSERT UPDATE DELETE DROP CREATE ALTER TRUNCATE
720
+ EXEC EXECUTE GRANT REVOKE
721
+ ]
722
+
723
+ query_upper = query.upcase
724
+ unsafe_keywords.each do |keyword|
725
+ if query_upper.include?(keyword)
726
+ raise ToolError, "Unsafe operation '#{keyword}' not allowed in read-only mode"
727
+ end
728
+ end
729
+ end
730
+
731
+ def execute_query(query, params)
732
+ Timeout::timeout(@timeout) do
733
+ dataset = @db[query, *params]
734
+
735
+ # Limit results
736
+ dataset = dataset.limit(@max_rows) if dataset.respond_to?(:limit)
737
+
738
+ # Execute and fetch
739
+ if query.upcase.start_with?('SELECT')
740
+ dataset.all
741
+ else
742
+ rows_affected = dataset
743
+ { rows_affected: rows_affected }
744
+ end
745
+ end
746
+ end
747
+
748
+ def format_query_result(result)
749
+ if result.is_a?(Array)
750
+ # Format as table
751
+ return "No results" if result.empty?
752
+
753
+ headers = result.first.keys
754
+ rows = result.map { |r| r.values }
755
+
756
+ format_table(headers, rows)
757
+ elsif result.is_a?(Hash)
758
+ "Query executed: #{result[:rows_affected]} rows affected"
759
+ else
760
+ result.to_s
761
+ end
762
+ end
763
+
764
+ def format_table(headers, rows)
765
+ # Calculate column widths
766
+ widths = headers.map(&:to_s).map(&:length)
767
+ rows.each do |row|
768
+ row.each_with_index do |cell, i|
769
+ widths[i] = [widths[i], cell.to_s.length].max
770
+ end
771
+ end
772
+
773
+ # Build table
774
+ separator = "+" + widths.map { |w| "-" * (w + 2) }.join("+") + "+"
775
+ header_row = "|" + headers.each_with_index.map { |h, i|
776
+ " #{h.to_s.ljust(widths[i])} "
777
+ }.join("|") + "|"
778
+
779
+ table = [separator, header_row, separator]
780
+
781
+ rows.each do |row|
782
+ row_str = "|" + row.each_with_index.map { |cell, i|
783
+ " #{cell.to_s.ljust(widths[i])} "
784
+ }.join("|") + "|"
785
+ table << row_str
786
+ end
787
+
788
+ table << separator
789
+ table.join("\n")
790
+ end
791
+ end
792
+ ```
793
+
794
+ ## File Processing Tools
795
+
796
+ ### Document Processor Tool
797
+
798
+ ```ruby
799
+ require 'pdf-reader'
800
+ require 'docx'
801
+ require 'csv'
802
+
803
+ class DocumentProcessorTool < RCrewAI::Tools::Base
804
+ SUPPORTED_FORMATS = %w[.pdf .docx .txt .csv .json]
805
+
806
+ def initialize(**options)
807
+ super
808
+ @name = 'document_processor'
809
+ @description = 'Extract and process content from various document formats'
810
+ @max_file_size = options[:max_file_size] || 10_000_000 # 10MB
811
+ end
812
+
813
+ def execute(**params)
814
+ validate_params!(params,
815
+ required: [:file_path],
816
+ optional: [:operation, :options]
817
+ )
818
+
819
+ file_path = params[:file_path]
820
+ operation = params[:operation] || :extract_text
821
+ options = params[:options] || {}
822
+
823
+ # Validate file
824
+ validate_file(file_path)
825
+
826
+ # Process based on operation
827
+ case operation.to_sym
828
+ when :extract_text
829
+ extract_text(file_path, options)
830
+ when :extract_metadata
831
+ extract_metadata(file_path)
832
+ when :convert
833
+ convert_document(file_path, options)
834
+ when :analyze
835
+ analyze_document(file_path, options)
836
+ else
837
+ raise ToolError, "Unknown operation: #{operation}"
838
+ end
839
+ end
840
+
841
+ private
842
+
843
+ def validate_file(file_path)
844
+ unless File.exist?(file_path)
845
+ raise ToolError, "File not found: #{file_path}"
846
+ end
847
+
848
+ if File.size(file_path) > @max_file_size
849
+ raise ToolError, "File too large: #{File.size(file_path)} bytes"
850
+ end
851
+
852
+ ext = File.extname(file_path).downcase
853
+ unless SUPPORTED_FORMATS.include?(ext)
854
+ raise ToolError, "Unsupported format: #{ext}"
855
+ end
856
+ end
857
+
858
+ def extract_text(file_path, options)
859
+ ext = File.extname(file_path).downcase
860
+
861
+ text = case ext
862
+ when '.pdf'
863
+ extract_pdf_text(file_path, options)
864
+ when '.docx'
865
+ extract_docx_text(file_path)
866
+ when '.txt'
867
+ File.read(file_path)
868
+ when '.csv'
869
+ extract_csv_text(file_path, options)
870
+ when '.json'
871
+ JSON.pretty_generate(JSON.parse(File.read(file_path)))
872
+ end
873
+
874
+ # Apply options
875
+ if options[:max_length]
876
+ text = text[0...options[:max_length]]
877
+ end
878
+
879
+ if options[:clean]
880
+ text = clean_text(text)
881
+ end
882
+
883
+ text
884
+ end
885
+
886
+ def extract_pdf_text(file_path, options)
887
+ reader = PDF::Reader.new(file_path)
888
+
889
+ if options[:page]
890
+ # Extract specific page
891
+ page = reader.pages[options[:page] - 1]
892
+ page&.text || ""
893
+ else
894
+ # Extract all pages
895
+ reader.pages.map(&:text).join("\n\n")
896
+ end
897
+ end
898
+
899
+ def extract_docx_text(file_path)
900
+ doc = Docx::Document.open(file_path)
901
+ doc.paragraphs.map(&:text).join("\n")
902
+ end
903
+
904
+ def extract_csv_text(file_path, options)
905
+ csv_options = {
906
+ headers: options[:headers] != false,
907
+ encoding: options[:encoding] || 'UTF-8'
908
+ }
909
+
910
+ rows = CSV.read(file_path, csv_options)
911
+
912
+ if options[:as_json]
913
+ JSON.pretty_generate(rows.map(&:to_h))
914
+ else
915
+ CSV.generate do |csv|
916
+ rows.each { |row| csv << row }
917
+ end
918
+ end
919
+ end
920
+
921
+ def extract_metadata(file_path)
922
+ metadata = {
923
+ filename: File.basename(file_path),
924
+ size: File.size(file_path),
925
+ modified: File.mtime(file_path),
926
+ format: File.extname(file_path)
927
+ }
928
+
929
+ ext = File.extname(file_path).downcase
930
+
931
+ case ext
932
+ when '.pdf'
933
+ reader = PDF::Reader.new(file_path)
934
+ metadata[:pages] = reader.page_count
935
+ metadata[:info] = reader.info
936
+ when '.docx'
937
+ doc = Docx::Document.open(file_path)
938
+ metadata[:paragraphs] = doc.paragraphs.count
939
+ metadata[:tables] = doc.tables.count
940
+ end
941
+
942
+ JSON.pretty_generate(metadata)
943
+ end
944
+
945
+ def analyze_document(file_path, options)
946
+ text = extract_text(file_path, {})
947
+
948
+ analysis = {
949
+ character_count: text.length,
950
+ word_count: text.split.length,
951
+ line_count: text.lines.count,
952
+ paragraph_count: text.split(/\n\n+/).length
953
+ }
954
+
955
+ if options[:keywords]
956
+ keywords = options[:keywords]
957
+ analysis[:keyword_frequency] = {}
958
+
959
+ keywords.each do |keyword|
960
+ count = text.scan(/#{Regexp.escape(keyword)}/i).length
961
+ analysis[:keyword_frequency][keyword] = count
962
+ end
963
+ end
964
+
965
+ JSON.pretty_generate(analysis)
966
+ end
967
+
968
+ def clean_text(text)
969
+ # Remove extra whitespace
970
+ text = text.gsub(/\s+/, ' ')
971
+
972
+ # Remove special characters
973
+ text = text.gsub(/[^\w\s\.\,\!\?\-]/, '')
974
+
975
+ # Normalize line endings
976
+ text = text.gsub(/\r\n/, "\n")
977
+
978
+ text.strip
979
+ end
980
+ end
981
+ ```
982
+
983
+ ## Testing Custom Tools
984
+
985
+ ### RSpec Tests for Tools
986
+
987
+ ```ruby
988
+ require 'rspec'
989
+
990
+ RSpec.describe CalculatorTool do
991
+ let(:tool) { CalculatorTool.new(precision: 2) }
992
+
993
+ describe '#execute' do
994
+ context 'with addition' do
995
+ it 'adds numbers correctly' do
996
+ result = tool.execute(
997
+ operation: 'add',
998
+ operands: [5, 3, 2]
999
+ )
1000
+ expect(result).to eq('Result: 10')
1001
+ end
1002
+ end
1003
+
1004
+ context 'with division' do
1005
+ it 'divides numbers correctly' do
1006
+ result = tool.execute(
1007
+ operation: 'divide',
1008
+ operands: [10, 2]
1009
+ )
1010
+ expect(result).to eq('Result: 5')
1011
+ end
1012
+
1013
+ it 'raises error for division by zero' do
1014
+ expect {
1015
+ tool.execute(
1016
+ operation: 'divide',
1017
+ operands: [10, 0]
1018
+ )
1019
+ }.to raise_error(RCrewAI::Tools::ToolError, /Division by zero/)
1020
+ end
1021
+ end
1022
+
1023
+ context 'with invalid parameters' do
1024
+ it 'raises error for missing required parameters' do
1025
+ expect {
1026
+ tool.execute(operation: 'add')
1027
+ }.to raise_error(RCrewAI::Tools::ToolError, /Missing required parameter/)
1028
+ end
1029
+
1030
+ it 'raises error for invalid operands' do
1031
+ expect {
1032
+ tool.execute(
1033
+ operation: 'add',
1034
+ operands: 'not an array'
1035
+ )
1036
+ }.to raise_error(RCrewAI::Tools::ToolError, /must be an array/)
1037
+ end
1038
+ end
1039
+ end
1040
+ end
1041
+ ```
1042
+
1043
+ ### Integration Testing
1044
+
1045
+ ```ruby
1046
+ RSpec.describe 'Tool Integration' do
1047
+ let(:agent) do
1048
+ RCrewAI::Agent.new(
1049
+ name: 'test_agent',
1050
+ role: 'Tool Tester',
1051
+ goal: 'Test tool integration',
1052
+ tools: [
1053
+ CalculatorTool.new,
1054
+ WeatherTool.new(api_key: 'test_key'),
1055
+ SessionMemoryTool.new
1056
+ ]
1057
+ )
1058
+ end
1059
+
1060
+ it 'agent can use multiple tools in sequence' do
1061
+ task = RCrewAI::Task.new(
1062
+ name: 'complex_calculation',
1063
+ description: 'Calculate 5 * 3, store result, then add 10',
1064
+ agent: agent
1065
+ )
1066
+
1067
+ # Mock the reasoning loop to use tools
1068
+ allow(agent).to receive(:reasoning_loop) do |task, context|
1069
+ # Step 1: Multiply
1070
+ result1 = agent.use_tool('calculator',
1071
+ operation: 'multiply',
1072
+ operands: [5, 3]
1073
+ )
1074
+
1075
+ # Step 2: Store result
1076
+ agent.use_tool('session_memory',
1077
+ action: 'store',
1078
+ key: 'multiply_result',
1079
+ value: 15
1080
+ )
1081
+
1082
+ # Step 3: Add
1083
+ result2 = agent.use_tool('calculator',
1084
+ operation: 'add',
1085
+ operands: [15, 10]
1086
+ )
1087
+
1088
+ "Final result: 25"
1089
+ end
1090
+
1091
+ result = task.execute
1092
+ expect(result).to include('25')
1093
+ end
1094
+ end
1095
+ ```
1096
+
1097
+ ## Tool Security and Validation
1098
+
1099
+ ### Secure Tool Base Class
1100
+
1101
+ ```ruby
1102
+ class SecureTool < RCrewAI::Tools::Base
1103
+ def execute(**params)
1104
+ # Input sanitization
1105
+ sanitized_params = sanitize_inputs(params)
1106
+
1107
+ # Rate limiting
1108
+ check_rate_limit
1109
+
1110
+ # Execute with timeout
1111
+ Timeout::timeout(execution_timeout) do
1112
+ # Audit logging
1113
+ log_execution(sanitized_params)
1114
+
1115
+ # Execute tool logic
1116
+ result = perform_execution(sanitized_params)
1117
+
1118
+ # Output validation
1119
+ validate_output(result)
1120
+
1121
+ result
1122
+ end
1123
+ rescue Timeout::Error
1124
+ handle_timeout
1125
+ rescue => e
1126
+ handle_error(e)
1127
+ end
1128
+
1129
+ private
1130
+
1131
+ def sanitize_inputs(params)
1132
+ params.transform_values do |value|
1133
+ case value
1134
+ when String
1135
+ # Remove potentially dangerous characters
1136
+ value.gsub(/[<>'\"&]/, '')
1137
+ when Array
1138
+ value.map { |v| sanitize_inputs(v) if v.is_a?(String) || v.is_a?(Hash) }
1139
+ when Hash
1140
+ sanitize_inputs(value)
1141
+ else
1142
+ value
1143
+ end
1144
+ end
1145
+ end
1146
+
1147
+ def check_rate_limit
1148
+ @last_execution ||= {}
1149
+ key = "#{self.class.name}_#{Thread.current.object_id}"
1150
+
1151
+ if @last_execution[key] && Time.now - @last_execution[key] < 1
1152
+ raise ToolError, "Rate limit exceeded - please wait"
1153
+ end
1154
+
1155
+ @last_execution[key] = Time.now
1156
+ end
1157
+
1158
+ def validate_output(result)
1159
+ # Check output size
1160
+ if result.to_s.length > 1_000_000
1161
+ raise ToolError, "Output too large"
1162
+ end
1163
+
1164
+ # Check for sensitive data
1165
+ if contains_sensitive_data?(result)
1166
+ raise ToolError, "Output contains sensitive information"
1167
+ end
1168
+ end
1169
+
1170
+ def contains_sensitive_data?(text)
1171
+ patterns = [
1172
+ /\b\d{3}-\d{2}-\d{4}\b/, # SSN
1173
+ /\b\d{16}\b/, # Credit card
1174
+ /api[_-]?key/i, # API keys
1175
+ /password/i, # Passwords
1176
+ /secret/i # Secrets
1177
+ ]
1178
+
1179
+ text_str = text.to_s
1180
+ patterns.any? { |pattern| text_str.match?(pattern) }
1181
+ end
1182
+
1183
+ def log_execution(params)
1184
+ @logger.info "Tool execution: #{@name}"
1185
+ @logger.debug "Parameters: #{params.inspect}"
1186
+ end
1187
+
1188
+ def execution_timeout
1189
+ 30 # Default 30 seconds
1190
+ end
1191
+ end
1192
+ ```
1193
+
1194
+ ## Best Practices
1195
+
1196
+ ### 1. **Tool Design Principles**
1197
+ - Single responsibility - each tool does one thing well
1198
+ - Clear, descriptive names and descriptions
1199
+ - Comprehensive parameter validation
1200
+ - Meaningful error messages
1201
+ - Consistent output formatting
1202
+
1203
+ ### 2. **Security Considerations**
1204
+ - Always sanitize inputs
1205
+ - Implement rate limiting
1206
+ - Use timeouts to prevent hanging
1207
+ - Validate output size and content
1208
+ - Audit log all executions
1209
+ - Never expose sensitive data
1210
+
1211
+ ### 3. **Performance Optimization**
1212
+ - Cache expensive operations
1213
+ - Use connection pooling for databases/APIs
1214
+ - Implement retry logic with backoff
1215
+ - Stream large files instead of loading entirely
1216
+ - Clean up resources after use
1217
+
1218
+ ### 4. **Error Handling**
1219
+ - Provide clear error messages
1220
+ - Distinguish between recoverable and fatal errors
1221
+ - Log errors with context
1222
+ - Implement graceful degradation
1223
+ - Return partial results when possible
1224
+
1225
+ ### 5. **Testing Strategy**
1226
+ - Unit test all tool methods
1227
+ - Test parameter validation thoroughly
1228
+ - Mock external dependencies
1229
+ - Test error conditions
1230
+ - Integration test with agents
1231
+ - Performance test with large inputs
1232
+
1233
+ ## Next Steps
1234
+
1235
+ Now that you can build custom tools:
1236
+
1237
+ 1. Learn about [Working with Multiple Crews]({{ site.baseurl }}/tutorials/multiple-crews)
1238
+ 2. Explore [Production Deployment]({{ site.baseurl }}/tutorials/deployment) strategies
1239
+ 3. Review the [Tools API Documentation]({{ site.baseurl }}/api/tools)
1240
+ 4. Check out [Example Custom Tools]({{ site.baseurl }}/examples/) in production
1241
+
1242
+ Custom tools are essential for extending RCrewAI to handle specialized tasks and integrate with your existing systems.