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.
- checksums.yaml +4 -4
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +55 -1
- data/README.md +250 -0
- data/ROADMAP.md +90 -0
- data/docs/upgrading-to-0.4.md +191 -0
- data/examples/flow_example.rb +89 -0
- data/examples/knowledge_rag_example.rb +72 -0
- data/examples/planning_and_training_example.rb +72 -0
- data/examples/structured_output_example.rb +92 -0
- data/lib/rcrewai/agent.rb +72 -6
- data/lib/rcrewai/agent_augmentations.rb +75 -0
- data/lib/rcrewai/configuration.rb +20 -0
- data/lib/rcrewai/context_window.rb +75 -0
- data/lib/rcrewai/crew.rb +122 -6
- 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/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 +7 -1
- data/lib/rcrewai/llm_client.rb +23 -0
- data/lib/rcrewai/multimodal.rb +67 -0
- data/lib/rcrewai/output_schema.rb +79 -0
- data/lib/rcrewai/planning.rb +65 -0
- data/lib/rcrewai/rate_limiter.rb +94 -0
- data/lib/rcrewai/task.rb +90 -2
- data/lib/rcrewai/tool_runner.rb +7 -1
- data/lib/rcrewai/version.rb +1 -1
- data/lib/rcrewai.rb +5 -0
- metadata +22 -1
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
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
|
|
@@ -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
|