rcrewai 0.4.0 → 0.6.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
@@ -23,16 +23,33 @@ module RCrewAI
23
23
  @planning_llm = options[:planning_llm]
24
24
  @planned = false
25
25
  @knowledge = build_knowledge(options[:knowledge], options[:knowledge_sources])
26
+ @before_kickoff_hooks = []
27
+ @after_kickoff_hooks = []
28
+ @last_inputs = {}
26
29
  @process_instance = nil
27
30
  validate_process_type!
28
31
  end
29
32
 
30
- attr_reader :knowledge, :stream_sink
33
+ attr_reader :knowledge, :stream_sink, :last_inputs
31
34
 
32
35
  def planning?
33
36
  @planning
34
37
  end
35
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
+
36
53
  def add_agent(agent)
37
54
  @agents << agent
38
55
  end
@@ -41,20 +58,25 @@ module RCrewAI
41
58
  @tasks << task
42
59
  end
43
60
 
44
- def execute(async: false, stream: nil, **async_options, &block)
61
+ def execute(async: false, stream: nil, inputs: {}, **async_options, &block)
45
62
  sinks = []
46
63
  sinks << block if block_given?
47
64
  Array(stream).each { |s| sinks << s } if stream
48
65
  @stream_sink = sinks.empty? ? nil : RCrewAI::Events.fan_out(sinks)
49
66
 
67
+ run_before_hooks(inputs)
68
+
50
69
  distribute_knowledge if @knowledge
51
70
  run_planning_pass if planning?
52
71
 
53
- if async
54
- execute_async(**async_options)
55
- else
56
- execute_sync
57
- end
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) }
58
80
  end
59
81
 
60
82
  # Runs the crew repeatedly, collecting feedback after each iteration and
@@ -144,6 +166,22 @@ module RCrewAI
144
166
 
145
167
  private
146
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
+
147
185
  def build_knowledge(knowledge, sources)
148
186
  return knowledge if knowledge
149
187
  return nil if sources.nil? || sources.empty?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../similarity'
4
+
3
5
  module RCrewAI
4
6
  module Knowledge
5
7
  # In-memory vector store with cosine-similarity search. The default backing
@@ -38,20 +40,8 @@ module RCrewAI
38
40
 
39
41
  private
40
42
 
41
- def cosine_similarity(a, b)
42
- dot = 0.0
43
- norm_a = 0.0
44
- norm_b = 0.0
45
- a.each_index do |i|
46
- ai = a[i].to_f
47
- bi = (b[i] || 0).to_f
48
- dot += ai * bi
49
- norm_a += ai * ai
50
- norm_b += bi * bi
51
- end
52
- return 0.0 if norm_a.zero? || norm_b.zero?
53
-
54
- dot / (Math.sqrt(norm_a) * Math.sqrt(norm_b))
43
+ def cosine_similarity(vec_a, vec_b)
44
+ Similarity.cosine(vec_a, vec_b)
55
45
  end
56
46
  end
57
47
  end
@@ -30,7 +30,7 @@ module RCrewAI
30
30
  iter += 1
31
31
  emit(Events::IterationStart, iteration: iter, iteration_index: iter)
32
32
 
33
- response = @llm.chat(messages: msgs)
33
+ response = @llm.chat(messages: fit_context(msgs))
34
34
  accumulate_usage(total_usage, response[:usage])
35
35
  reasoning = response[:content] || ''
36
36
  last_reasoning = reasoning
@@ -60,6 +60,12 @@ module RCrewAI
60
60
 
61
61
  private
62
62
 
63
+ # Trims the message list to the model's context window when the agent
64
+ # supports it; a no-op otherwise.
65
+ def fit_context(messages)
66
+ @agent.respond_to?(:fit_context) ? @agent.fit_context(messages) : messages
67
+ end
68
+
63
69
  def parse_and_execute_actions(reasoning, iter)
64
70
  results = []
65
71
  iteration_history = []
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative '../similarity'
5
+ require_relative 'in_memory_store'
6
+
7
+ module RCrewAI
8
+ class Memory
9
+ # Shared behavior for the memory types: embed-on-write (when an embedder is
10
+ # present) and semantic recall with a lexical fallback when it isn't.
11
+ # Records are namespaced per (agent) scope + a type suffix so different
12
+ # memory types don't collide in a shared store.
13
+ class BaseMemory
14
+ def initialize(scope:, embedder: nil, store: nil, limit: nil)
15
+ @scope = "#{scope}:#{type_suffix}"
16
+ @embedder = embedder
17
+ @store = store || InMemoryStore.new
18
+ @limit = limit
19
+ @seq = 0
20
+ end
21
+
22
+ def record(text, metadata = {})
23
+ add(text, metadata)
24
+ end
25
+
26
+ def recall(query, limit: 3)
27
+ records = search_records(query, limit)
28
+ records.map { |r| format(r) }
29
+ end
30
+
31
+ def count
32
+ @store.all(scope: @scope).length
33
+ end
34
+
35
+ def clear!
36
+ @store.delete(scope: @scope)
37
+ end
38
+
39
+ protected
40
+
41
+ # Subclasses override to namespace their records.
42
+ def type_suffix
43
+ 'base'
44
+ end
45
+
46
+ def format(record)
47
+ { text: record[:text], metadata: record[:metadata] }
48
+ end
49
+
50
+ def add(text, metadata)
51
+ vector = embed(text)
52
+ id = next_id(text)
53
+ @store.add(id: id, text: text, vector: vector, scope: @scope, metadata: stringify(metadata))
54
+ evict_if_needed
55
+ id
56
+ end
57
+
58
+ def search_records(query, limit)
59
+ all = @store.all(scope: @scope)
60
+ return [] if all.empty?
61
+
62
+ query_vector = embed(query)
63
+ if query_vector && all.any? { |r| r[:vector] }
64
+ @store.search(query_vector, k: limit, scope: @scope)
65
+ else
66
+ lexical_search(query, all, limit)
67
+ end
68
+ end
69
+
70
+ def lexical_search(query, records, limit)
71
+ records
72
+ .map { |r| [r, Similarity.lexical(query, r[:text])] }
73
+ .sort_by { |(_r, score)| -score }
74
+ .first(limit)
75
+ .map(&:first)
76
+ end
77
+
78
+ def embed(text)
79
+ return nil unless @embedder
80
+
81
+ @embedder.embed([text]).first
82
+ rescue StandardError
83
+ nil # embedding is best-effort; fall back to lexical
84
+ end
85
+
86
+ def evict_if_needed
87
+ return unless @limit
88
+
89
+ records = @store.all(scope: @scope)
90
+ return if records.length <= @limit
91
+
92
+ # records carry a monotonic :seq in metadata; drop the oldest.
93
+ oldest = records.min_by { |r| r[:metadata]['seq'].to_i }
94
+ @store.delete_record(id: oldest[:id], scope: @scope) if @store.respond_to?(:delete_record)
95
+ end
96
+
97
+ def next_id(text)
98
+ @seq += 1
99
+ Digest::SHA256.hexdigest("#{@scope}:#{@seq}:#{text}")[0, 24]
100
+ end
101
+
102
+ def stringify(metadata)
103
+ (metadata || {}).transform_keys(&:to_s).merge('seq' => @seq)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_memory'
4
+
5
+ module RCrewAI
6
+ class Memory
7
+ # Facts about entities (people, systems, concepts) accumulated from work.
8
+ # Entities are extracted heuristically (capitalized tokens / acronyms); an
9
+ # LLM-backed extractor can be swapped in later.
10
+ class EntityMemory < BaseMemory
11
+ # Skip sentence-initial common words that happen to be capitalized.
12
+ COMMON = %w[The A An I In On At To For Of With By And Or But It This That Who Where When].freeze
13
+
14
+ def initialize(scope:, embedder: nil, store: nil, limit: nil)
15
+ super
16
+ @entities = []
17
+ end
18
+
19
+ # Records a full observation and indexes the entities it mentions.
20
+ def observe(text)
21
+ found = extract_entities(text)
22
+ @entities.concat(found)
23
+ add(text, { 'entities' => found })
24
+ end
25
+
26
+ def entities
27
+ @entities.uniq
28
+ end
29
+
30
+ protected
31
+
32
+ def type_suffix
33
+ 'entity'
34
+ end
35
+
36
+ def format(record)
37
+ record[:text]
38
+ end
39
+
40
+ private
41
+
42
+ def extract_entities(text)
43
+ text.to_s.scan(/\b([A-Z][a-zA-Z0-9]{1,})\b/).flatten.reject { |w| COMMON.include?(w) }.uniq
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../similarity'
4
+
5
+ module RCrewAI
6
+ class Memory
7
+ # Default, volatile vector store: records live in a Hash keyed by scope.
8
+ # Records: { id:, text:, vector:, metadata: }. Cosine search in Ruby.
9
+ class InMemoryStore
10
+ def initialize
11
+ @scopes = Hash.new { |h, k| h[k] = {} }
12
+ end
13
+
14
+ def add(id:, text:, vector:, scope:, metadata: {})
15
+ @scopes[scope][id] = { id: id, text: text, vector: vector, metadata: metadata || {} }
16
+ end
17
+
18
+ def all(scope:)
19
+ @scopes[scope].values
20
+ end
21
+
22
+ def search(vector, k:, scope:)
23
+ @scopes[scope].values
24
+ .reject { |r| r[:vector].nil? }
25
+ .map { |r| [r, Similarity.cosine(vector, r[:vector])] }
26
+ .sort_by { |(_r, score)| -score }
27
+ .first(k)
28
+ .map(&:first)
29
+ end
30
+
31
+ def delete(scope:)
32
+ @scopes.delete(scope)
33
+ end
34
+
35
+ def delete_record(id:, scope:)
36
+ @scopes[scope].delete(id)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_memory'
4
+
5
+ module RCrewAI
6
+ class Memory
7
+ # Durable insights promoted from successful executions. Dedupes
8
+ # near-identical insights so the store doesn't fill with paraphrases.
9
+ class LongTermMemory < BaseMemory
10
+ DEDUPE_THRESHOLD = 0.92
11
+
12
+ def record(text, metadata = {})
13
+ return nil if duplicate?(text)
14
+
15
+ add(text, metadata)
16
+ end
17
+
18
+ protected
19
+
20
+ def type_suffix
21
+ 'long_term'
22
+ end
23
+
24
+ private
25
+
26
+ def duplicate?(text)
27
+ existing = @store.all(scope: @scope)
28
+ return false if existing.empty?
29
+
30
+ query_vector = embed(text)
31
+ if query_vector && existing.any? { |r| r[:vector] }
32
+ existing.any? { |r| r[:vector] && Similarity.cosine(query_vector, r[:vector]) >= DEDUPE_THRESHOLD }
33
+ else
34
+ existing.any? { |r| Similarity.lexical(text, r[:text]) >= DEDUPE_THRESHOLD }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_memory'
4
+
5
+ module RCrewAI
6
+ class Memory
7
+ # Recent executions with semantic recall. Capped and volatile-by-default.
8
+ class ShortTermMemory < BaseMemory
9
+ def initialize(scope:, embedder: nil, store: nil, limit: 100)
10
+ super
11
+ end
12
+
13
+ protected
14
+
15
+ def type_suffix
16
+ 'short_term'
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../similarity'
5
+
6
+ module RCrewAI
7
+ class Memory
8
+ # Persistent vector store backed by SQLite. Vectors are packed as
9
+ # little-endian floats; metadata is JSON. Cosine is computed in Ruby over
10
+ # rows filtered by scope — adequate for the thousands-of-memories scale an
11
+ # agent produces; an ANN index is a later optimization.
12
+ #
13
+ # The sqlite3 gem is required lazily so the rest of the library (and the
14
+ # in-memory store) works even if it isn't installed.
15
+ class SqliteStore
16
+ DEFAULT_PATH = File.join(Dir.home, '.rcrewai', 'memory.db')
17
+
18
+ def initialize(path: DEFAULT_PATH)
19
+ require 'sqlite3'
20
+ ensure_parent_dir(path) unless path == ':memory:'
21
+ @db = SQLite3::Database.new(path)
22
+ @db.results_as_hash = true
23
+ create_schema
24
+ end
25
+
26
+ def add(id:, text:, vector:, scope:, metadata: {})
27
+ @db.execute(
28
+ 'INSERT INTO memories (id, scope, text, vector, metadata) VALUES (?, ?, ?, ?, ?) ' \
29
+ 'ON CONFLICT(id) DO UPDATE SET scope=excluded.scope, text=excluded.text, ' \
30
+ 'vector=excluded.vector, metadata=excluded.metadata',
31
+ [id, scope, text, pack_vector(vector), JSON.generate(metadata || {})]
32
+ )
33
+ end
34
+
35
+ def all(scope:)
36
+ @db.execute('SELECT * FROM memories WHERE scope = ?', [scope]).map { |row| to_record(row) }
37
+ end
38
+
39
+ def search(vector, k:, scope:)
40
+ all(scope: scope)
41
+ .reject { |r| r[:vector].nil? }
42
+ .map { |r| [r, Similarity.cosine(vector, r[:vector])] }
43
+ .sort_by { |(_r, score)| -score }
44
+ .first(k)
45
+ .map(&:first)
46
+ end
47
+
48
+ def delete(scope:)
49
+ @db.execute('DELETE FROM memories WHERE scope = ?', [scope])
50
+ end
51
+
52
+ def delete_record(id:, scope:)
53
+ @db.execute('DELETE FROM memories WHERE id = ? AND scope = ?', [id, scope])
54
+ end
55
+
56
+ private
57
+
58
+ def create_schema
59
+ @db.execute(<<~SQL)
60
+ CREATE TABLE IF NOT EXISTS memories (
61
+ id TEXT PRIMARY KEY,
62
+ scope TEXT NOT NULL,
63
+ text TEXT,
64
+ vector BLOB,
65
+ metadata TEXT,
66
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
67
+ )
68
+ SQL
69
+ @db.execute('CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories (scope)')
70
+ end
71
+
72
+ def ensure_parent_dir(path)
73
+ require 'fileutils'
74
+ FileUtils.mkdir_p(File.dirname(path))
75
+ end
76
+
77
+ def pack_vector(vector)
78
+ return nil if vector.nil?
79
+
80
+ vector.map(&:to_f).pack('e*')
81
+ end
82
+
83
+ def unpack_vector(blob)
84
+ return nil if blob.nil?
85
+
86
+ blob.unpack('e*')
87
+ end
88
+
89
+ def to_record(row)
90
+ {
91
+ id: row['id'],
92
+ text: row['text'],
93
+ vector: unpack_vector(row['vector']),
94
+ metadata: JSON.parse(row['metadata'] || '{}')
95
+ }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_memory'
4
+
5
+ module RCrewAI
6
+ class Memory
7
+ # Tool-call history and outcomes. Replaces the old @tool_usage array;
8
+ # persistable and searchable like the other memory types.
9
+ class ToolMemory < BaseMemory
10
+ def record_call(tool_name, params, result)
11
+ success = !result.to_s.downcase.include?('error')
12
+ text = "#{tool_name}(#{format_params(params)}) -> #{result}"
13
+ add(text, { 'tool' => tool_name, 'success' => success, 'result' => result.to_s })
14
+ end
15
+
16
+ # Most-recent-first usage records for a given tool.
17
+ def usage_for(tool_name, limit: 5)
18
+ @store.all(scope: @scope)
19
+ .select { |r| r[:metadata]['tool'] == tool_name }
20
+ .sort_by { |r| -r[:metadata]['seq'].to_i }
21
+ .first(limit)
22
+ .map { |r| r[:text] }
23
+ end
24
+
25
+ protected
26
+
27
+ def type_suffix
28
+ 'tool'
29
+ end
30
+
31
+ private
32
+
33
+ def format_params(params)
34
+ (params || {}).map { |k, v| "#{k}=#{v}" }.join(', ')
35
+ end
36
+ end
37
+ end
38
+ end