rcrewai 0.7.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/ROADMAP.md +144 -75
- data/lib/rcrewai/checkpoint/cli.rb +98 -0
- data/lib/rcrewai/checkpoint.rb +145 -0
- data/lib/rcrewai/cli.rb +12 -1
- data/lib/rcrewai/configuration.rb +7 -0
- data/lib/rcrewai/crew.rb +82 -2
- data/lib/rcrewai/events.rb +71 -5
- data/lib/rcrewai/legacy_react_runner.rb +14 -9
- data/lib/rcrewai/llm_client.rb +20 -15
- data/lib/rcrewai/llm_clients/anthropic.rb +13 -4
- data/lib/rcrewai/llm_clients/azure.rb +2 -2
- data/lib/rcrewai/llm_clients/base.rb +55 -1
- data/lib/rcrewai/llm_clients/bedrock.rb +134 -0
- data/lib/rcrewai/llm_clients/google.rb +13 -4
- data/lib/rcrewai/llm_clients/ollama.rb +18 -4
- data/lib/rcrewai/llm_clients/openai.rb +19 -15
- data/lib/rcrewai/llm_clients/openai_compatible.rb +34 -0
- data/lib/rcrewai/llm_clients/openai_responses.rb +127 -0
- data/lib/rcrewai/llm_clients/snowflake_cortex.rb +42 -0
- data/lib/rcrewai/process.rb +39 -0
- data/lib/rcrewai/tool_runner.rb +16 -10
- data/lib/rcrewai/version.rb +1 -1
- data/lib/rcrewai.rb +3 -0
- metadata +7 -1
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,13 +63,16 @@ 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)
|
|
67
71
|
@tasks.each { |t| t.stream_sink = @stream_sink }
|
|
68
72
|
|
|
73
|
+
@checkpoint_store = checkpoint if checkpoint
|
|
74
|
+
open_checkpoint_run if @checkpoint_store
|
|
75
|
+
|
|
69
76
|
run_before_hooks(inputs)
|
|
70
77
|
|
|
71
78
|
distribute_knowledge if @knowledge
|
|
@@ -80,6 +87,44 @@ module RCrewAI
|
|
|
80
87
|
@tasks.each { |t| t.stream_sink = nil }
|
|
81
88
|
end
|
|
82
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?
|
|
126
|
+
end
|
|
127
|
+
|
|
83
128
|
# Runs the crew once per input set, returning one result per input in order.
|
|
84
129
|
# Runs are isolated: each execution starts from only its own inputs.
|
|
85
130
|
def kickoff_for_each(inputs:)
|
|
@@ -173,6 +218,41 @@ module RCrewAI
|
|
|
173
218
|
|
|
174
219
|
private
|
|
175
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
|
+
|
|
176
256
|
def run_before_hooks(inputs)
|
|
177
257
|
# Assign before running hooks so a hook that reads #last_inputs sees this
|
|
178
258
|
# run's own inputs; update it as each hook transforms them.
|
data/lib/rcrewai/events.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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)
|
data/lib/rcrewai/llm_client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'json'
|
|
6
|
+
require_relative 'base'
|
|
7
|
+
require_relative '../events'
|
|
8
|
+
require_relative '../pricing'
|
|
9
|
+
|
|
10
|
+
module RCrewAI
|
|
11
|
+
module LLMClients
|
|
12
|
+
# AWS Bedrock via the Converse API (v4). Converse gives every Bedrock model
|
|
13
|
+
# one request/response shape regardless of the underlying vendor, so this
|
|
14
|
+
# client speaks Converse rather than each model's native format.
|
|
15
|
+
#
|
|
16
|
+
# Authentication: the configured api_key is sent as a bearer token, which
|
|
17
|
+
# covers Bedrock API keys and any gateway fronting Bedrock. Full SigV4
|
|
18
|
+
# request signing is not implemented -- it needs the aws-sigv4 gem, and
|
|
19
|
+
# adding a hard AWS dependency for one provider is not worth it. Users
|
|
20
|
+
# needing SigV4 can sign via a before_request hook.
|
|
21
|
+
class Bedrock < Base
|
|
22
|
+
STOP_REASONS = {
|
|
23
|
+
'end_turn' => :stop,
|
|
24
|
+
'stop_sequence' => :stop,
|
|
25
|
+
'max_tokens' => :length,
|
|
26
|
+
'tool_use' => :tool_calls,
|
|
27
|
+
'content_filtered' => :content_filter
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(config = RCrewAI.configuration, **hooks)
|
|
31
|
+
super
|
|
32
|
+
@region = config.aws_region
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def provider_name
|
|
36
|
+
:bedrock
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options) # rubocop:disable Lint/UnusedMethodArgument
|
|
40
|
+
system_text = extract_system(messages)
|
|
41
|
+
payload = {
|
|
42
|
+
messages: format_messages(messages),
|
|
43
|
+
inferenceConfig: {
|
|
44
|
+
temperature: options[:temperature] || config.temperature,
|
|
45
|
+
maxTokens: options[:max_tokens] || config.max_tokens
|
|
46
|
+
}.compact
|
|
47
|
+
}
|
|
48
|
+
payload[:system] = [{ text: system_text }] if system_text
|
|
49
|
+
payload[:toolConfig] = { tools: format_tools(tools) } if tools && !tools.empty?
|
|
50
|
+
|
|
51
|
+
plain_chat(payload)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Converse exposes tool use uniformly, but streaming uses a separate
|
|
55
|
+
# endpoint and event-stream framing that this client does not implement.
|
|
56
|
+
def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def plain_chat(payload)
|
|
63
|
+
url = converse_url
|
|
64
|
+
payload = apply_before_request(payload)
|
|
65
|
+
started_at = Time.now
|
|
66
|
+
log_request(:post, url, payload)
|
|
67
|
+
response = http_client.post(url, payload, build_headers.merge(auth_header))
|
|
68
|
+
log_response(response)
|
|
69
|
+
body = handle_response(response)
|
|
70
|
+
apply_after_response(normalize(body), started_at)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def converse_url
|
|
74
|
+
"https://bedrock-runtime.#{@region}.amazonaws.com/model/#{CGI.escape(config.model)}/converse"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Converse carries the system prompt at the top level, not in messages.
|
|
78
|
+
def extract_system(messages)
|
|
79
|
+
systems = messages.select { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }
|
|
80
|
+
return nil if systems.empty?
|
|
81
|
+
|
|
82
|
+
systems.map { |m| m[:content] }.join("\n\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Every message content is a list of typed blocks.
|
|
86
|
+
def format_messages(messages)
|
|
87
|
+
messages.reject { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }.map do |m|
|
|
88
|
+
{ role: m[:role].to_s, content: [{ text: m[:content].to_s }] }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def format_tools(tools)
|
|
93
|
+
tools.map do |t|
|
|
94
|
+
{ toolSpec: { name: t[:name], description: t[:description],
|
|
95
|
+
inputSchema: { json: t[:parameters] } } }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize(body)
|
|
100
|
+
blocks = body.dig('output', 'message', 'content') || []
|
|
101
|
+
text = blocks.filter_map { |b| b['text'] }.join
|
|
102
|
+
tool_calls = blocks.filter_map do |b|
|
|
103
|
+
use = b['toolUse']
|
|
104
|
+
next unless use
|
|
105
|
+
|
|
106
|
+
{ id: use['toolUseId'], name: use['name'], arguments: use['input'] || {} }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
content: text.empty? ? nil : text,
|
|
111
|
+
tool_calls: tool_calls,
|
|
112
|
+
usage: {
|
|
113
|
+
prompt_tokens: body.dig('usage', 'inputTokens'),
|
|
114
|
+
completion_tokens: body.dig('usage', 'outputTokens'),
|
|
115
|
+
total_tokens: body.dig('usage', 'totalTokens')
|
|
116
|
+
},
|
|
117
|
+
finish_reason: STOP_REASONS.fetch(body['stopReason'], :stop),
|
|
118
|
+
model: config.model,
|
|
119
|
+
provider: provider_name
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def auth_header
|
|
124
|
+
{ 'Authorization' => "Bearer #{config.api_key}" }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_config!
|
|
128
|
+
raise ConfigurationError, 'Bedrock API key is required' unless config.api_key
|
|
129
|
+
raise ConfigurationError, 'An AWS region is required for Bedrock' unless config.aws_region
|
|
130
|
+
raise ConfigurationError, 'Model is required' unless config.model
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -20,8 +20,8 @@ module RCrewAI
|
|
|
20
20
|
'RECITATION' => :stop
|
|
21
21
|
}.freeze
|
|
22
22
|
|
|
23
|
-
def initialize(config = RCrewAI.configuration)
|
|
24
|
-
super
|
|
23
|
+
def initialize(config = RCrewAI.configuration, **hooks)
|
|
24
|
+
super(config, **hooks)
|
|
25
25
|
@base_url = BASE_URL
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -62,17 +62,25 @@ module RCrewAI
|
|
|
62
62
|
%w[gemini-pro gemini-1.5-pro gemini-1.5-flash gemini-pro-vision]
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def provider_name
|
|
66
|
+
:google
|
|
67
|
+
end
|
|
68
|
+
|
|
65
69
|
private
|
|
66
70
|
|
|
67
71
|
def plain_chat(url, payload)
|
|
72
|
+
payload = apply_before_request(payload)
|
|
73
|
+
started_at = Time.now
|
|
68
74
|
log_request(:post, url, payload)
|
|
69
75
|
response = http_client.post(url, payload, build_headers)
|
|
70
76
|
log_response(response)
|
|
71
77
|
body = handle_response(response)
|
|
72
|
-
normalize_non_streaming(body)
|
|
78
|
+
apply_after_response(normalize_non_streaming(body), started_at)
|
|
73
79
|
end
|
|
74
80
|
|
|
75
81
|
def stream_chat(url, payload, sink)
|
|
82
|
+
payload = apply_before_request(payload)
|
|
83
|
+
started_at = Time.now
|
|
76
84
|
log_request(:post, url, payload)
|
|
77
85
|
|
|
78
86
|
assembled_text = +''
|
|
@@ -123,7 +131,7 @@ module RCrewAI
|
|
|
123
131
|
|
|
124
132
|
finish_reason = :tool_calls if tool_calls.any?
|
|
125
133
|
|
|
126
|
-
{
|
|
134
|
+
result = {
|
|
127
135
|
content: assembled_text.empty? ? nil : assembled_text,
|
|
128
136
|
tool_calls: tool_calls,
|
|
129
137
|
usage: usage || {},
|
|
@@ -131,6 +139,7 @@ module RCrewAI
|
|
|
131
139
|
model: config.model,
|
|
132
140
|
provider: :google
|
|
133
141
|
}
|
|
142
|
+
apply_after_response(result, started_at)
|
|
134
143
|
end
|
|
135
144
|
|
|
136
145
|
def streaming_post(url, payload, &on_chunk)
|