rcrewai 0.3.0 → 0.5.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.
data/lib/rcrewai/crew.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
3
4
  require_relative 'process'
4
5
  require_relative 'async_executor'
5
6
  require_relative 'events'
7
+ require_relative 'planning'
6
8
 
7
9
  module RCrewAI
8
10
  class Crew
@@ -17,10 +19,37 @@ module RCrewAI
17
19
  @process_type = options.fetch(:process, :sequential)
18
20
  @verbose = options.fetch(:verbose, false)
19
21
  @max_iterations = options.fetch(:max_iterations, 10)
22
+ @planning = options.fetch(:planning, false)
23
+ @planning_llm = options[:planning_llm]
24
+ @planned = false
25
+ @knowledge = build_knowledge(options[:knowledge], options[:knowledge_sources])
26
+ @before_kickoff_hooks = []
27
+ @after_kickoff_hooks = []
28
+ @last_inputs = {}
20
29
  @process_instance = nil
21
30
  validate_process_type!
22
31
  end
23
32
 
33
+ attr_reader :knowledge, :stream_sink, :last_inputs
34
+
35
+ def planning?
36
+ @planning
37
+ end
38
+
39
+ # Register a callback run before execution. Receives the inputs hash and may
40
+ # return a transformed hash. Multiple hooks run in registration order.
41
+ def before_kickoff(&block)
42
+ @before_kickoff_hooks << block
43
+ self
44
+ end
45
+
46
+ # Register a callback run after execution. Receives the result and may
47
+ # return a transformed result. Multiple hooks run in registration order.
48
+ def after_kickoff(&block)
49
+ @after_kickoff_hooks << block
50
+ self
51
+ end
52
+
24
53
  def add_agent(agent)
25
54
  @agents << agent
26
55
  end
@@ -29,20 +58,55 @@ module RCrewAI
29
58
  @tasks << task
30
59
  end
31
60
 
32
- def execute(async: false, stream: nil, **async_options, &block)
61
+ def execute(async: false, stream: nil, inputs: {}, **async_options, &block)
33
62
  sinks = []
34
63
  sinks << block if block_given?
35
64
  Array(stream).each { |s| sinks << s } if stream
36
65
  @stream_sink = sinks.empty? ? nil : RCrewAI::Events.fan_out(sinks)
37
66
 
38
- if async
39
- execute_async(**async_options)
40
- else
41
- execute_sync
67
+ run_before_hooks(inputs)
68
+
69
+ distribute_knowledge if @knowledge
70
+ run_planning_pass if planning?
71
+
72
+ result = async ? execute_async(**async_options) : execute_sync
73
+ run_after_hooks(result)
74
+ end
75
+
76
+ # Runs the crew once per input set, returning one result per input in order.
77
+ # Runs are isolated: each execution starts from only its own inputs.
78
+ def kickoff_for_each(inputs:)
79
+ Array(inputs).map { |input| execute(inputs: input) }
80
+ end
81
+
82
+ # Runs the crew repeatedly, collecting feedback after each iteration and
83
+ # persisting it to +filename+ as JSON. +feedback+ is a callable
84
+ # ->(iteration, result) { "..." }; it defaults to prompting a human.
85
+ # Mirrors CrewAI's crew.train.
86
+ def train(n_iterations:, filename:, feedback: nil)
87
+ feedback ||= method(:default_training_feedback)
88
+ entries = []
89
+
90
+ (1..n_iterations).each do |iteration|
91
+ result = execute
92
+ note = feedback.call(iteration, result)
93
+ entries << { iteration: iteration, feedback: note }
42
94
  end
95
+
96
+ write_training_file(filename, entries)
97
+ { iterations: n_iterations, filename: filename, entries: entries }
43
98
  end
44
99
 
45
- attr_reader :stream_sink
100
+ # Runs the crew repeatedly and scores each run. +scorer+ is a callable
101
+ # ->(result) { Float }; it defaults to the run's success_rate.
102
+ # Mirrors CrewAI's crew.test.
103
+ def test(n_iterations:, scorer: nil, model: nil) # rubocop:disable Lint/UnusedMethodArgument
104
+ scorer ||= ->(result) { result[:success_rate].to_f }
105
+ scores = (1..n_iterations).map { scorer.call(execute) }
106
+ average = scores.empty? ? 0.0 : (scores.sum / scores.length).round(2)
107
+
108
+ { iterations: n_iterations, scores: scores, average_score: average }
109
+ end
46
110
 
47
111
  def execute_async(**options)
48
112
  puts "Executing crew: #{name} (async #{process_type} process)"
@@ -102,6 +166,58 @@ module RCrewAI
102
166
 
103
167
  private
104
168
 
169
+ def run_before_hooks(inputs)
170
+ # Assign before running hooks so a hook that reads #last_inputs sees this
171
+ # run's own inputs; update it as each hook transforms them.
172
+ @last_inputs = inputs || {}
173
+ @before_kickoff_hooks.each do |hook|
174
+ @last_inputs = hook.call(@last_inputs) || @last_inputs
175
+ end
176
+ @last_inputs
177
+ end
178
+
179
+ def run_after_hooks(result)
180
+ @after_kickoff_hooks.reduce(result) do |acc, hook|
181
+ hook.call(acc) || acc
182
+ end
183
+ end
184
+
185
+ def build_knowledge(knowledge, sources)
186
+ return knowledge if knowledge
187
+ return nil if sources.nil? || sources.empty?
188
+
189
+ Knowledge::Base.new(sources: sources)
190
+ end
191
+
192
+ def distribute_knowledge
193
+ @knowledge.build!
194
+ agents.each { |agent| agent.crew_knowledge = @knowledge if agent.respond_to?(:crew_knowledge=) }
195
+ end
196
+
197
+ def default_training_feedback(iteration, _result)
198
+ require_relative 'human_input'
199
+ response = HumanInput.new.request_input(
200
+ "Feedback for training iteration #{iteration} (press enter to skip):"
201
+ )
202
+ response.is_a?(Hash) ? response[:input].to_s : response.to_s
203
+ end
204
+
205
+ def write_training_file(filename, entries)
206
+ require 'json'
207
+ require 'fileutils'
208
+ FileUtils.mkdir_p(File.dirname(filename))
209
+ File.write(filename, JSON.pretty_generate(entries))
210
+ end
211
+
212
+ def run_planning_pass
213
+ return if @planned
214
+
215
+ logger = Logger.new($stdout)
216
+ logger.level = verbose ? Logger::DEBUG : Logger::INFO
217
+ Planning.new(self, llm: LLMClient.resolve(@planning_llm), logger: logger).plan!
218
+ @planned = true
219
+ end
220
+
105
221
  def validate_process_type!
106
222
  valid_processes = %i[sequential hierarchical consensual]
107
223
  return if valid_processes.include?(process_type)
@@ -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
@@ -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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'chunker'
4
+ require_relative 'store'
5
+ require_relative 'sources'
6
+ require_relative 'embedder'
7
+
8
+ module RCrewAI
9
+ module Knowledge
10
+ # A knowledge base: loads sources, chunks their text, embeds the chunks, and
11
+ # answers similarity queries. Attach one to an Agent (role-specific) or a
12
+ # Crew (shared) via the +knowledge_sources:+ option.
13
+ class Base
14
+ attr_reader :sources
15
+
16
+ def initialize(sources: [], embedder: nil, chunk_size: 1000, overlap: 100)
17
+ @sources = Array(sources)
18
+ @embedder = embedder || Embedder.new
19
+ @chunker = Chunker.new(chunk_size: chunk_size, overlap: overlap)
20
+ @store = Store.new
21
+ @built = false
22
+ end
23
+
24
+ # Loads, chunks, and embeds all sources. Idempotent.
25
+ def build!
26
+ return self if @built
27
+
28
+ chunks = @sources.flat_map { |source| @chunker.chunk(source.read) }
29
+ unless chunks.empty?
30
+ vectors = @embedder.embed(chunks)
31
+ chunks.zip(vectors).each { |text, vector| @store.add(text, vector) }
32
+ end
33
+
34
+ @built = true
35
+ self
36
+ end
37
+
38
+ # Returns up to k chunks most relevant to the query string.
39
+ def search(query, k: 3)
40
+ build! unless @built
41
+ return [] if @store.empty?
42
+
43
+ query_vector = @embedder.embed([query]).first
44
+ @store.search(query_vector, k: k)
45
+ end
46
+
47
+ def empty?
48
+ @sources.empty?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCrewAI
4
+ module Knowledge
5
+ # Splits text into fixed-size, overlapping character windows. Overlap keeps
6
+ # context from spilling across chunk boundaries during retrieval.
7
+ class Chunker
8
+ def initialize(chunk_size: 1000, overlap: 100)
9
+ raise ArgumentError, 'overlap must be smaller than chunk_size' if overlap >= chunk_size
10
+
11
+ @chunk_size = chunk_size
12
+ @overlap = overlap
13
+ end
14
+
15
+ def chunk(text)
16
+ text = text.to_s
17
+ return [] if text.empty?
18
+ return [text] if text.length <= @chunk_size
19
+
20
+ chunks = []
21
+ start = 0
22
+ step = @chunk_size - @overlap
23
+ while start < text.length
24
+ chunks << text[start, @chunk_size]
25
+ start += step
26
+ end
27
+ chunks
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'faraday'
5
+
6
+ module RCrewAI
7
+ module Knowledge
8
+ # Turns text into embedding vectors. Defaults to OpenAI's embeddings API;
9
+ # #embed takes an array of strings and returns an array of vectors. Any
10
+ # object responding to #embed can be substituted (see specs).
11
+ class Embedder
12
+ DEFAULT_MODEL = 'text-embedding-3-small'
13
+ OPENAI_URL = 'https://api.openai.com/v1/embeddings'
14
+
15
+ def initialize(model: DEFAULT_MODEL, api_key: nil, config: RCrewAI.configuration)
16
+ @model = model
17
+ @api_key = api_key || config.openai_api_key || config.api_key
18
+ end
19
+
20
+ def embed(texts)
21
+ texts = Array(texts)
22
+ return [] if texts.empty?
23
+
24
+ response = connection.post(OPENAI_URL) do |req|
25
+ req.headers['Authorization'] = "Bearer #{@api_key}"
26
+ req.headers['Content-Type'] = 'application/json'
27
+ req.body = JSON.generate(model: @model, input: texts)
28
+ end
29
+
30
+ raise EmbeddingError, "embedding request failed: #{response.status}" unless response.success?
31
+
32
+ body = response.body
33
+ body = JSON.parse(body) if body.is_a?(String)
34
+ body['data'].map { |d| d['embedding'] }
35
+ end
36
+
37
+ private
38
+
39
+ def connection
40
+ @connection ||= Faraday.new do |f|
41
+ f.adapter Faraday.default_adapter
42
+ end
43
+ end
44
+ end
45
+
46
+ class EmbeddingError < RCrewAI::Error; end
47
+ end
48
+ end