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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+ require 'time'
7
+
8
+ module RCrewAI
9
+ # Durable execution state for a crew run.
10
+ #
11
+ # A checkpoint records, per task, whether it completed and what it produced.
12
+ # Resuming a run replays those results instead of re-executing the tasks --
13
+ # the expensive part of a crew run is the LLM calls, so skipping a completed
14
+ # task is the whole point.
15
+ #
16
+ # Granularity is task-level: a checkpoint is written after each task settles,
17
+ # so a crash loses at most the task in flight. Stores are pluggable; anything
18
+ # responding to #save(id, record), #load(id), #list and #delete works.
19
+ module Checkpoint
20
+ class CheckpointError < RCrewAI::Error; end
21
+
22
+ # Volatile; for tests and single-process runs.
23
+ class MemoryStore
24
+ def initialize
25
+ @data = {}
26
+ end
27
+
28
+ def save(id, record)
29
+ @data[id] = deep_dup(record)
30
+ end
31
+
32
+ def load(id)
33
+ record = @data[id]
34
+ record && deep_dup(record)
35
+ end
36
+
37
+ def list
38
+ @data.keys
39
+ end
40
+
41
+ def delete(id)
42
+ @data.delete(id)
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ # Records are plain JSON-shaped data, so a round-trip is a sufficient
49
+ # deep copy and keeps a caller's later mutation from reaching the store.
50
+ def deep_dup(record)
51
+ JSON.parse(JSON.generate(record))
52
+ end
53
+ end
54
+
55
+ # One JSON file per run under a directory.
56
+ class FileStore
57
+ def initialize(dir)
58
+ @dir = dir
59
+ FileUtils.mkdir_p(@dir)
60
+ end
61
+
62
+ def save(id, record)
63
+ File.write(path_for(id), JSON.pretty_generate(record))
64
+ end
65
+
66
+ def load(id)
67
+ path = path_for(id)
68
+ return nil unless File.exist?(path)
69
+
70
+ JSON.parse(File.read(path))
71
+ end
72
+
73
+ def list
74
+ Dir.glob(File.join(@dir, '*.json')).map { |p| File.basename(p, '.json') }
75
+ end
76
+
77
+ def delete(id)
78
+ path = path_for(id)
79
+ File.delete(path) if File.exist?(path)
80
+ nil
81
+ end
82
+
83
+ private
84
+
85
+ # Run ids reach here from callers and from stored records, so a path
86
+ # separator or traversal segment must not be able to steer the write
87
+ # outside the checkpoint directory.
88
+ def path_for(id)
89
+ s = id.to_s
90
+ raise CheckpointError, "invalid run id: #{id.inspect}" if s.empty? ||
91
+ s.include?('/') ||
92
+ s.include?('\\') ||
93
+ s == '.' || s == '..'
94
+
95
+ File.join(@dir, "#{s}.json")
96
+ end
97
+ end
98
+
99
+ module_function
100
+
101
+ def new_run_id
102
+ SecureRandom.uuid
103
+ end
104
+
105
+ # Builds the record persisted for a run.
106
+ def record_for(run_id:, crew_name:, tasks:, parent_run_id: nil)
107
+ {
108
+ 'run_id' => run_id,
109
+ 'parent_run_id' => parent_run_id,
110
+ 'crew' => crew_name,
111
+ 'updated_at' => Time.now.utc.iso8601,
112
+ 'tasks' => tasks
113
+ }
114
+ end
115
+
116
+ # Serializes one task's settled state.
117
+ def task_entry(task, status)
118
+ {
119
+ 'status' => status.to_s,
120
+ 'result' => task.result,
121
+ 'execution_time' => task.execution_time
122
+ }
123
+ end
124
+
125
+ # Walks parent_run_id links from +run_id+ back to the root, returning the
126
+ # chain oldest-first. Stops on a missing record rather than raising, so a
127
+ # pruned ancestor truncates the chain instead of breaking it.
128
+ def lineage(store, run_id)
129
+ chain = []
130
+ seen = {}
131
+ current = run_id
132
+
133
+ while current && !seen[current]
134
+ seen[current] = true
135
+ record = store.load(current)
136
+ break unless record
137
+
138
+ chain.unshift(current)
139
+ current = record['parent_run_id']
140
+ end
141
+
142
+ chain
143
+ end
144
+ end
145
+ end
data/lib/rcrewai/cli.rb CHANGED
@@ -8,9 +8,13 @@ module RCrewAI
8
8
  Crew.create(crew_name)
9
9
  end
10
10
 
11
+ # Thor reserves #run, so the command is defined under another name and
12
+ # mapped back. Without this the whole class raises on load, which is why
13
+ # cli.rb went unrequired -- and why bin/rcrewai never worked.
11
14
  desc 'run', 'Run the AI crew'
12
15
  option :crew, type: :string, required: true, desc: 'Name of the crew to run'
13
- def run
16
+ map 'run' => :run_crew
17
+ def run_crew
14
18
  crew_name = options[:crew]
15
19
  puts "Running crew: #{crew_name}"
16
20
  crew = Crew.load(crew_name)
@@ -31,9 +35,16 @@ module RCrewAI
31
35
  desc 'task SUBCOMMAND ...ARGS', 'Manage tasks'
32
36
  subcommand 'task', Task::CLI
33
37
 
38
+ desc 'checkpoint SUBCOMMAND ...ARGS', 'Inspect run checkpoints'
39
+ subcommand 'checkpoint', Checkpoint::CLI
40
+
34
41
  desc 'version', 'Show version'
35
42
  def version
36
43
  puts "rcrewai version #{RCrewAI::VERSION}"
37
44
  end
45
+
46
+ def self.exit_on_failure?
47
+ true
48
+ end
38
49
  end
39
50
  end
@@ -6,6 +6,7 @@ module RCrewAI
6
6
  :openai_api_key, :anthropic_api_key, :google_api_key, :azure_api_key,
7
7
  :openai_model, :anthropic_model, :google_model, :azure_model,
8
8
  :base_url, :api_version, :deployment_name,
9
+ :aws_region, :snowflake_account,
9
10
  :pricing, :ollama_native_tools, :log_level
10
11
 
11
12
  def initialize
@@ -21,6 +22,9 @@ module RCrewAI
21
22
  @google_model = 'gemini-pro'
22
23
  @azure_model = 'gpt-4'
23
24
 
25
+ @aws_region = nil
26
+ @snowflake_account = nil
27
+
24
28
  @pricing = nil
25
29
  @ollama_native_tools = nil
26
30
  @log_level = :info
@@ -101,6 +105,9 @@ module RCrewAI
101
105
  @google_api_key = ENV['GOOGLE_API_KEY'] || ENV['GEMINI_API_KEY']
102
106
  @azure_api_key = ENV['AZURE_OPENAI_API_KEY']
103
107
 
108
+ @aws_region ||= ENV.fetch('AWS_REGION', nil) || ENV.fetch('AWS_DEFAULT_REGION', nil)
109
+ @snowflake_account ||= ENV.fetch('SNOWFLAKE_ACCOUNT', nil)
110
+
104
111
  @api_key = ENV['LLM_API_KEY'] if @api_key.nil?
105
112
  @base_url = ENV['LLM_BASE_URL'] if @base_url.nil?
106
113
  @api_version = ENV['AZURE_API_VERSION'] if @api_version.nil?
data/lib/rcrewai/crew.rb CHANGED
@@ -28,10 +28,14 @@ module RCrewAI
28
28
  @after_kickoff_hooks = []
29
29
  @last_inputs = {}
30
30
  @process_instance = nil
31
+ @checkpoint_store = options[:checkpoint]
32
+ @run_id = nil
33
+ @parent_run_id = nil
34
+ @completed_from_checkpoint = {}
31
35
  validate_process_type!
32
36
  end
33
37
 
34
- attr_reader :knowledge, :stream_sink, :last_inputs, :consensus_agents
38
+ attr_reader :knowledge, :stream_sink, :last_inputs, :consensus_agents, :run_id
35
39
 
36
40
  def planning?
37
41
  @planning
@@ -59,11 +63,15 @@ module RCrewAI
59
63
  @tasks << task
60
64
  end
61
65
 
62
- def execute(async: false, stream: nil, inputs: {}, **async_options, &block)
66
+ def execute(async: false, stream: nil, inputs: {}, checkpoint: nil, **async_options, &block)
63
67
  sinks = []
64
68
  sinks << block if block_given?
65
69
  Array(stream).each { |s| sinks << s } if stream
66
70
  @stream_sink = sinks.empty? ? nil : RCrewAI::Events.fan_out(sinks)
71
+ @tasks.each { |t| t.stream_sink = @stream_sink }
72
+
73
+ @checkpoint_store = checkpoint if checkpoint
74
+ open_checkpoint_run if @checkpoint_store
67
75
 
68
76
  run_before_hooks(inputs)
69
77
 
@@ -72,6 +80,49 @@ module RCrewAI
72
80
 
73
81
  result = async ? execute_async(**async_options) : execute_sync
74
82
  run_after_hooks(result)
83
+ ensure
84
+ # Drop per-task references to the caller's sink so request-scoped
85
+ # collectors do not stay reachable after the run. The crew's own
86
+ # +stream_sink+ reader is left intact: Process reads it.
87
+ @tasks.each { |t| t.stream_sink = nil }
88
+ end
89
+
90
+ # Resumes a checkpointed run: tasks that already completed replay their
91
+ # stored results, and only the rest execute. The resumed run gets its own
92
+ # run id linked to +run_id+ via parent_run_id, so the original record stays
93
+ # intact and the chain stays walkable.
94
+ def resume(run_id, checkpoint: nil, **options)
95
+ store = checkpoint || @checkpoint_store
96
+ raise Checkpoint::CheckpointError, 'no checkpoint store configured' unless store
97
+
98
+ record = store.load(run_id)
99
+ raise Checkpoint::CheckpointError, "no checkpoint for run id #{run_id}" unless record
100
+
101
+ @checkpoint_store = store
102
+ @parent_run_id = run_id
103
+ @completed_from_checkpoint = completed_entries(record)
104
+ apply_checkpoint(@completed_from_checkpoint)
105
+
106
+ execute(**options)
107
+ end
108
+
109
+ # Tasks whose results came from a checkpoint rather than this run.
110
+ def restored_task_names
111
+ @completed_from_checkpoint.keys
112
+ end
113
+
114
+ # Records one task's settled state and flushes the run record. Called by
115
+ # Process as each task finishes, so a crash loses at most the task in
116
+ # flight rather than the whole run.
117
+ def checkpoint_task(task, status)
118
+ return unless @checkpoint_store
119
+
120
+ @checkpoint_tasks[task.name] = Checkpoint.task_entry(task, status)
121
+ write_checkpoint
122
+ end
123
+
124
+ def checkpointing?
125
+ !@checkpoint_store.nil?
75
126
  end
76
127
 
77
128
  # Runs the crew once per input set, returning one result per input in order.
@@ -167,6 +218,41 @@ module RCrewAI
167
218
 
168
219
  private
169
220
 
221
+ def open_checkpoint_run
222
+ @run_id = Checkpoint.new_run_id
223
+ @checkpoint_tasks = {}
224
+ # Carry restored entries into the new record so it describes the whole
225
+ # run, not just the tasks this process happened to execute.
226
+ @completed_from_checkpoint.each { |name, entry| @checkpoint_tasks[name] = entry }
227
+ write_checkpoint
228
+ end
229
+
230
+ def write_checkpoint
231
+ @checkpoint_store.save(
232
+ @run_id,
233
+ Checkpoint.record_for(run_id: @run_id, crew_name: @name,
234
+ tasks: @checkpoint_tasks, parent_run_id: @parent_run_id)
235
+ )
236
+ end
237
+
238
+ def completed_entries(record)
239
+ (record['tasks'] || {}).select { |_name, entry| entry['status'] == 'completed' }
240
+ end
241
+
242
+ # Replays stored results onto the matching tasks so downstream tasks can
243
+ # still read their context. A checkpointed name with no matching task is
244
+ # ignored: the crew may legitimately have been rebuilt differently.
245
+ def apply_checkpoint(entries)
246
+ @tasks.each do |task|
247
+ entry = entries[task.name]
248
+ next unless entry
249
+
250
+ task.result = entry['result']
251
+ task.status = :completed
252
+ task.execution_time = entry['execution_time']
253
+ end
254
+ end
255
+
170
256
  def run_before_hooks(inputs)
171
257
  # Assign before running hooks so a hook that reads #last_inputs sees this
172
258
  # run's own inputs; update it as each hook transforms them.
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module RCrewAI
4
6
  module Events
5
- BASE_ATTRS = %i[type timestamp agent iteration].freeze
7
+ # Every event carries its own :id and, when emitted inside a
8
+ # +with_parent+ scope, the :parent_id of the enclosing span. Together these
9
+ # turn a flat event stream into a tree that a subscriber can reassemble --
10
+ # which is what tracing exporters need.
11
+ BASE_ATTRS = %i[type timestamp agent iteration id parent_id].freeze
6
12
 
7
13
  Event = Struct.new(*BASE_ATTRS, keyword_init: true)
8
14
  TextDelta = Struct.new(*BASE_ATTRS, :text, keyword_init: true)
@@ -16,15 +22,75 @@ module RCrewAI
16
22
  IterationEnd = Struct.new(*BASE_ATTRS, :finish_reason, keyword_init: true)
17
23
  Error = Struct.new(*BASE_ATTRS, :error, keyword_init: true)
18
24
 
25
+ # Auto-assign an :id to any event that did not supply one. Struct's
26
+ # keyword_init initializer is wrapped rather than replaced so every event
27
+ # type gets this without restating the attribute list.
28
+ [Event, TextDelta, TextDone, ToolCallStart, ToolCallResult, ToolCallError,
29
+ Thinking, Usage, IterationStart, IterationEnd, Error].each do |klass|
30
+ klass.prepend(Module.new do
31
+ def initialize(**kwargs)
32
+ kwargs[:id] ||= SecureRandom.uuid
33
+ super(**kwargs)
34
+ end
35
+ end)
36
+ end
37
+
38
+ PARENT_KEY = :rcrewai_event_parent
39
+
40
+ # Runs the block with +id+ as the parent span for any event emitted through
41
+ # +emit+ on this thread. Scopes nest and are restored on exit, including
42
+ # when the block raises. Thread-local: a child thread starts with no parent
43
+ # rather than inheriting one, since it is a separate branch of work.
44
+ def self.with_parent(id)
45
+ previous = Thread.current[PARENT_KEY]
46
+ Thread.current[PARENT_KEY] = id
47
+ yield
48
+ ensure
49
+ Thread.current[PARENT_KEY] = previous
50
+ end
51
+
52
+ def self.current_parent
53
+ Thread.current[PARENT_KEY]
54
+ end
55
+
56
+ # Stamps the enclosing parent span onto the event (unless it already names
57
+ # one) and hands it to the sink.
58
+ def self.emit(sink, event)
59
+ return event if sink.nil?
60
+
61
+ event.parent_id ||= current_parent
62
+ sink.call(event)
63
+ event
64
+ end
65
+
66
+ # Wraps one or more sinks in a single callable.
67
+ #
68
+ # Delivery is serialized: sinks are invoked under a mutex, so a sink shared
69
+ # by concurrently executing agents is never entered from two threads at
70
+ # once and does not need locking of its own. The lock is reentrant, so a
71
+ # sink that emits back through the same fan-out does not deadlock.
72
+ #
73
+ # A sink that raises is reported and skipped -- one bad subscriber must not
74
+ # take down the run or starve the others.
19
75
  def self.fan_out(sinks)
20
76
  sinks = Array(sinks).compact
77
+ mutex = Mutex.new
21
78
  lambda do |event|
22
- sinks.each do |s|
23
- s.call(event)
24
- rescue StandardError => e
25
- Kernel.warn "[rcrewai] event sink raised: #{e.class}: #{e.message}"
79
+ if mutex.owned?
80
+ deliver(sinks, event)
81
+ else
82
+ mutex.synchronize { deliver(sinks, event) }
26
83
  end
27
84
  end
28
85
  end
86
+
87
+ def self.deliver(sinks, event)
88
+ sinks.each do |s|
89
+ s.call(event)
90
+ rescue StandardError => e
91
+ Kernel.warn "[rcrewai] event sink raised: #{e.class}: #{e.message}"
92
+ end
93
+ end
94
+ private_class_method :deliver
29
95
  end
30
96
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
3
4
  require_relative 'events'
4
5
 
5
6
  module RCrewAI
@@ -19,6 +20,12 @@ module RCrewAI
19
20
  end
20
21
 
21
22
  def run(messages:)
23
+ Events.with_parent(@run_span_id ||= SecureRandom.uuid) { run_loop(messages: messages) }
24
+ end
25
+
26
+ private
27
+
28
+ def run_loop(messages:)
22
29
  msgs = messages.dup
23
30
  history = []
24
31
  iter = 0
@@ -58,8 +65,6 @@ module RCrewAI
58
65
  finish_reason: :max_iterations, usage: total_usage)
59
66
  end
60
67
 
61
- private
62
-
63
68
  # Trims the message list to the model's context window when the agent
64
69
  # supports it; a no-op otherwise.
65
70
  def fit_context(messages)
@@ -144,13 +149,13 @@ module RCrewAI
144
149
  type_sym = klass.name.split('::').last
145
150
  .gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
146
151
  .sub(/^_/, '').to_sym
147
- @sink.call(klass.new(
148
- type: type_sym,
149
- timestamp: Time.now,
150
- agent: @agent.respond_to?(:name) ? @agent.name : nil,
151
- iteration: iteration,
152
- **attrs
153
- ))
152
+ Events.emit(@sink, klass.new(
153
+ type: type_sym,
154
+ timestamp: Time.now,
155
+ agent: @agent.respond_to?(:name) ? @agent.name : nil,
156
+ iteration: iteration,
157
+ **attrs
158
+ ))
154
159
  end
155
160
 
156
161
  def accumulate_usage(total, partial)
@@ -6,26 +6,31 @@ require_relative 'llm_clients/anthropic'
6
6
  require_relative 'llm_clients/google'
7
7
  require_relative 'llm_clients/azure'
8
8
  require_relative 'llm_clients/ollama'
9
+ require_relative 'llm_clients/openai_compatible'
10
+ require_relative 'llm_clients/bedrock'
11
+ require_relative 'llm_clients/snowflake_cortex'
12
+ require_relative 'llm_clients/openai_responses'
9
13
 
10
14
  module RCrewAI
11
15
  class LLMClient
12
- def self.for_provider(provider = nil, config = RCrewAI.configuration)
16
+ PROVIDERS = {
17
+ openai: LLMClients::OpenAI,
18
+ anthropic: LLMClients::Anthropic,
19
+ google: LLMClients::Google,
20
+ azure: LLMClients::Azure,
21
+ ollama: LLMClients::Ollama,
22
+ openai_compatible: LLMClients::OpenAICompatible,
23
+ bedrock: LLMClients::Bedrock,
24
+ snowflake: LLMClients::SnowflakeCortex,
25
+ openai_responses: LLMClients::OpenAIResponses
26
+ }.freeze
27
+
28
+ def self.for_provider(provider = nil, config = RCrewAI.configuration, **hooks)
13
29
  provider ||= config.llm_provider
30
+ klass = PROVIDERS[provider.to_sym]
31
+ raise ConfigurationError, "Unsupported provider: #{provider}" unless klass
14
32
 
15
- case provider.to_sym
16
- when :openai
17
- LLMClients::OpenAI.new(config)
18
- when :anthropic
19
- LLMClients::Anthropic.new(config)
20
- when :google
21
- LLMClients::Google.new(config)
22
- when :azure
23
- LLMClients::Azure.new(config)
24
- when :ollama
25
- LLMClients::Ollama.new(config)
26
- else
27
- raise ConfigurationError, "Unsupported provider: #{provider}"
28
- end
33
+ klass.new(config, **hooks)
29
34
  end
30
35
 
31
36
  # Resolves a per-agent / per-pass LLM spec into a client.
@@ -21,8 +21,8 @@ module RCrewAI
21
21
  'max_tokens' => :length
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 = BASE_URL
27
27
  end
28
28
 
@@ -74,19 +74,27 @@ module RCrewAI
74
74
  ]
75
75
  end
76
76
 
77
+ def provider_name
78
+ :anthropic
79
+ end
80
+
77
81
  private
78
82
 
79
83
  def plain_chat(payload)
80
84
  url = "#{@base_url}/messages"
85
+ payload = apply_before_request(payload)
86
+ started_at = Time.now
81
87
  log_request(:post, url, payload)
82
88
  response = http_client.post(url, payload, build_headers.merge(auth_header))
83
89
  log_response(response)
84
90
  body = handle_response(response)
85
- normalize_non_streaming(body)
91
+ apply_after_response(normalize_non_streaming(body), started_at)
86
92
  end
87
93
 
88
94
  def stream_chat(payload, sink) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
89
95
  url = "#{@base_url}/messages"
96
+ payload = apply_before_request(payload)
97
+ started_at = Time.now
90
98
  log_request(:post, url, payload)
91
99
 
92
100
  assembled_text = +''
@@ -150,7 +158,7 @@ module RCrewAI
150
158
  ))
151
159
  end
152
160
 
153
- {
161
+ result = {
154
162
  content: assembled_text.empty? ? nil : assembled_text,
155
163
  tool_calls: tool_calls,
156
164
  usage: usage,
@@ -158,6 +166,7 @@ module RCrewAI
158
166
  model: config.model,
159
167
  provider: :anthropic
160
168
  }
169
+ apply_after_response(result, started_at)
161
170
  end
162
171
 
163
172
  def streaming_post(url, payload, &on_chunk)
@@ -8,8 +8,8 @@ module RCrewAI
8
8
  # path with an api-version query param, and authenticates with an api-key
9
9
  # header instead of Authorization: Bearer.
10
10
  class Azure < OpenAI
11
- def initialize(config = RCrewAI.configuration)
12
- super
11
+ def initialize(config = RCrewAI.configuration, **hooks)
12
+ super(config, **hooks)
13
13
  @api_version = config.api_version || '2024-02-01'
14
14
  @deployment_name = config.deployment_name || config.model
15
15
  @base_url = build_endpoint_url
@@ -9,13 +9,31 @@ module RCrewAI
9
9
  class Base
10
10
  attr_reader :config, :logger
11
11
 
12
- def initialize(config = RCrewAI.configuration)
12
+ def initialize(config = RCrewAI.configuration, before_request: nil, after_response: nil)
13
13
  @config = config
14
14
  @logger = Logger.new($stdout)
15
15
  @logger.level = Logger::INFO
16
+ @before_request_hooks = Array(before_request)
17
+ @after_response_hooks = Array(after_response)
16
18
  validate_config!
17
19
  end
18
20
 
21
+ # Registers a hook run just before the request payload is sent.
22
+ # Receives (payload, context) where context carries :provider and :model.
23
+ # Returning a payload replaces it; returning nil keeps the original.
24
+ def before_request(callable = nil, &block)
25
+ @before_request_hooks << (callable || block)
26
+ self
27
+ end
28
+
29
+ # Registers a hook run just after a response is normalized.
30
+ # Receives (result, context) where context adds :duration_ms.
31
+ # Returning a result replaces it; returning nil keeps the original.
32
+ def after_response(callable = nil, &block)
33
+ @after_response_hooks << (callable || block)
34
+ self
35
+ end
36
+
19
37
  def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
20
38
  raise NotImplementedError, 'Subclasses must implement #chat method'
21
39
  end
@@ -30,6 +48,42 @@ module RCrewAI
30
48
 
31
49
  protected
32
50
 
51
+ # Threads the payload through every before_request hook. A hook that
52
+ # raises is reported and skipped -- observability must never break a call.
53
+ def apply_before_request(payload)
54
+ return payload if @before_request_hooks.empty?
55
+
56
+ ctx = hook_context
57
+ @before_request_hooks.reduce(payload) do |acc, hook|
58
+ hook.call(acc, ctx) || acc
59
+ rescue StandardError => e
60
+ Kernel.warn "[rcrewai] before_request hook raised: #{e.class}: #{e.message}"
61
+ acc
62
+ end
63
+ end
64
+
65
+ # Threads the normalized result through every after_response hook.
66
+ def apply_after_response(result, started_at)
67
+ return result if @after_response_hooks.empty?
68
+
69
+ ctx = hook_context.merge(duration_ms: ((Time.now - started_at) * 1000).round(3))
70
+ @after_response_hooks.reduce(result) do |acc, hook|
71
+ hook.call(acc, ctx) || acc
72
+ rescue StandardError => e
73
+ Kernel.warn "[rcrewai] after_response hook raised: #{e.class}: #{e.message}"
74
+ acc
75
+ end
76
+ end
77
+
78
+ def hook_context
79
+ { provider: provider_name, model: config.model }
80
+ end
81
+
82
+ # Providers override this; Base has no wire identity of its own.
83
+ def provider_name
84
+ nil
85
+ end
86
+
33
87
  def validate_config!
34
88
  raise ConfigurationError, 'API key is required' unless config.api_key
35
89
  raise ConfigurationError, 'Model is required' unless config.model