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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -0
- data/.rubocop_todo.yml +99 -0
- data/CHANGELOG.md +64 -1
- data/README.md +170 -2
- data/ROADMAP.md +84 -0
- data/Rakefile +53 -53
- data/bin/rcrewai +3 -3
- data/docs/mcp.md +109 -0
- data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
- data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
- data/docs/upgrading-to-0.3.md +163 -0
- data/examples/async_execution_example.rb +82 -81
- data/examples/hierarchical_crew_example.rb +68 -72
- data/examples/human_in_the_loop_example.rb +73 -74
- data/examples/mcp_example.rb +48 -0
- data/examples/native_tools_example.rb +64 -0
- data/examples/streaming_example.rb +56 -0
- data/lib/rcrewai/agent.rb +181 -286
- data/lib/rcrewai/async_executor.rb +43 -43
- data/lib/rcrewai/cli.rb +11 -11
- data/lib/rcrewai/configuration.rb +34 -9
- data/lib/rcrewai/crew.rb +134 -39
- data/lib/rcrewai/events.rb +30 -0
- data/lib/rcrewai/flow/state.rb +47 -0
- data/lib/rcrewai/flow/state_store.rb +50 -0
- data/lib/rcrewai/flow.rb +243 -0
- data/lib/rcrewai/human_input.rb +104 -114
- data/lib/rcrewai/knowledge/base.rb +52 -0
- data/lib/rcrewai/knowledge/chunker.rb +31 -0
- data/lib/rcrewai/knowledge/embedder.rb +48 -0
- data/lib/rcrewai/knowledge/sources.rb +83 -0
- data/lib/rcrewai/knowledge/store.rb +58 -0
- data/lib/rcrewai/knowledge.rb +13 -0
- data/lib/rcrewai/legacy_react_runner.rb +172 -0
- data/lib/rcrewai/llm_client.rb +24 -1
- data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
- data/lib/rcrewai/llm_clients/azure.rb +23 -128
- data/lib/rcrewai/llm_clients/base.rb +11 -7
- data/lib/rcrewai/llm_clients/google.rb +159 -95
- data/lib/rcrewai/llm_clients/ollama.rb +150 -106
- data/lib/rcrewai/llm_clients/openai.rb +140 -63
- data/lib/rcrewai/mcp/client.rb +101 -0
- data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
- data/lib/rcrewai/mcp/transport/http.rb +53 -0
- data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
- data/lib/rcrewai/mcp.rb +8 -0
- data/lib/rcrewai/memory.rb +45 -37
- data/lib/rcrewai/output_schema.rb +79 -0
- data/lib/rcrewai/planning.rb +65 -0
- data/lib/rcrewai/pricing.rb +34 -0
- data/lib/rcrewai/process.rb +86 -95
- data/lib/rcrewai/provider_schema.rb +38 -0
- data/lib/rcrewai/sse_parser.rb +55 -0
- data/lib/rcrewai/task.rb +145 -66
- data/lib/rcrewai/tool_runner.rb +132 -0
- data/lib/rcrewai/tool_schema.rb +97 -0
- data/lib/rcrewai/tools/base.rb +98 -37
- data/lib/rcrewai/tools/code_executor.rb +71 -74
- data/lib/rcrewai/tools/email_sender.rb +70 -78
- data/lib/rcrewai/tools/file_reader.rb +38 -30
- data/lib/rcrewai/tools/file_writer.rb +40 -38
- data/lib/rcrewai/tools/pdf_processor.rb +115 -130
- data/lib/rcrewai/tools/sql_database.rb +58 -55
- data/lib/rcrewai/tools/web_search.rb +26 -25
- data/lib/rcrewai/version.rb +2 -2
- data/lib/rcrewai.rb +20 -10
- data/rcrewai.gemspec +39 -39
- metadata +77 -47
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
module Events
|
|
5
|
+
BASE_ATTRS = %i[type timestamp agent iteration].freeze
|
|
6
|
+
|
|
7
|
+
Event = Struct.new(*BASE_ATTRS, keyword_init: true)
|
|
8
|
+
TextDelta = Struct.new(*BASE_ATTRS, :text, keyword_init: true)
|
|
9
|
+
TextDone = Struct.new(*BASE_ATTRS, :text, keyword_init: true)
|
|
10
|
+
ToolCallStart = Struct.new(*BASE_ATTRS, :tool, :args, :call_id, keyword_init: true)
|
|
11
|
+
ToolCallResult = Struct.new(*BASE_ATTRS, :tool, :call_id, :result, :duration_ms, keyword_init: true)
|
|
12
|
+
ToolCallError = Struct.new(*BASE_ATTRS, :tool, :call_id, :error, keyword_init: true)
|
|
13
|
+
Thinking = Struct.new(*BASE_ATTRS, :text, keyword_init: true)
|
|
14
|
+
Usage = Struct.new(*BASE_ATTRS, :prompt_tokens, :completion_tokens, :total_tokens, :cost_usd, keyword_init: true)
|
|
15
|
+
IterationStart = Struct.new(*BASE_ATTRS, :iteration_index, keyword_init: true)
|
|
16
|
+
IterationEnd = Struct.new(*BASE_ATTRS, :finish_reason, keyword_init: true)
|
|
17
|
+
Error = Struct.new(*BASE_ATTRS, :error, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def self.fan_out(sinks)
|
|
20
|
+
sinks = Array(sinks).compact
|
|
21
|
+
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}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
class Flow
|
|
7
|
+
# Mutable, schemaless flow state with a stable unique id. Access attributes
|
|
8
|
+
# as methods (state.foo, state.foo = 1) or via [] / to_h. Mirrors CrewAI's
|
|
9
|
+
# unstructured (dict-based) flow state, with an automatic UUID.
|
|
10
|
+
class State
|
|
11
|
+
def initialize(attributes = {})
|
|
12
|
+
@attributes = {}
|
|
13
|
+
attributes.each { |k, v| @attributes[k.to_sym] = v }
|
|
14
|
+
@attributes[:id] ||= SecureRandom.uuid
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def id
|
|
18
|
+
@attributes[:id]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def [](key)
|
|
22
|
+
@attributes[key.to_sym]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def []=(key, value)
|
|
26
|
+
@attributes[key.to_sym] = value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
@attributes.dup
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def method_missing(name, *args)
|
|
38
|
+
key = name.to_s
|
|
39
|
+
if key.end_with?('=')
|
|
40
|
+
@attributes[key[0..-2].to_sym] = args.first
|
|
41
|
+
else
|
|
42
|
+
@attributes[name]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RCrewAI
|
|
7
|
+
class Flow
|
|
8
|
+
# Persists flow state keyed by state id, so a flow can be resumed across
|
|
9
|
+
# restarts. Two built-ins: in-memory (tests / single process) and file-based
|
|
10
|
+
# (JSON on disk). Any object with #save(id, hash) and #load(id) works.
|
|
11
|
+
class MemoryStateStore
|
|
12
|
+
def initialize
|
|
13
|
+
@data = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def save(id, hash)
|
|
17
|
+
@data[id] = hash.dup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load(id)
|
|
21
|
+
@data[id]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Stores each state as a JSON file named <id>.json under a directory.
|
|
26
|
+
class FileStateStore
|
|
27
|
+
def initialize(dir)
|
|
28
|
+
@dir = dir
|
|
29
|
+
FileUtils.mkdir_p(@dir)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def save(id, hash)
|
|
33
|
+
File.write(path_for(id), JSON.pretty_generate(hash))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load(id)
|
|
37
|
+
path = path_for(id)
|
|
38
|
+
return nil unless File.exist?(path)
|
|
39
|
+
|
|
40
|
+
JSON.parse(File.read(path))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def path_for(id)
|
|
46
|
+
File.join(@dir, "#{id}.json")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/rcrewai/flow.rb
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'flow/state'
|
|
4
|
+
require_relative 'flow/state_store'
|
|
5
|
+
|
|
6
|
+
module RCrewAI
|
|
7
|
+
# Event-driven workflow engine — CrewAI's second pillar, in Ruby.
|
|
8
|
+
#
|
|
9
|
+
# Subclass Flow and declare methods with the class-level DSL:
|
|
10
|
+
#
|
|
11
|
+
# class GuideFlow < RCrewAI::Flow
|
|
12
|
+
# start :pick_topic
|
|
13
|
+
# def pick_topic = state.topic = 'ruby'
|
|
14
|
+
#
|
|
15
|
+
# listen :pick_topic
|
|
16
|
+
# def research(prev) = "researched #{prev}"
|
|
17
|
+
#
|
|
18
|
+
# router :research
|
|
19
|
+
# def route(prev) = prev.include?('ruby') ? :publish : :revise
|
|
20
|
+
#
|
|
21
|
+
# listen :publish
|
|
22
|
+
# def publish = state.done = true
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# GuideFlow.new.kickoff
|
|
26
|
+
#
|
|
27
|
+
# Triggers combine with and_/or_. State is a schemaless object with a UUID and
|
|
28
|
+
# can be persisted/restored via a state store.
|
|
29
|
+
class Flow
|
|
30
|
+
# --- Trigger descriptors -------------------------------------------------
|
|
31
|
+
Trigger = Struct.new(:mode, :names) do
|
|
32
|
+
def satisfied_by?(completed)
|
|
33
|
+
case mode
|
|
34
|
+
when :single, :or then names.any? { |n| completed.include?(n) }
|
|
35
|
+
when :and then names.all? { |n| completed.include?(n) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# --- Class-level DSL -----------------------------------------------------
|
|
41
|
+
class << self
|
|
42
|
+
def start_methods
|
|
43
|
+
@start_methods ||= []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# name => Trigger. Populated when a listen/router declaration is bound to
|
|
47
|
+
# the next defined method.
|
|
48
|
+
def listeners
|
|
49
|
+
@listeners ||= {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def routers
|
|
53
|
+
@routers ||= {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def start(method_name)
|
|
57
|
+
start_methods << method_name.to_sym
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def listen(trigger)
|
|
61
|
+
@pending = [:listen, normalize_trigger(trigger)]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def router(trigger)
|
|
65
|
+
@pending = [:router, normalize_trigger(trigger)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def or_(*names)
|
|
69
|
+
Trigger.new(:or, names.map(&:to_sym))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def and_(*names)
|
|
73
|
+
Trigger.new(:and, names.map(&:to_sym))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Binds a pending listen/router declaration to the method just defined.
|
|
77
|
+
def method_added(method_name)
|
|
78
|
+
super
|
|
79
|
+
return unless @pending
|
|
80
|
+
|
|
81
|
+
kind, trigger = @pending
|
|
82
|
+
@pending = nil
|
|
83
|
+
case kind
|
|
84
|
+
when :listen then listeners[method_name.to_sym] = trigger
|
|
85
|
+
when :router then routers[method_name.to_sym] = trigger
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Merge inherited declarations so subclasses of a Flow subclass compose.
|
|
90
|
+
def inherited(subclass)
|
|
91
|
+
super
|
|
92
|
+
subclass.instance_variable_set(:@start_methods, start_methods.dup)
|
|
93
|
+
subclass.instance_variable_set(:@listeners, listeners.dup)
|
|
94
|
+
subclass.instance_variable_set(:@routers, routers.dup)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def normalize_trigger(trigger)
|
|
100
|
+
return trigger if trigger.is_a?(Trigger)
|
|
101
|
+
|
|
102
|
+
Trigger.new(:single, [trigger.to_sym])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- Instance ------------------------------------------------------------
|
|
107
|
+
attr_reader :state
|
|
108
|
+
|
|
109
|
+
def initialize(state_store: nil, feedback_handler: nil)
|
|
110
|
+
@state = State.new
|
|
111
|
+
@state_store = state_store
|
|
112
|
+
@feedback_handler = feedback_handler
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A pause point for human feedback. Calls the configured feedback_handler
|
|
116
|
+
# with the prompt and returns its response; without a handler, prompts on
|
|
117
|
+
# the console. Mirrors CrewAI's @human_feedback.
|
|
118
|
+
def human_feedback(prompt)
|
|
119
|
+
return @feedback_handler.call(prompt) if @feedback_handler
|
|
120
|
+
|
|
121
|
+
require_relative 'human_input'
|
|
122
|
+
response = HumanInput.new.request_input(prompt)
|
|
123
|
+
response.is_a?(Hash) ? response[:input] : response
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Runs the flow to completion. Optional inputs seed the state.
|
|
127
|
+
def kickoff(inputs: {})
|
|
128
|
+
inputs.each { |k, v| @state[k] = v }
|
|
129
|
+
|
|
130
|
+
@completed = [] # method names that have finished
|
|
131
|
+
@outputs = {} # method name => return value
|
|
132
|
+
@router_labels = [] # labels emitted by routers, act as pseudo-triggers
|
|
133
|
+
|
|
134
|
+
self.class.start_methods.each { |m| run_method(m) }
|
|
135
|
+
drain_listeners
|
|
136
|
+
|
|
137
|
+
persist
|
|
138
|
+
@state
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Restores state previously persisted under +id+.
|
|
142
|
+
def restore(id)
|
|
143
|
+
raise FlowError, 'no state store configured' unless @state_store
|
|
144
|
+
|
|
145
|
+
hash = @state_store.load(id)
|
|
146
|
+
raise FlowError, "no persisted state for id #{id}" unless hash
|
|
147
|
+
|
|
148
|
+
@state = State.new(symbolize(hash))
|
|
149
|
+
@state
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def run_method(method_name)
|
|
155
|
+
trigger = self.class.listeners[method_name] || self.class.routers[method_name]
|
|
156
|
+
arg = trigger ? @outputs[last_trigger_name(trigger)] : nil
|
|
157
|
+
|
|
158
|
+
result = arity_for(method_name).zero? ? send(method_name) : send(method_name, arg)
|
|
159
|
+
|
|
160
|
+
@completed << method_name
|
|
161
|
+
@outputs[method_name] = result
|
|
162
|
+
|
|
163
|
+
# A router's return value becomes a label that listeners can trigger on.
|
|
164
|
+
@router_labels << result.to_sym if self.class.routers.key?(method_name) && result
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Repeatedly fire any listeners/routers whose triggers are now satisfied,
|
|
168
|
+
# until no new method runs (fixed point).
|
|
169
|
+
def drain_listeners
|
|
170
|
+
reactive = self.class.listeners.merge(self.class.routers)
|
|
171
|
+
loop do
|
|
172
|
+
ran = false
|
|
173
|
+
reactive.each do |method_name, trigger|
|
|
174
|
+
next if fired_enough?(method_name, trigger)
|
|
175
|
+
|
|
176
|
+
if trigger.satisfied_by?(satisfied_set)
|
|
177
|
+
run_listener(method_name, trigger)
|
|
178
|
+
ran = true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
break unless ran
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# For :single/:or triggers we fire once per completed trigger name; for :and
|
|
186
|
+
# we fire once. Track how many times each listener has fired.
|
|
187
|
+
def run_listener(method_name, trigger)
|
|
188
|
+
@fired ||= Hash.new { |h, k| h[k] = [] }
|
|
189
|
+
|
|
190
|
+
case trigger.mode
|
|
191
|
+
when :and
|
|
192
|
+
@fired[method_name] << :once
|
|
193
|
+
invoke_listener(method_name, @outputs[trigger.names.last])
|
|
194
|
+
else
|
|
195
|
+
pending = trigger.names.select { |n| satisfied_set.include?(n) } - @fired[method_name]
|
|
196
|
+
pending.each do |name|
|
|
197
|
+
@fired[method_name] << name
|
|
198
|
+
invoke_listener(method_name, @outputs[name])
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def invoke_listener(method_name, arg)
|
|
204
|
+
result = arity_for(method_name).zero? ? send(method_name) : send(method_name, arg)
|
|
205
|
+
@completed << method_name
|
|
206
|
+
@outputs[method_name] = result
|
|
207
|
+
@router_labels << result.to_sym if self.class.routers.key?(method_name) && result
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def fired_enough?(method_name, trigger)
|
|
211
|
+
@fired ||= Hash.new { |h, k| h[k] = [] }
|
|
212
|
+
case trigger.mode
|
|
213
|
+
when :and then @fired[method_name].any?
|
|
214
|
+
else (trigger.names & satisfied_set).all? { |n| @fired[method_name].include?(n) }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Names available to satisfy triggers: completed methods + router labels.
|
|
219
|
+
def satisfied_set
|
|
220
|
+
@completed + @router_labels
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def last_trigger_name(trigger)
|
|
224
|
+
(trigger.names & @completed).last || trigger.names.last
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def arity_for(method_name)
|
|
228
|
+
method(method_name).arity
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def persist
|
|
232
|
+
return unless @state_store
|
|
233
|
+
|
|
234
|
+
@state_store.save(@state.id, @state.to_h)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def symbolize(hash)
|
|
238
|
+
hash.transform_keys(&:to_sym)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
class FlowError < Error; end
|
|
243
|
+
end
|