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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
module Knowledge
|
|
5
|
+
# A knowledge source yields plain text via #read. Concrete sources load from
|
|
6
|
+
# strings, files, PDFs, CSVs, or URLs.
|
|
7
|
+
class Source
|
|
8
|
+
def read
|
|
9
|
+
raise NotImplementedError, 'Subclasses must implement #read'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class StringSource < Source
|
|
14
|
+
def initialize(text)
|
|
15
|
+
super()
|
|
16
|
+
@text = text.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read
|
|
20
|
+
@text
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class FileSource < Source
|
|
25
|
+
def initialize(path)
|
|
26
|
+
super()
|
|
27
|
+
@path = path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def read
|
|
31
|
+
File.read(@path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class PdfSource < Source
|
|
36
|
+
def initialize(path)
|
|
37
|
+
super()
|
|
38
|
+
@path = path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read
|
|
42
|
+
require 'pdf-reader'
|
|
43
|
+
reader = PDF::Reader.new(@path)
|
|
44
|
+
reader.pages.map(&:text).join("\n")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class CsvSource < Source
|
|
49
|
+
def initialize(path)
|
|
50
|
+
super()
|
|
51
|
+
@path = path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read
|
|
55
|
+
require 'csv'
|
|
56
|
+
CSV.read(@path).map { |row| row.join(', ') }.join("\n")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class UrlSource < Source
|
|
61
|
+
def initialize(url, fetcher: nil)
|
|
62
|
+
super()
|
|
63
|
+
@url = url
|
|
64
|
+
@fetcher = fetcher
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read
|
|
68
|
+
html = @fetcher ? @fetcher.call(@url) : fetch(@url)
|
|
69
|
+
require 'nokogiri'
|
|
70
|
+
doc = Nokogiri::HTML(html)
|
|
71
|
+
doc.search('script, style').remove
|
|
72
|
+
doc.text.gsub(/\s+/, ' ').strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def fetch(url)
|
|
78
|
+
require 'faraday'
|
|
79
|
+
Faraday.get(url).body
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
module Knowledge
|
|
5
|
+
# In-memory vector store with cosine-similarity search. The default backing
|
|
6
|
+
# store for Knowledge — no external service required. The interface
|
|
7
|
+
# (#add, #search) is intentionally small so a Chroma/Qdrant-backed store can
|
|
8
|
+
# be swapped in later.
|
|
9
|
+
class Store
|
|
10
|
+
Entry = Struct.new(:text, :vector)
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@entries = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add(text, vector)
|
|
17
|
+
@entries << Entry.new(text, vector)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the texts of the top-k entries most similar to +query_vector+.
|
|
21
|
+
def search(query_vector, k: 3)
|
|
22
|
+
return [] if @entries.empty?
|
|
23
|
+
|
|
24
|
+
@entries
|
|
25
|
+
.map { |e| [e.text, cosine_similarity(query_vector, e.vector)] }
|
|
26
|
+
.sort_by { |(_text, score)| -score }
|
|
27
|
+
.first(k)
|
|
28
|
+
.map(&:first)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size
|
|
32
|
+
@entries.length
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def empty?
|
|
36
|
+
@entries.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
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))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'knowledge/chunker'
|
|
4
|
+
require_relative 'knowledge/store'
|
|
5
|
+
require_relative 'knowledge/sources'
|
|
6
|
+
require_relative 'knowledge/embedder'
|
|
7
|
+
require_relative 'knowledge/base'
|
|
8
|
+
|
|
9
|
+
module RCrewAI
|
|
10
|
+
# Retrieval-augmented knowledge for agents and crews. See Knowledge::Base.
|
|
11
|
+
module Knowledge
|
|
12
|
+
end
|
|
13
|
+
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 = []
|
data/lib/rcrewai/llm_client.rb
CHANGED
|
@@ -28,6 +28,29 @@ module RCrewAI
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Resolves a per-agent / per-pass LLM spec into a client.
|
|
32
|
+
# nil -> global provider
|
|
33
|
+
# Symbol/String -> that provider, global model
|
|
34
|
+
# Hash -> { provider:, model:, api_key:, temperature: } overrides
|
|
35
|
+
# client object -> returned as-is (anything responding to #chat)
|
|
36
|
+
def self.resolve(spec, config = RCrewAI.configuration)
|
|
37
|
+
case spec
|
|
38
|
+
when nil
|
|
39
|
+
for_provider(nil, config)
|
|
40
|
+
when Symbol, String
|
|
41
|
+
overridden = config.with_overrides(provider: spec)
|
|
42
|
+
for_provider(overridden.llm_provider, overridden)
|
|
43
|
+
when Hash
|
|
44
|
+
overridden = config.with_overrides(**spec)
|
|
45
|
+
for_provider(overridden.llm_provider, overridden)
|
|
46
|
+
else
|
|
47
|
+
return spec if spec.respond_to?(:chat)
|
|
48
|
+
|
|
49
|
+
raise ConfigurationError,
|
|
50
|
+
"Invalid llm: expected a provider symbol, an options hash, or a client responding to #chat, got #{spec.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
31
54
|
def self.chat(messages:, **options)
|
|
32
55
|
client = for_provider
|
|
33
56
|
client.chat(messages: messages, **options)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
# Builds multimodal message content (text + images) in the OpenAI
|
|
7
|
+
# chat-completions format:
|
|
8
|
+
# [{ type: 'text', text: '...' },
|
|
9
|
+
# { type: 'image_url', image_url: { url: '...' } }]
|
|
10
|
+
#
|
|
11
|
+
# Local image paths are base64-encoded into data URLs; URLs pass through.
|
|
12
|
+
# Only OpenAI-style multimodal is supported today; other providers raise.
|
|
13
|
+
module Multimodal
|
|
14
|
+
SUPPORTED_PROVIDERS = %i[openai azure].freeze
|
|
15
|
+
|
|
16
|
+
MIME_TYPES = {
|
|
17
|
+
'.png' => 'image/png',
|
|
18
|
+
'.jpg' => 'image/jpeg',
|
|
19
|
+
'.jpeg' => 'image/jpeg',
|
|
20
|
+
'.gif' => 'image/gif',
|
|
21
|
+
'.webp' => 'image/webp'
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Returns an OpenAI-style content-parts array for the given text and
|
|
27
|
+
# attachments. With no attachments this is a single text part.
|
|
28
|
+
def content_parts(text, attachments)
|
|
29
|
+
parts = [{ type: 'text', text: text.to_s }]
|
|
30
|
+
Array(attachments).each { |att| parts << image_part(att) }
|
|
31
|
+
parts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def supported_provider?(provider)
|
|
35
|
+
SUPPORTED_PROVIDERS.include?(provider.to_sym)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ensure_supported_provider!(provider)
|
|
39
|
+
return if supported_provider?(provider)
|
|
40
|
+
|
|
41
|
+
raise UnsupportedProviderError,
|
|
42
|
+
"multimodal attachments are not supported for provider #{provider}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def image_part(attachment)
|
|
46
|
+
type = attachment[:type] || attachment['type']
|
|
47
|
+
raise UnsupportedAttachmentError, "unsupported attachment type: #{type.inspect}" unless type.to_sym == :image
|
|
48
|
+
|
|
49
|
+
url = attachment[:url] || attachment['url']
|
|
50
|
+
path = attachment[:path] || attachment['path']
|
|
51
|
+
resolved = url || data_url_for(path)
|
|
52
|
+
|
|
53
|
+
{ type: 'image_url', image_url: { url: resolved } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def data_url_for(path)
|
|
57
|
+
raise UnsupportedAttachmentError, 'image attachment needs a :url or :path' unless path
|
|
58
|
+
|
|
59
|
+
mime = MIME_TYPES[File.extname(path).downcase] || 'application/octet-stream'
|
|
60
|
+
encoded = Base64.strict_encode64(File.binread(path))
|
|
61
|
+
"data:#{mime};base64,#{encoded}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class UnsupportedAttachmentError < RCrewAI::Error; end
|
|
65
|
+
class UnsupportedProviderError < RCrewAI::Error; end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
# Validates and coerces a task's raw string output against a JSON-Schema
|
|
7
|
+
# subset (object / type / required / property types). Used by Task for the
|
|
8
|
+
# `output_schema:` option. Kept intentionally small: it covers the shapes an
|
|
9
|
+
# LLM is realistically asked to emit, not the whole JSON Schema spec.
|
|
10
|
+
module OutputSchema
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Returns the validated/coerced object.
|
|
14
|
+
# Raises OutputSchemaError if the string can't be parsed or doesn't conform.
|
|
15
|
+
def coerce(raw, schema)
|
|
16
|
+
data = parse(raw)
|
|
17
|
+
validate!(data, schema)
|
|
18
|
+
data
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Extracts a JSON document from a string that may contain surrounding prose,
|
|
22
|
+
# then parses it. Prefers a fenced ```json block, then the first balanced
|
|
23
|
+
# object/array, then the whole string.
|
|
24
|
+
def parse(raw)
|
|
25
|
+
candidate = extract_json(raw.to_s)
|
|
26
|
+
JSON.parse(candidate)
|
|
27
|
+
rescue JSON::ParserError => e
|
|
28
|
+
raise OutputSchemaError, "output is not valid JSON: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def extract_json(text)
|
|
32
|
+
if (fenced = text[/```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```/m, 1])
|
|
33
|
+
return fenced
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
first = text.index(/[{\[]/)
|
|
37
|
+
last = text.rindex(/[}\]]/)
|
|
38
|
+
return text if first.nil? || last.nil? || last < first
|
|
39
|
+
|
|
40
|
+
text[first..last]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate!(data, schema)
|
|
44
|
+
type = (schema[:type] || schema['type'])&.to_s
|
|
45
|
+
case type
|
|
46
|
+
when 'object' then validate_object!(data, schema)
|
|
47
|
+
when 'array' then raise_unless(data.is_a?(Array), 'expected an array')
|
|
48
|
+
when 'string' then raise_unless(data.is_a?(String), 'expected a string')
|
|
49
|
+
when 'integer' then raise_unless(data.is_a?(Integer), 'expected an integer')
|
|
50
|
+
when 'number' then raise_unless(data.is_a?(Numeric), 'expected a number')
|
|
51
|
+
when 'boolean' then raise_unless([true, false].include?(data), 'expected a boolean')
|
|
52
|
+
end
|
|
53
|
+
data
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_object!(data, schema)
|
|
57
|
+
raise_unless(data.is_a?(Hash), 'expected a JSON object')
|
|
58
|
+
|
|
59
|
+
required = schema[:required] || schema['required'] || []
|
|
60
|
+
required.each do |key|
|
|
61
|
+
raise_unless(data.key?(key.to_s), "missing required property '#{key}'")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
props = schema[:properties] || schema['properties'] || {}
|
|
65
|
+
props.each do |name, subschema|
|
|
66
|
+
value = data[name.to_s]
|
|
67
|
+
next if value.nil?
|
|
68
|
+
|
|
69
|
+
validate!(value, subschema)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def raise_unless(condition, message)
|
|
74
|
+
raise OutputSchemaError, message unless condition
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class OutputSchemaError < Error; end
|
|
79
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'output_schema'
|
|
5
|
+
|
|
6
|
+
module RCrewAI
|
|
7
|
+
# Runs a single planning pass over a crew's tasks before execution. Asks an
|
|
8
|
+
# LLM to draft a short, concrete plan for each task and folds that plan into
|
|
9
|
+
# the task's description, so the executing agent starts with a game plan.
|
|
10
|
+
#
|
|
11
|
+
# Mirrors CrewAI's `planning=True`. Best-effort: if the planner errors or
|
|
12
|
+
# returns unparseable output, execution proceeds with the original tasks.
|
|
13
|
+
class Planning
|
|
14
|
+
def initialize(crew, llm: nil, logger: nil)
|
|
15
|
+
@crew = crew
|
|
16
|
+
@llm = llm || LLMClient.for_provider
|
|
17
|
+
@logger = logger
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def plan!
|
|
21
|
+
return if @crew.tasks.empty?
|
|
22
|
+
|
|
23
|
+
plans = request_plans
|
|
24
|
+
return if plans.nil? || plans.empty?
|
|
25
|
+
|
|
26
|
+
@crew.tasks.each do |task|
|
|
27
|
+
step = plans[task.name] || plans[task.name.to_s]
|
|
28
|
+
task.enrich_description("Plan: #{step}") if step
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
@logger&.warn("Planning pass failed, continuing without a plan: #{e.message}")
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def request_plans
|
|
38
|
+
response = @llm.chat(messages: [
|
|
39
|
+
{ role: 'system', content: system_prompt },
|
|
40
|
+
{ role: 'user', content: user_prompt }
|
|
41
|
+
])
|
|
42
|
+
content = response.is_a?(Hash) ? response[:content].to_s : response.to_s
|
|
43
|
+
parse_plans(content)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_plans(content)
|
|
47
|
+
OutputSchema.parse(content)
|
|
48
|
+
rescue OutputSchemaError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def system_prompt
|
|
53
|
+
'You are a planning assistant. Given a list of tasks, produce a short, ' \
|
|
54
|
+
'concrete plan for each. Respond ONLY with a JSON object mapping each ' \
|
|
55
|
+
'task name to a one-sentence plan string.'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def user_prompt
|
|
59
|
+
lines = @crew.tasks.map do |t|
|
|
60
|
+
"- #{t.name}: #{t.description} (expected: #{t.expected_output || 'n/a'})"
|
|
61
|
+
end
|
|
62
|
+
"Tasks:\n#{lines.join("\n")}\n\nReturn JSON: { \"<task name>\": \"<plan>\", ... }"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
# Thread-safe requests-per-minute throttle. Records call timestamps in a
|
|
5
|
+
# rolling 60-second window; `acquire` blocks (sleeps) until a slot is free.
|
|
6
|
+
#
|
|
7
|
+
# The clock and sleeper are injectable so tests can drive time deterministically
|
|
8
|
+
# without touching the wall clock. `max_rpm` of nil or 0 means unlimited.
|
|
9
|
+
class RateLimiter
|
|
10
|
+
WINDOW = 60.0
|
|
11
|
+
|
|
12
|
+
def initialize(max_rpm:, clock: nil, sleeper: nil)
|
|
13
|
+
@max_rpm = max_rpm
|
|
14
|
+
@clock = clock || -> { current_time }
|
|
15
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
16
|
+
@calls = []
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Blocks until making a call would keep us within max_rpm, then records it.
|
|
21
|
+
def acquire
|
|
22
|
+
return record_unlimited if unlimited?
|
|
23
|
+
|
|
24
|
+
loop do
|
|
25
|
+
wait = @mutex.synchronize do
|
|
26
|
+
prune(@clock.call)
|
|
27
|
+
if @calls.length < @max_rpm
|
|
28
|
+
@calls << @clock.call
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
# Time until the oldest in-window call ages out.
|
|
32
|
+
(@calls.first + WINDOW) - @clock.call
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@sleeper.call(wait) if wait.positive?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Number of calls currently inside the window (useful for tests/metrics).
|
|
40
|
+
def recent_count
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
prune(@clock.call)
|
|
43
|
+
@calls.length
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def unlimited?
|
|
50
|
+
@max_rpm.nil? || @max_rpm.zero?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_unlimited
|
|
54
|
+
@mutex.synchronize { @calls << @clock.call }
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prune(now)
|
|
59
|
+
cutoff = now - WINDOW
|
|
60
|
+
@calls.reject! { |t| t <= cutoff }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def current_time
|
|
64
|
+
# Fully-qualified: bare `Process` would resolve to RCrewAI::Process here.
|
|
65
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Wraps an LLM client so every #chat acquires a rate-limiter slot first.
|
|
69
|
+
# All other messages delegate to the wrapped client unchanged.
|
|
70
|
+
class ThrottledClient
|
|
71
|
+
def initialize(client, limiter)
|
|
72
|
+
@client = client
|
|
73
|
+
@limiter = limiter
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def chat(**kwargs, &block)
|
|
77
|
+
@limiter.acquire
|
|
78
|
+
@client.chat(**kwargs, &block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def respond_to_missing?(name, include_private = false)
|
|
82
|
+
@client.respond_to?(name, include_private)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
86
|
+
if @client.respond_to?(name)
|
|
87
|
+
@client.public_send(name, *args, **kwargs, &block)
|
|
88
|
+
else
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/rcrewai/task.rb
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'async_executor'
|
|
4
4
|
require_relative 'human_input'
|
|
5
|
+
require_relative 'output_schema'
|
|
5
6
|
|
|
6
7
|
module RCrewAI
|
|
7
8
|
class Task
|
|
8
9
|
include AsyncExtensions
|
|
9
10
|
include HumanInteractionExtensions
|
|
10
|
-
attr_reader :name, :description, :agent, :context, :expected_output, :tools, :async
|
|
11
|
+
attr_reader :name, :description, :agent, :context, :expected_output, :tools, :async,
|
|
12
|
+
:raw_result, :structured_output, :attachments
|
|
11
13
|
attr_accessor :result, :status, :start_time, :end_time, :execution_time
|
|
12
14
|
|
|
13
15
|
def initialize(name:, description:, agent: nil, **options)
|
|
@@ -19,6 +21,17 @@ module RCrewAI
|
|
|
19
21
|
@tools = options[:tools] || [] # Additional tools for this specific task
|
|
20
22
|
@async = options[:async] || false # Whether task can run asynchronously
|
|
21
23
|
@callback = options[:callback] # Callback function after completion
|
|
24
|
+
@attachments = options[:attachments] || [] # Multimodal inputs (images)
|
|
25
|
+
|
|
26
|
+
# Output processing (0.4.0)
|
|
27
|
+
@output_schema = options[:output_schema] # JSON-schema for structured output
|
|
28
|
+
@guardrail = options[:guardrail] # ->(output) { [ok, value_or_error] }
|
|
29
|
+
@guardrail_max_retries = options.fetch(:guardrail_max_retries, 3)
|
|
30
|
+
@output_file = options[:output_file] # Path to write result to
|
|
31
|
+
@create_directory = options.fetch(:create_directory, true)
|
|
32
|
+
@markdown = options.fetch(:markdown, false)
|
|
33
|
+
@raw_result = nil # Unprocessed string content
|
|
34
|
+
@structured_output = nil # Parsed object when output_schema set
|
|
22
35
|
|
|
23
36
|
# Human interaction options
|
|
24
37
|
@human_input_enabled = options[:human_input] || false
|
|
@@ -62,7 +75,7 @@ module RCrewAI
|
|
|
62
75
|
)
|
|
63
76
|
end
|
|
64
77
|
|
|
65
|
-
@result =
|
|
78
|
+
@result = run_agent_with_output_processing
|
|
66
79
|
|
|
67
80
|
# Post-execution human review if configured
|
|
68
81
|
if @human_input_enabled && @human_review_points.include?(:completion)
|
|
@@ -166,6 +179,12 @@ module RCrewAI
|
|
|
166
179
|
@context << task unless @context.include?(task)
|
|
167
180
|
end
|
|
168
181
|
|
|
182
|
+
# Appends supplementary guidance (e.g. a planning step) to the task's
|
|
183
|
+
# description without discarding the original instructions.
|
|
184
|
+
def enrich_description(text)
|
|
185
|
+
@description = "#{@description}\n\n#{text}"
|
|
186
|
+
end
|
|
187
|
+
|
|
169
188
|
def add_tool(tool)
|
|
170
189
|
@tools << tool unless @tools.include?(tool)
|
|
171
190
|
end
|
|
@@ -190,6 +209,74 @@ module RCrewAI
|
|
|
190
209
|
|
|
191
210
|
private
|
|
192
211
|
|
|
212
|
+
# Runs the agent, then applies guardrail validation and schema coercion.
|
|
213
|
+
# Guardrail/schema failures re-run the agent (up to @guardrail_max_retries)
|
|
214
|
+
# with the failure fed back in, rather than raising immediately.
|
|
215
|
+
def run_agent_with_output_processing
|
|
216
|
+
attempts = 0
|
|
217
|
+
feedback = nil
|
|
218
|
+
|
|
219
|
+
loop do
|
|
220
|
+
attempts += 1
|
|
221
|
+
raw = extract_content(agent.execute_task(self))
|
|
222
|
+
@raw_result = raw
|
|
223
|
+
|
|
224
|
+
begin
|
|
225
|
+
apply_guardrail!(raw)
|
|
226
|
+
@structured_output = OutputSchema.coerce(raw, @output_schema) if @output_schema
|
|
227
|
+
rescue OutputProcessingError, OutputSchemaError => e
|
|
228
|
+
if attempts <= @guardrail_max_retries
|
|
229
|
+
feedback = e.message
|
|
230
|
+
append_feedback_to_description(feedback)
|
|
231
|
+
next
|
|
232
|
+
end
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
write_output_file(guardrail_value_or(raw)) if @output_file
|
|
237
|
+
return guardrail_value_or(raw)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Accepts either the legacy plain-string return or the 0.3.0 result hash.
|
|
242
|
+
def extract_content(agent_result)
|
|
243
|
+
return agent_result[:content].to_s if agent_result.is_a?(Hash)
|
|
244
|
+
|
|
245
|
+
agent_result.to_s
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def apply_guardrail!(raw)
|
|
249
|
+
return unless @guardrail
|
|
250
|
+
|
|
251
|
+
ok, value = @guardrail.call(raw)
|
|
252
|
+
raise OutputProcessingError, "guardrail rejected output: #{value}" unless ok
|
|
253
|
+
|
|
254
|
+
@guardrail_value = value
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def guardrail_value_or(raw)
|
|
258
|
+
@guardrail ? @guardrail_value : raw
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def append_feedback_to_description(feedback)
|
|
262
|
+
@description = "#{@description}\n\n[Retry] Previous attempt was rejected: #{feedback}. " \
|
|
263
|
+
'Please correct the output.'
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def write_output_file(content)
|
|
267
|
+
dir = File.dirname(@output_file)
|
|
268
|
+
if @create_directory
|
|
269
|
+
require 'fileutils'
|
|
270
|
+
FileUtils.mkdir_p(dir)
|
|
271
|
+
elsif !Dir.exist?(dir)
|
|
272
|
+
raise OutputProcessingError, "output directory does not exist: #{dir}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
body = content.to_s
|
|
276
|
+
body = "# #{name}\n\n#{body}" if @markdown && !body.lstrip.start_with?('#')
|
|
277
|
+
File.write(@output_file, body)
|
|
278
|
+
end
|
|
279
|
+
|
|
193
280
|
def confirm_task_execution
|
|
194
281
|
message = "Confirm execution of task: #{name}"
|
|
195
282
|
context = "Description: #{description}\nExpected Output: #{expected_output || 'Not specified'}\nAssigned Agent: #{agent&.name || 'No agent'}"
|
|
@@ -365,4 +452,5 @@ module RCrewAI
|
|
|
365
452
|
|
|
366
453
|
class TaskExecutionError < Error; end
|
|
367
454
|
class TaskDependencyError < TaskExecutionError; end
|
|
455
|
+
class OutputProcessingError < TaskExecutionError; end
|
|
368
456
|
end
|
data/lib/rcrewai/tool_runner.rb
CHANGED
|
@@ -27,7 +27,7 @@ module RCrewAI
|
|
|
27
27
|
emit(Events::IterationStart, iteration: iter, iteration_index: iter)
|
|
28
28
|
|
|
29
29
|
response = @llm.chat(
|
|
30
|
-
messages: msgs,
|
|
30
|
+
messages: fit_context(msgs),
|
|
31
31
|
tools: @tools.map(&:json_schema),
|
|
32
32
|
stream: ->(e) { @sink.call(retag(e, iter)) }
|
|
33
33
|
)
|
|
@@ -80,6 +80,12 @@ module RCrewAI
|
|
|
80
80
|
|
|
81
81
|
private
|
|
82
82
|
|
|
83
|
+
# Trims the message list to the model's context window when the agent
|
|
84
|
+
# supports it; a no-op otherwise.
|
|
85
|
+
def fit_context(messages)
|
|
86
|
+
@agent.respond_to?(:fit_context) ? @agent.fit_context(messages) : messages
|
|
87
|
+
end
|
|
88
|
+
|
|
83
89
|
def tool_result_message(call_id, content)
|
|
84
90
|
{ role: 'tool', tool_call_id: call_id, content: content }
|
|
85
91
|
end
|
data/lib/rcrewai/version.rb
CHANGED
data/lib/rcrewai.rb
CHANGED
|
@@ -23,6 +23,10 @@ require_relative 'rcrewai/sse_parser'
|
|
|
23
23
|
require_relative 'rcrewai/pricing'
|
|
24
24
|
require_relative 'rcrewai/llm_client'
|
|
25
25
|
require_relative 'rcrewai/memory'
|
|
26
|
+
require_relative 'rcrewai/rate_limiter'
|
|
27
|
+
require_relative 'rcrewai/context_window'
|
|
28
|
+
require_relative 'rcrewai/multimodal'
|
|
29
|
+
require_relative 'rcrewai/knowledge'
|
|
26
30
|
require_relative 'rcrewai/human_input'
|
|
27
31
|
require_relative 'rcrewai/tool_schema'
|
|
28
32
|
require_relative 'rcrewai/provider_schema'
|
|
@@ -41,4 +45,5 @@ require_relative 'rcrewai/async_executor'
|
|
|
41
45
|
require_relative 'rcrewai/agent'
|
|
42
46
|
require_relative 'rcrewai/task'
|
|
43
47
|
require_relative 'rcrewai/crew'
|
|
48
|
+
require_relative 'rcrewai/flow'
|
|
44
49
|
require_relative 'rcrewai/mcp'
|