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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -1
- data/README.md +108 -1
- data/ROADMAP.md +15 -10
- data/docs/superpowers/specs/2026-07-06-cognitive-memory-design.md +118 -0
- data/docs/upgrading-to-0.4.md +191 -0
- data/docs/upgrading-to-0.6.md +101 -0
- data/examples/cognitive_memory_example.rb +66 -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 +50 -7
- data/lib/rcrewai/agent_augmentations.rb +75 -0
- data/lib/rcrewai/context_window.rb +75 -0
- data/lib/rcrewai/crew.rb +45 -7
- data/lib/rcrewai/knowledge/store.rb +4 -14
- data/lib/rcrewai/legacy_react_runner.rb +7 -1
- data/lib/rcrewai/memory/base_memory.rb +107 -0
- data/lib/rcrewai/memory/entity_memory.rb +47 -0
- data/lib/rcrewai/memory/in_memory_store.rb +40 -0
- data/lib/rcrewai/memory/long_term_memory.rb +39 -0
- data/lib/rcrewai/memory/short_term_memory.rb +20 -0
- data/lib/rcrewai/memory/sqlite_store.rb +99 -0
- data/lib/rcrewai/memory/tool_memory.rb +38 -0
- data/lib/rcrewai/memory.rb +57 -160
- data/lib/rcrewai/multimodal.rb +67 -0
- data/lib/rcrewai/rate_limiter.rb +94 -0
- data/lib/rcrewai/similarity.rb +43 -0
- data/lib/rcrewai/task.rb +2 -1
- data/lib/rcrewai/tool_runner.rb +7 -1
- data/lib/rcrewai/version.rb +1 -1
- data/lib/rcrewai.rb +11 -0
- data/rcrewai.gemspec +1 -0
- metadata +35 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Cognitive Memory — semantic recall + persistence.
|
|
5
|
+
#
|
|
6
|
+
# Agents remember past executions and recall them by *meaning*, not just shared
|
|
7
|
+
# words. With a SQLite store, memory survives restarts.
|
|
8
|
+
#
|
|
9
|
+
# This example uses a fake, deterministic embedder so it runs WITHOUT an API
|
|
10
|
+
# key. In real use you'd pass `RCrewAI::Knowledge::Embedder.new` (OpenAI).
|
|
11
|
+
#
|
|
12
|
+
# Run:
|
|
13
|
+
# ruby examples/cognitive_memory_example.rb
|
|
14
|
+
|
|
15
|
+
require_relative '../lib/rcrewai'
|
|
16
|
+
require 'tmpdir'
|
|
17
|
+
|
|
18
|
+
RCrewAI.configure(validate: false) do |c|
|
|
19
|
+
c.llm_provider = :openai
|
|
20
|
+
c.api_key = 'demo-key'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Fake embedder: maps text to a concept vector by keyword. Any object
|
|
24
|
+
# responding to embed(texts) -> [[float, ...], ...] works.
|
|
25
|
+
class ConceptEmbedder
|
|
26
|
+
def embed(texts)
|
|
27
|
+
texts.map do |t|
|
|
28
|
+
l = t.downcase
|
|
29
|
+
[
|
|
30
|
+
l.match?(/payment|billing|invoice|charge/) ? 1.0 : 0.0,
|
|
31
|
+
l.match?(/auth|login|session|token/) ? 1.0 : 0.0,
|
|
32
|
+
l.match?(/deploy|release|ci|pipeline/) ? 1.0 : 0.0
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Task = Struct.new(:name, :description)
|
|
39
|
+
|
|
40
|
+
Dir.mktmpdir do |dir|
|
|
41
|
+
store = RCrewAI::Memory::SqliteStore.new(path: File.join(dir, 'memory.db'))
|
|
42
|
+
agent = RCrewAI::Agent.new(
|
|
43
|
+
name: 'engineer', role: 'Senior engineer', goal: 'Ship reliable software',
|
|
44
|
+
memory: { embedder: ConceptEmbedder.new, store: store }
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Record a few past executions.
|
|
48
|
+
agent.memory.add_execution(Task.new('t1', 'fixed the billing invoice bug'),
|
|
49
|
+
'adjusted the payment retry logic', 1.2)
|
|
50
|
+
agent.memory.add_execution(Task.new('t2', 'patched the login session flow'),
|
|
51
|
+
'rotated the auth tokens', 0.8)
|
|
52
|
+
agent.memory.add_execution(Task.new('t3', 'sped up the release pipeline'),
|
|
53
|
+
'parallelized the CI stages', 2.0)
|
|
54
|
+
|
|
55
|
+
puts '== Semantic recall =='
|
|
56
|
+
# Query shares NO words with the billing execution, but is conceptually close.
|
|
57
|
+
query = Task.new('q', 'a customer got double-charged on checkout')
|
|
58
|
+
puts agent.memory.relevant_executions(query, 1)
|
|
59
|
+
|
|
60
|
+
puts "\n== Persistence (reopen the DB in a fresh agent) =="
|
|
61
|
+
store2 = RCrewAI::Memory::SqliteStore.new(path: File.join(dir, 'memory.db'))
|
|
62
|
+
agent2 = RCrewAI::Agent.new(name: 'engineer', role: 'r', goal: 'g',
|
|
63
|
+
memory: { embedder: ConceptEmbedder.new, store: store2 })
|
|
64
|
+
puts agent2.memory.relevant_executions(query, 1)
|
|
65
|
+
puts "stats: #{agent2.memory.stats.inspect}"
|
|
66
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Flows — event-driven workflows (RCrewAI's second pillar).
|
|
5
|
+
#
|
|
6
|
+
# Subclass RCrewAI::Flow and wire methods together with the class-level DSL:
|
|
7
|
+
# `start` kicks things off, `listen` reacts to another method's output, and
|
|
8
|
+
# `router` branches by emitting a label that listeners can trigger on. State
|
|
9
|
+
# is a schemaless object with an automatic UUID, and can be persisted so a run
|
|
10
|
+
# can be resumed later.
|
|
11
|
+
#
|
|
12
|
+
# This example needs no API key — it demonstrates the engine itself.
|
|
13
|
+
#
|
|
14
|
+
# Run:
|
|
15
|
+
# ruby examples/flow_example.rb
|
|
16
|
+
|
|
17
|
+
require_relative '../lib/rcrewai'
|
|
18
|
+
|
|
19
|
+
# A tiny content pipeline: outline -> draft -> review (router) -> publish/expand.
|
|
20
|
+
class ArticleFlow < RCrewAI::Flow
|
|
21
|
+
start :outline
|
|
22
|
+
def outline
|
|
23
|
+
state.sections = %w[intro body conclusion]
|
|
24
|
+
state.sections.length # this return value is passed to listeners of :outline
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
listen :outline
|
|
28
|
+
def draft(section_count)
|
|
29
|
+
state.words = section_count * 100
|
|
30
|
+
state.words
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A router's return value (:publish / :expand) becomes a label that the
|
|
34
|
+
# matching `listen` methods fire on.
|
|
35
|
+
router :draft
|
|
36
|
+
def review(words)
|
|
37
|
+
words >= 250 ? :publish : :expand
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
listen :publish
|
|
41
|
+
def publish
|
|
42
|
+
state.status = 'published'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
listen :expand
|
|
46
|
+
def expand
|
|
47
|
+
state.status = 'needs more work'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
puts '== Basic run =='
|
|
52
|
+
flow = ArticleFlow.new
|
|
53
|
+
flow.kickoff(inputs: { author: 'Ada' })
|
|
54
|
+
puts "id: #{flow.state.id}"
|
|
55
|
+
puts "author: #{flow.state.author} (seeded via kickoff inputs)"
|
|
56
|
+
puts "sections: #{flow.state.sections.inspect}"
|
|
57
|
+
puts "words: #{flow.state.words}"
|
|
58
|
+
puts "status: #{flow.state.status.inspect} (routed to :publish since words >= 250)"
|
|
59
|
+
|
|
60
|
+
puts "\n== and_/or_ combinators =="
|
|
61
|
+
class GateFlow < RCrewAI::Flow
|
|
62
|
+
start :fetch_a
|
|
63
|
+
def fetch_a = 'A'
|
|
64
|
+
|
|
65
|
+
start :fetch_b
|
|
66
|
+
def fetch_b = 'B'
|
|
67
|
+
|
|
68
|
+
# Fires only after BOTH starts complete.
|
|
69
|
+
listen and_(:fetch_a, :fetch_b)
|
|
70
|
+
def merge
|
|
71
|
+
state.merged = 'both done'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
gate = GateFlow.new
|
|
76
|
+
gate.kickoff
|
|
77
|
+
puts "merged: #{gate.state.merged.inspect} (and_ waited for both starts)"
|
|
78
|
+
|
|
79
|
+
puts "\n== Persistence round-trip =="
|
|
80
|
+
require 'tmpdir'
|
|
81
|
+
store = RCrewAI::Flow::FileStateStore.new(File.join(Dir.tmpdir, 'rcrewai-flow-demo'))
|
|
82
|
+
|
|
83
|
+
original = ArticleFlow.new(state_store: store)
|
|
84
|
+
original.kickoff
|
|
85
|
+
id = original.state.id
|
|
86
|
+
|
|
87
|
+
resumed = ArticleFlow.new(state_store: store)
|
|
88
|
+
resumed.restore(id)
|
|
89
|
+
puts "restored status for #{id[0, 8]}...: #{resumed.state.status.inspect}"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Knowledge (RAG) — ground agents in your own documents.
|
|
5
|
+
#
|
|
6
|
+
# Sources (strings, files, PDFs, CSVs, URLs) are chunked, embedded, and stored
|
|
7
|
+
# in an in-memory cosine-similarity vector store. At execution time the most
|
|
8
|
+
# relevant chunks are injected into the agent's task prompt.
|
|
9
|
+
#
|
|
10
|
+
# This example uses a fake, deterministic embedder so it runs WITHOUT an API
|
|
11
|
+
# key. In real use you'd omit `embedder:` and let it default to OpenAI's
|
|
12
|
+
# text-embedding-3-small (set OPENAI_API_KEY).
|
|
13
|
+
#
|
|
14
|
+
# Run:
|
|
15
|
+
# ruby examples/knowledge_rag_example.rb
|
|
16
|
+
|
|
17
|
+
require_relative '../lib/rcrewai'
|
|
18
|
+
|
|
19
|
+
# A toy embedder: maps text to a small vector by keyword presence. Any object
|
|
20
|
+
# responding to `embed(texts) -> [[float, ...], ...]` works here.
|
|
21
|
+
class KeywordEmbedder
|
|
22
|
+
KEYWORDS = %w[refund shipping warranty].freeze
|
|
23
|
+
|
|
24
|
+
def embed(texts)
|
|
25
|
+
texts.map do |t|
|
|
26
|
+
lower = t.downcase
|
|
27
|
+
KEYWORDS.map { |kw| lower.include?(kw) ? 1.0 : 0.0 }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 1. Build a knowledge base from a few policy snippets.
|
|
33
|
+
knowledge = RCrewAI::Knowledge::Base.new(
|
|
34
|
+
sources: [
|
|
35
|
+
RCrewAI::Knowledge::StringSource.new('Refunds are available within 30 days of purchase.'),
|
|
36
|
+
RCrewAI::Knowledge::StringSource.new('Standard shipping takes 5-7 business days.'),
|
|
37
|
+
RCrewAI::Knowledge::StringSource.new('The warranty covers manufacturing defects for one year.')
|
|
38
|
+
],
|
|
39
|
+
embedder: KeywordEmbedder.new
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# 2. Retrieve directly (what the agent does under the hood).
|
|
43
|
+
puts '== Direct retrieval =='
|
|
44
|
+
%w[refund shipping warranty].each do |query|
|
|
45
|
+
top = knowledge.search(query, k: 1).first
|
|
46
|
+
puts "#{query.ljust(9)} -> #{top}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# 3. Attach the knowledge to an agent and see it injected into the prompt.
|
|
50
|
+
puts "\n== Injected into the agent prompt =="
|
|
51
|
+
RCrewAI.configure(validate: false) do |c|
|
|
52
|
+
c.llm_provider = :openai
|
|
53
|
+
c.api_key = 'demo-key' # not used — we only build the prompt below
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
agent = RCrewAI::Agent.new(
|
|
57
|
+
name: 'support',
|
|
58
|
+
role: 'Customer support specialist',
|
|
59
|
+
goal: 'Answer customer questions using company policy',
|
|
60
|
+
knowledge: knowledge
|
|
61
|
+
)
|
|
62
|
+
task = RCrewAI::Task.new(
|
|
63
|
+
name: 'answer',
|
|
64
|
+
description: 'What is the refund policy?',
|
|
65
|
+
agent: agent
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
messages = agent.send(:build_initial_messages, task)
|
|
69
|
+
puts messages.find { |m| m[:role] == 'user' }[:content]
|
|
70
|
+
|
|
71
|
+
# Crew-level knowledge is shared with every agent, e.g.:
|
|
72
|
+
# crew = RCrewAI::Crew.new('support', knowledge: knowledge)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Crew planning, plus the train/test workflows.
|
|
5
|
+
#
|
|
6
|
+
# - planning: true -> a planner pass drafts a per-task plan and
|
|
7
|
+
# folds it into each task's description before
|
|
8
|
+
# execution.
|
|
9
|
+
# - crew.train(...) -> runs the crew repeatedly, collecting feedback
|
|
10
|
+
# after each run and persisting it as JSON.
|
|
11
|
+
# - crew.test(...) -> runs the crew repeatedly and scores each run.
|
|
12
|
+
#
|
|
13
|
+
# This example stubs the planner LLM and the process so it runs WITHOUT an API
|
|
14
|
+
# key, focusing on the wiring.
|
|
15
|
+
#
|
|
16
|
+
# Run:
|
|
17
|
+
# ruby examples/planning_and_training_example.rb
|
|
18
|
+
|
|
19
|
+
require_relative '../lib/rcrewai'
|
|
20
|
+
require 'tmpdir'
|
|
21
|
+
|
|
22
|
+
RCrewAI.configure(validate: false) do |c|
|
|
23
|
+
c.llm_provider = :openai
|
|
24
|
+
c.api_key = 'demo-key'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A fake planner client: returns a JSON map of task name -> plan.
|
|
28
|
+
class FakePlanner
|
|
29
|
+
def chat(**)
|
|
30
|
+
{ content: '{"research": "list 3 sources", "summarize": "write 5 bullets"}' }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
agent = RCrewAI::Agent.new(name: 'analyst', role: 'Analyst', goal: 'Analyze')
|
|
35
|
+
research = RCrewAI::Task.new(name: 'research', description: 'Research the topic', agent: agent)
|
|
36
|
+
summarize = RCrewAI::Task.new(name: 'summarize', description: 'Summarize findings', agent: agent)
|
|
37
|
+
|
|
38
|
+
crew = RCrewAI::Crew.new('analysis', planning: true, planning_llm: FakePlanner.new)
|
|
39
|
+
crew.add_agent(agent)
|
|
40
|
+
crew.add_task(research)
|
|
41
|
+
crew.add_task(summarize)
|
|
42
|
+
|
|
43
|
+
# Stub the actual task execution so the demo needs no live LLM.
|
|
44
|
+
module RCrewAI
|
|
45
|
+
module Process
|
|
46
|
+
class Sequential
|
|
47
|
+
def execute
|
|
48
|
+
[{ status: :completed }]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
puts '== Planning pass =='
|
|
55
|
+
crew.execute
|
|
56
|
+
puts "research.description:\n #{research.description.gsub("\n", "\n ")}"
|
|
57
|
+
puts "summarize.description:\n #{summarize.description.gsub("\n", "\n ")}"
|
|
58
|
+
|
|
59
|
+
puts "\n== Training (feedback persisted to JSON) =="
|
|
60
|
+
file = File.join(Dir.tmpdir, 'rcrewai-training-demo.json')
|
|
61
|
+
summary = crew.train(
|
|
62
|
+
n_iterations: 3,
|
|
63
|
+
filename: file,
|
|
64
|
+
feedback: ->(iteration, _result) { "run #{iteration}: looked good" }
|
|
65
|
+
)
|
|
66
|
+
puts "iterations: #{summary[:iterations]}, file: #{summary[:filename]}"
|
|
67
|
+
puts File.read(file)
|
|
68
|
+
File.delete(file)
|
|
69
|
+
|
|
70
|
+
puts "\n== Testing (per-run scores) =="
|
|
71
|
+
result = crew.test(n_iterations: 3, scorer: ->(_run) { 90.0 + rand(10) })
|
|
72
|
+
puts "scores: #{result[:scores].inspect}, average: #{result[:average_score]}"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Structured output, guardrails, and file output on a Task.
|
|
5
|
+
#
|
|
6
|
+
# After the agent produces its answer, a Task can:
|
|
7
|
+
# - validate & coerce it against a JSON schema (output_schema:)
|
|
8
|
+
# - validate & transform it with a guardrail (guardrail:)
|
|
9
|
+
# - write it to disk, optionally as markdown (output_file:, markdown:)
|
|
10
|
+
#
|
|
11
|
+
# Schema/guardrail failures re-run the agent with the error fed back.
|
|
12
|
+
#
|
|
13
|
+
# This example stubs the agent so it runs WITHOUT an API key. In real use the
|
|
14
|
+
# agent calls your configured LLM.
|
|
15
|
+
#
|
|
16
|
+
# Run:
|
|
17
|
+
# ruby examples/structured_output_example.rb
|
|
18
|
+
|
|
19
|
+
require_relative '../lib/rcrewai'
|
|
20
|
+
require 'tmpdir'
|
|
21
|
+
|
|
22
|
+
# A stand-in agent: returns canned responses so we can demonstrate the
|
|
23
|
+
# post-processing pipeline deterministically. A real Agent behaves the same
|
|
24
|
+
# way from the Task's point of view (it returns { content: "..." }).
|
|
25
|
+
class ScriptedAgent
|
|
26
|
+
def initialize(responses)
|
|
27
|
+
@responses = responses
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def tools = []
|
|
31
|
+
|
|
32
|
+
def execute_task(_task)
|
|
33
|
+
{ content: @responses.shift }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
puts '== Structured output (with a repair retry) =='
|
|
38
|
+
# First response is invalid JSON; the task feeds the error back and retries,
|
|
39
|
+
# and the second response conforms to the schema.
|
|
40
|
+
agent = ScriptedAgent.new(['sorry, not sure', '{"title": "Q3 Report", "words": 1200}'])
|
|
41
|
+
|
|
42
|
+
task = RCrewAI::Task.new(
|
|
43
|
+
name: 'extract',
|
|
44
|
+
description: 'Extract the article title and word count as JSON',
|
|
45
|
+
agent: agent,
|
|
46
|
+
output_schema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: { title: { type: 'string' }, words: { type: 'integer' } },
|
|
49
|
+
required: ['title']
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
task.execute
|
|
53
|
+
puts "structured_output: #{task.structured_output.inspect}"
|
|
54
|
+
puts "raw_result: #{task.raw_result.inspect}"
|
|
55
|
+
|
|
56
|
+
puts "\n== Guardrail (transform + reject/retry) =="
|
|
57
|
+
# The guardrail requires the answer to mention a price; the first attempt does
|
|
58
|
+
# not, so the task re-runs, and the second attempt passes (and is stripped).
|
|
59
|
+
agent = ScriptedAgent.new(['no price yet', ' Final price: $49 '])
|
|
60
|
+
|
|
61
|
+
guardrail = lambda do |output|
|
|
62
|
+
if output.include?('$')
|
|
63
|
+
[true, output.strip] # accept + transform
|
|
64
|
+
else
|
|
65
|
+
[false, 'must include a price'] # reject with a reason (fed back to the agent)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
task = RCrewAI::Task.new(
|
|
70
|
+
name: 'quote',
|
|
71
|
+
description: 'Give the final price',
|
|
72
|
+
agent: agent,
|
|
73
|
+
guardrail: guardrail,
|
|
74
|
+
guardrail_max_retries: 2
|
|
75
|
+
)
|
|
76
|
+
puts "result: #{task.execute.inspect}"
|
|
77
|
+
|
|
78
|
+
puts "\n== File output (markdown) =="
|
|
79
|
+
agent = ScriptedAgent.new(['All systems nominal.'])
|
|
80
|
+
path = File.join(Dir.tmpdir, 'rcrewai-report-demo.md')
|
|
81
|
+
|
|
82
|
+
task = RCrewAI::Task.new(
|
|
83
|
+
name: 'report',
|
|
84
|
+
description: 'Write a status report',
|
|
85
|
+
agent: agent,
|
|
86
|
+
output_file: path,
|
|
87
|
+
markdown: true
|
|
88
|
+
)
|
|
89
|
+
task.execute
|
|
90
|
+
puts "wrote #{path}:"
|
|
91
|
+
puts File.read(path)
|
|
92
|
+
File.delete(path)
|
data/lib/rcrewai/agent.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'logger'
|
|
4
4
|
require_relative 'llm_client'
|
|
5
5
|
require_relative 'memory'
|
|
6
|
+
require_relative 'rate_limiter'
|
|
7
|
+
require_relative 'agent_augmentations'
|
|
8
|
+
require_relative 'multimodal'
|
|
6
9
|
require_relative 'tools/base'
|
|
7
10
|
require_relative 'tool_runner'
|
|
8
11
|
require_relative 'legacy_react_runner'
|
|
@@ -11,7 +14,8 @@ require_relative 'human_input'
|
|
|
11
14
|
module RCrewAI
|
|
12
15
|
class Agent
|
|
13
16
|
include HumanInteractionExtensions
|
|
14
|
-
|
|
17
|
+
include AgentAugmentations
|
|
18
|
+
attr_reader :name, :role, :goal, :backstory, :tools, :memory, :llm_client, :knowledge, :rate_limiter
|
|
15
19
|
attr_accessor :verbose, :allow_delegation, :max_iterations, :max_execution_time, :manager
|
|
16
20
|
# Set by the crew so agents see shared knowledge in addition to their own.
|
|
17
21
|
attr_writer :crew_knowledge
|
|
@@ -32,8 +36,12 @@ module RCrewAI
|
|
|
32
36
|
@require_approval_for_final_answer = options.fetch(:require_approval_for_final_answer, false)
|
|
33
37
|
@logger = Logger.new($stdout)
|
|
34
38
|
@logger.level = verbose ? Logger::DEBUG : Logger::INFO
|
|
35
|
-
@
|
|
36
|
-
@
|
|
39
|
+
@reasoning = options.fetch(:reasoning, false)
|
|
40
|
+
@max_reasoning_attempts = options.fetch(:max_reasoning_attempts, 3)
|
|
41
|
+
@respect_context_window = options.fetch(:respect_context_window, false)
|
|
42
|
+
@memory = build_memory(options[:memory])
|
|
43
|
+
@rate_limiter = options[:max_rpm] ? RateLimiter.new(max_rpm: options[:max_rpm]) : nil
|
|
44
|
+
@llm_client = wrap_with_rate_limiter(build_llm_client(options[:llm]))
|
|
37
45
|
@knowledge = build_knowledge(options[:knowledge], options[:knowledge_sources])
|
|
38
46
|
@subordinates = [] # For manager agents
|
|
39
47
|
end
|
|
@@ -46,6 +54,9 @@ module RCrewAI
|
|
|
46
54
|
initial_messages = build_initial_messages(task)
|
|
47
55
|
sink = stream || ->(_) {}
|
|
48
56
|
|
|
57
|
+
reasoning = reasoning? ? run_reasoning_pass(task) : nil
|
|
58
|
+
initial_messages = inject_reasoning(initial_messages, reasoning) if reasoning
|
|
59
|
+
|
|
49
60
|
runner_class = pick_runner_class
|
|
50
61
|
@logger.info "[rcrewai] agent=#{name} runner=#{runner_class.name.split('::').last}"
|
|
51
62
|
|
|
@@ -63,7 +74,7 @@ module RCrewAI
|
|
|
63
74
|
memory.add_execution(task, result_string, execution_time)
|
|
64
75
|
task.result = result_string
|
|
65
76
|
|
|
66
|
-
build_task_result(task, runner_result)
|
|
77
|
+
build_task_result(task, runner_result, reasoning: reasoning)
|
|
67
78
|
rescue StandardError => e
|
|
68
79
|
@logger.error "Task execution failed: #{e.message}"
|
|
69
80
|
task.result = "Task failed: #{e.message}"
|
|
@@ -202,6 +213,24 @@ module RCrewAI
|
|
|
202
213
|
LLMClient.resolve(llm)
|
|
203
214
|
end
|
|
204
215
|
|
|
216
|
+
# Wraps the client so every #chat is throttled, when a rate limiter is set.
|
|
217
|
+
def wrap_with_rate_limiter(client)
|
|
218
|
+
return client unless @rate_limiter
|
|
219
|
+
|
|
220
|
+
RateLimiter::ThrottledClient.new(client, @rate_limiter)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Builds the agent's memory. Accepts a pre-built Memory, an options hash
|
|
224
|
+
# (`{ embedder:, store:, scope:, short_term_limit: }`), or nil for the
|
|
225
|
+
# zero-config default. Memory is scoped to the agent's name so agents don't
|
|
226
|
+
# share recall (matters when a persistent store is shared).
|
|
227
|
+
def build_memory(memory)
|
|
228
|
+
return memory if memory.is_a?(Memory)
|
|
229
|
+
|
|
230
|
+
opts = memory.is_a?(Hash) ? memory : {}
|
|
231
|
+
Memory.new(scope: opts.fetch(:scope, name), **opts.slice(:embedder, :store, :short_term_limit))
|
|
232
|
+
end
|
|
233
|
+
|
|
205
234
|
# Accepts a pre-built Knowledge::Base via +knowledge:+ or an array of
|
|
206
235
|
# sources via +knowledge_sources:+ (wrapped in a Base). Returns nil if
|
|
207
236
|
# neither is given.
|
|
@@ -249,10 +278,20 @@ module RCrewAI
|
|
|
249
278
|
|
|
250
279
|
[
|
|
251
280
|
{ role: 'system', content: system },
|
|
252
|
-
{ role: 'user', content: user }
|
|
281
|
+
{ role: 'user', content: build_user_content(user, task) }
|
|
253
282
|
]
|
|
254
283
|
end
|
|
255
284
|
|
|
285
|
+
# Returns a plain string, or an OpenAI-style multimodal parts array when the
|
|
286
|
+
# task carries attachments (guarded to providers that support it).
|
|
287
|
+
def build_user_content(text, task)
|
|
288
|
+
attachments = task.respond_to?(:attachments) ? task.attachments : nil
|
|
289
|
+
return text if attachments.nil? || attachments.empty?
|
|
290
|
+
|
|
291
|
+
Multimodal.ensure_supported_provider!(RCrewAI.configuration.llm_provider)
|
|
292
|
+
Multimodal.content_parts(text, attachments)
|
|
293
|
+
end
|
|
294
|
+
|
|
256
295
|
# Retrieves knowledge chunks relevant to the task from the agent's own
|
|
257
296
|
# knowledge base and/or the crew-level base injected via #knowledge=.
|
|
258
297
|
def retrieve_knowledge(task)
|
|
@@ -266,7 +305,7 @@ module RCrewAI
|
|
|
266
305
|
''
|
|
267
306
|
end
|
|
268
307
|
|
|
269
|
-
def build_task_result(task, runner_result)
|
|
308
|
+
def build_task_result(task, runner_result, reasoning: nil)
|
|
270
309
|
{
|
|
271
310
|
task: task.name,
|
|
272
311
|
agent: name,
|
|
@@ -274,10 +313,14 @@ module RCrewAI
|
|
|
274
313
|
tool_calls_history: runner_result[:tool_calls_history] || [],
|
|
275
314
|
usage: runner_result[:usage] || {},
|
|
276
315
|
iterations: runner_result[:iterations],
|
|
277
|
-
finish_reason: runner_result[:finish_reason]
|
|
316
|
+
finish_reason: runner_result[:finish_reason],
|
|
317
|
+
reasoning: reasoning
|
|
278
318
|
}
|
|
279
319
|
end
|
|
280
320
|
|
|
321
|
+
# Asks the LLM to think through an approach before answering. Retries up to
|
|
322
|
+
# @max_reasoning_attempts if the model returns empty output; returns nil if
|
|
323
|
+
# every attempt is empty (execution then proceeds without a plan).
|
|
281
324
|
def pick_runner_class
|
|
282
325
|
schemas_ok = @tools.empty? || @tools.all? { |t| t.respond_to?(:json_schema) && t.json_schema }
|
|
283
326
|
native = @llm_client.respond_to?(:supports_native_tools?) &&
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'context_window'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
# Optional per-task augmentations mixed into Agent: a reasoning/planning pass
|
|
7
|
+
# before answering, and context-window trimming of the message history.
|
|
8
|
+
# Kept in a module so Agent's core stays focused.
|
|
9
|
+
module AgentAugmentations
|
|
10
|
+
def reasoning?
|
|
11
|
+
@reasoning
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def respect_context_window?
|
|
15
|
+
@respect_context_window
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Trims a message list to fit the model's context window when the agent has
|
|
19
|
+
# respect_context_window enabled; otherwise returns it unchanged. Called by
|
|
20
|
+
# the runners before each LLM call.
|
|
21
|
+
def fit_context(messages)
|
|
22
|
+
return messages unless @respect_context_window
|
|
23
|
+
|
|
24
|
+
limit = ContextWindow.window_for(llm_model_name)
|
|
25
|
+
reserve = [RCrewAI.configuration.max_tokens.to_i, 0].max
|
|
26
|
+
ContextWindow.fit(messages, limit: limit, reserve: reserve)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Asks the LLM to think through an approach before answering. Retries up to
|
|
32
|
+
# @max_reasoning_attempts if the model returns empty output; returns nil if
|
|
33
|
+
# every attempt is empty (execution then proceeds without a plan).
|
|
34
|
+
def run_reasoning_pass(task)
|
|
35
|
+
prompt = <<~PROMPT
|
|
36
|
+
You are #{role}. Before answering, think step by step about how to best
|
|
37
|
+
accomplish this task. Produce a short, concrete plan (do not answer yet).
|
|
38
|
+
|
|
39
|
+
Task: #{task.description}
|
|
40
|
+
Expected Output: #{task.expected_output || 'not specified'}
|
|
41
|
+
PROMPT
|
|
42
|
+
|
|
43
|
+
@max_reasoning_attempts.times do
|
|
44
|
+
response = @llm_client.chat(messages: [{ role: 'user', content: prompt }])
|
|
45
|
+
text = (response.is_a?(Hash) ? response[:content] : response).to_s.strip
|
|
46
|
+
return text unless text.empty?
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
@logger.warn("Reasoning pass failed: #{e.message}")
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Adds the reasoning trace to the user message so the answer pass can use it.
|
|
55
|
+
def inject_reasoning(messages, reasoning)
|
|
56
|
+
messages.map do |msg|
|
|
57
|
+
next msg unless msg[:role] == 'user'
|
|
58
|
+
|
|
59
|
+
{ role: 'user', content: "#{msg[:content]}\n\nYour plan:\n#{reasoning}" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Best-effort model name from the (possibly wrapped) client, for context
|
|
64
|
+
# window sizing. Falls back to the global configured model.
|
|
65
|
+
def llm_model_name
|
|
66
|
+
if @llm_client.respond_to?(:config) && @llm_client.config.respond_to?(:model)
|
|
67
|
+
@llm_client.config.model
|
|
68
|
+
else
|
|
69
|
+
RCrewAI.configuration.model
|
|
70
|
+
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
RCrewAI.configuration.model
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
# Keeps a conversation within a model's context window by dropping the oldest
|
|
5
|
+
# non-system messages when it would overflow. Token counts use a cheap
|
|
6
|
+
# chars/4 heuristic (no tokenizer dependency); the goal is to avoid hard
|
|
7
|
+
# context-length errors, not exact accounting.
|
|
8
|
+
module ContextWindow
|
|
9
|
+
CHARS_PER_TOKEN = 4
|
|
10
|
+
DEFAULT_WINDOW = 8_192
|
|
11
|
+
|
|
12
|
+
# Approximate context window sizes (in tokens) by model.
|
|
13
|
+
WINDOWS = {
|
|
14
|
+
'gpt-4o' => 128_000,
|
|
15
|
+
'gpt-4o-mini' => 128_000,
|
|
16
|
+
'gpt-4-turbo' => 128_000,
|
|
17
|
+
'gpt-4' => 8_192,
|
|
18
|
+
'gpt-3.5-turbo' => 16_385,
|
|
19
|
+
'claude-opus-4-7' => 200_000,
|
|
20
|
+
'claude-sonnet-4-6' => 200_000,
|
|
21
|
+
'claude-haiku-4-5' => 200_000,
|
|
22
|
+
'claude-3-5-sonnet-20241022' => 200_000,
|
|
23
|
+
'claude-3-haiku-20240307' => 200_000,
|
|
24
|
+
'gemini-1.5-pro' => 1_000_000,
|
|
25
|
+
'gemini-1.5-flash' => 1_000_000
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def estimate_tokens(input)
|
|
31
|
+
text = input.is_a?(Array) ? input.map { |m| m[:content].to_s }.join : input.to_s
|
|
32
|
+
(text.length / CHARS_PER_TOKEN.to_f).ceil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def window_for(model)
|
|
36
|
+
WINDOWS[model] || DEFAULT_WINDOW
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns a copy of +messages+ trimmed to fit within (limit - reserve)
|
|
40
|
+
# tokens. System messages are always kept, as is the final message. The
|
|
41
|
+
# oldest non-system, non-final messages are dropped first.
|
|
42
|
+
def fit(messages, limit:, reserve: 0)
|
|
43
|
+
budget = limit - reserve
|
|
44
|
+
return messages if estimate_tokens(messages) <= budget
|
|
45
|
+
|
|
46
|
+
system = messages.select { |m| m[:role] == 'system' }
|
|
47
|
+
last = messages.last
|
|
48
|
+
# Candidates for dropping: everything that isn't a system message or the
|
|
49
|
+
# final message, oldest first.
|
|
50
|
+
middle = messages.reject { |m| m[:role] == 'system' || m.equal?(last) }
|
|
51
|
+
|
|
52
|
+
kept_middle = middle.dup
|
|
53
|
+
until fits?(system, kept_middle, last, budget) || kept_middle.empty?
|
|
54
|
+
kept_middle.shift # drop the oldest
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
rebuild(messages, system, kept_middle, last)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# -- helpers --------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def fits?(system, middle, last, budget)
|
|
63
|
+
parts = system + middle
|
|
64
|
+
parts << last unless system.include?(last) || middle.include?(last)
|
|
65
|
+
estimate_tokens(parts) <= budget
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def rebuild(original, system, middle, last)
|
|
69
|
+
keep = (system + middle)
|
|
70
|
+
keep << last unless keep.include?(last)
|
|
71
|
+
# Preserve original ordering.
|
|
72
|
+
original.select { |m| keep.include?(m) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|