rcrewai 0.2.1 → 0.4.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 +21 -0
- data/.rubocop_todo.yml +99 -0
- data/CHANGELOG.md +64 -1
- data/README.md +170 -2
- data/ROADMAP.md +84 -0
- data/Rakefile +53 -53
- data/bin/rcrewai +3 -3
- data/docs/mcp.md +109 -0
- data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
- data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
- data/docs/upgrading-to-0.3.md +163 -0
- data/examples/async_execution_example.rb +82 -81
- data/examples/hierarchical_crew_example.rb +68 -72
- data/examples/human_in_the_loop_example.rb +73 -74
- data/examples/mcp_example.rb +48 -0
- data/examples/native_tools_example.rb +64 -0
- data/examples/streaming_example.rb +56 -0
- data/lib/rcrewai/agent.rb +181 -286
- data/lib/rcrewai/async_executor.rb +43 -43
- data/lib/rcrewai/cli.rb +11 -11
- data/lib/rcrewai/configuration.rb +34 -9
- data/lib/rcrewai/crew.rb +134 -39
- data/lib/rcrewai/events.rb +30 -0
- 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/human_input.rb +104 -114
- 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 +172 -0
- data/lib/rcrewai/llm_client.rb +24 -1
- data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
- data/lib/rcrewai/llm_clients/azure.rb +23 -128
- data/lib/rcrewai/llm_clients/base.rb +11 -7
- data/lib/rcrewai/llm_clients/google.rb +159 -95
- data/lib/rcrewai/llm_clients/ollama.rb +150 -106
- data/lib/rcrewai/llm_clients/openai.rb +140 -63
- data/lib/rcrewai/mcp/client.rb +101 -0
- data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
- data/lib/rcrewai/mcp/transport/http.rb +53 -0
- data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
- data/lib/rcrewai/mcp.rb +8 -0
- data/lib/rcrewai/memory.rb +45 -37
- data/lib/rcrewai/output_schema.rb +79 -0
- data/lib/rcrewai/planning.rb +65 -0
- data/lib/rcrewai/pricing.rb +34 -0
- data/lib/rcrewai/process.rb +86 -95
- data/lib/rcrewai/provider_schema.rb +38 -0
- data/lib/rcrewai/sse_parser.rb +55 -0
- data/lib/rcrewai/task.rb +145 -66
- data/lib/rcrewai/tool_runner.rb +132 -0
- data/lib/rcrewai/tool_schema.rb +97 -0
- data/lib/rcrewai/tools/base.rb +98 -37
- data/lib/rcrewai/tools/code_executor.rb +71 -74
- data/lib/rcrewai/tools/email_sender.rb +70 -78
- data/lib/rcrewai/tools/file_reader.rb +38 -30
- data/lib/rcrewai/tools/file_writer.rb +40 -38
- data/lib/rcrewai/tools/pdf_processor.rb +115 -130
- data/lib/rcrewai/tools/sql_database.rb +58 -55
- data/lib/rcrewai/tools/web_search.rb +26 -25
- data/lib/rcrewai/version.rb +2 -2
- data/lib/rcrewai.rb +20 -10
- data/rcrewai.gemspec +39 -39
- metadata +77 -47
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'transport/stdio'
|
|
5
|
+
require_relative 'transport/http'
|
|
6
|
+
require_relative 'tool_adapter'
|
|
7
|
+
|
|
8
|
+
module RCrewAI
|
|
9
|
+
module MCP
|
|
10
|
+
class Error < RCrewAI::Error; end
|
|
11
|
+
|
|
12
|
+
# Minimal MCP (Model Context Protocol) JSON-RPC client. Connects to a
|
|
13
|
+
# server via stdio or streamable HTTP, performs the initialize/initialized
|
|
14
|
+
# handshake, lists tools, and exposes them as RCrewAI::Tools::Base instances.
|
|
15
|
+
class Client
|
|
16
|
+
PROTOCOL_VERSION = '2024-11-05'
|
|
17
|
+
|
|
18
|
+
attr_reader :server_name, :tools
|
|
19
|
+
|
|
20
|
+
def self.connect(**opts)
|
|
21
|
+
new(**opts).tap(&:open)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.with_connection(**opts)
|
|
25
|
+
c = connect(**opts)
|
|
26
|
+
begin
|
|
27
|
+
yield c
|
|
28
|
+
ensure
|
|
29
|
+
c&.close
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(command: nil, args: [], env: {}, url: nil, headers: {})
|
|
34
|
+
@transport = if url
|
|
35
|
+
Transport::Http.new(url: url, headers: headers)
|
|
36
|
+
else
|
|
37
|
+
Transport::Stdio.new(command: command, args: args, env: env)
|
|
38
|
+
end
|
|
39
|
+
@request_id = 0
|
|
40
|
+
@tools = []
|
|
41
|
+
@server_name = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def open
|
|
45
|
+
@transport.open
|
|
46
|
+
handshake
|
|
47
|
+
load_tools
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def close
|
|
51
|
+
@transport.close
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def call_tool(name, args)
|
|
55
|
+
result = request('tools/call',
|
|
56
|
+
name: strip_prefix(name),
|
|
57
|
+
arguments: args)
|
|
58
|
+
text = result.dig('content', 0, 'text')
|
|
59
|
+
text || result['content']
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def handshake
|
|
65
|
+
info = request('initialize',
|
|
66
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
67
|
+
capabilities: { tools: {} },
|
|
68
|
+
clientInfo: { name: 'rcrewai', version: RCrewAI::VERSION })
|
|
69
|
+
@server_name = info.dig('serverInfo', 'name') || 'mcp'
|
|
70
|
+
notify('notifications/initialized', {})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_tools
|
|
74
|
+
result = request('tools/list', {})
|
|
75
|
+
@tools = Array(result['tools']).map { |t| ToolAdapter.new(self, t, @server_name) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def request(method, params)
|
|
79
|
+
@request_id += 1
|
|
80
|
+
msg = { jsonrpc: '2.0', id: @request_id, method: method, params: params }
|
|
81
|
+
@transport.send_line(msg.to_json)
|
|
82
|
+
line = @transport.recv_line
|
|
83
|
+
raise Error, 'connection closed before response' if line.nil?
|
|
84
|
+
|
|
85
|
+
reply = JSON.parse(line)
|
|
86
|
+
raise Error, reply['error']['message'] if reply['error']
|
|
87
|
+
|
|
88
|
+
reply['result']
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def notify(method, params)
|
|
92
|
+
msg = { jsonrpc: '2.0', method: method, params: params }
|
|
93
|
+
@transport.send_line(msg.to_json)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def strip_prefix(prefixed_name)
|
|
97
|
+
prefixed_name.sub(/^#{Regexp.escape(@server_name)}__/, '')
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../tools/base'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
module MCP
|
|
7
|
+
# Wraps a tool advertised by an MCP server as an RCrewAI::Tools::Base.
|
|
8
|
+
# Names are prefixed with the server name to avoid collisions when an
|
|
9
|
+
# agent has tools from multiple MCP servers.
|
|
10
|
+
class ToolAdapter < RCrewAI::Tools::Base
|
|
11
|
+
def initialize(client, mcp_tool_descriptor, server_name)
|
|
12
|
+
super()
|
|
13
|
+
@client = client
|
|
14
|
+
@descriptor = mcp_tool_descriptor
|
|
15
|
+
@server_name = server_name
|
|
16
|
+
@adapter_name = "#{server_name}__#{mcp_tool_descriptor['name']}"
|
|
17
|
+
@adapter_description = mcp_tool_descriptor['description'].to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
@adapter_name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def description
|
|
25
|
+
@adapter_description
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def json_schema
|
|
29
|
+
{
|
|
30
|
+
name: @adapter_name,
|
|
31
|
+
description: @adapter_description,
|
|
32
|
+
parameters: stringify_keys(@descriptor['inputSchema'] ||
|
|
33
|
+
{ 'type' => 'object', 'additionalProperties' => true })
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute(**args)
|
|
38
|
+
@client.call_tool(@adapter_name, args)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def execute_with_validation(args_hash)
|
|
42
|
+
execute(**args_hash.transform_keys(&:to_sym))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def stringify_keys(value)
|
|
48
|
+
case value
|
|
49
|
+
when Hash
|
|
50
|
+
value.each_with_object({}) { |(k, v), out| out[k.to_s] = stringify_keys(v) }
|
|
51
|
+
when Array
|
|
52
|
+
value.map { |v| stringify_keys(v) }
|
|
53
|
+
else
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require_relative '../../sse_parser'
|
|
5
|
+
|
|
6
|
+
module RCrewAI
|
|
7
|
+
module MCP
|
|
8
|
+
module Transport
|
|
9
|
+
# Streamable HTTP transport for MCP: server pushes JSON-RPC responses
|
|
10
|
+
# via a long-lived SSE stream (GET); client sends requests via POST.
|
|
11
|
+
class Http
|
|
12
|
+
def initialize(url:, headers: {})
|
|
13
|
+
@url = url
|
|
14
|
+
@headers = headers
|
|
15
|
+
@queue = Queue.new
|
|
16
|
+
@sse_thread = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def open
|
|
20
|
+
@http = Faraday.new(url: @url) { |f| f.adapter Faraday.default_adapter }
|
|
21
|
+
@sse_thread = Thread.new { start_sse_stream }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def send_line(json)
|
|
25
|
+
@http.post('') do |req|
|
|
26
|
+
req.headers.merge!(@headers).merge!('Content-Type' => 'application/json')
|
|
27
|
+
req.body = json
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def recv_line
|
|
32
|
+
@queue.pop
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def close
|
|
36
|
+
@sse_thread&.kill
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def start_sse_stream
|
|
42
|
+
parser = SSEParser.new do |evt|
|
|
43
|
+
@queue << "#{evt[:data]}\n" if evt[:event] == 'message' || evt[:event].nil?
|
|
44
|
+
end
|
|
45
|
+
@http.get('') do |req|
|
|
46
|
+
req.headers.merge!(@headers).merge!('Accept' => 'text/event-stream')
|
|
47
|
+
req.options.on_data = proc { |chunk, _| parser.feed(chunk) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module RCrewAI
|
|
7
|
+
module MCP
|
|
8
|
+
module Transport
|
|
9
|
+
class Stdio
|
|
10
|
+
def initialize(command:, args: [], env: {})
|
|
11
|
+
@command = command
|
|
12
|
+
@args = args
|
|
13
|
+
@env = env
|
|
14
|
+
@stdin = nil
|
|
15
|
+
@stdout = nil
|
|
16
|
+
@stderr_thread = nil
|
|
17
|
+
@wait_thr = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def open
|
|
21
|
+
@stdin, @stdout, stderr, @wait_thr = Open3.popen3(@env, @command, *@args)
|
|
22
|
+
@stderr_thread = Thread.new do
|
|
23
|
+
stderr.each_line { |l| Kernel.warn "[mcp-stderr] #{l}" }
|
|
24
|
+
rescue IOError
|
|
25
|
+
# stream closed
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def send_line(json)
|
|
30
|
+
@stdin.write("#{json}\n")
|
|
31
|
+
@stdin.flush
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def recv_line
|
|
35
|
+
@stdout.gets
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
return unless @wait_thr&.alive?
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
::Process.kill('TERM', @wait_thr.pid)
|
|
43
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
44
|
+
# already dead
|
|
45
|
+
end
|
|
46
|
+
@stdin&.close
|
|
47
|
+
@stdout&.close
|
|
48
|
+
@stderr_thread&.kill
|
|
49
|
+
rescue IOError
|
|
50
|
+
# streams already closed
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/rcrewai/mcp.rb
ADDED
data/lib/rcrewai/memory.rb
CHANGED
|
@@ -32,16 +32,16 @@ module RCrewAI
|
|
|
32
32
|
@short_term = @short_term.first(@max_short_term)
|
|
33
33
|
|
|
34
34
|
# Add to long-term memory if successful
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
return unless execution_data[:success]
|
|
36
|
+
|
|
37
|
+
task_type = execution_data[:task_type]
|
|
38
|
+
@long_term[task_type] ||= []
|
|
39
|
+
@long_term[task_type] << execution_data
|
|
40
|
+
|
|
41
|
+
# Keep only best executions for each type
|
|
42
|
+
@long_term[task_type] = @long_term[task_type]
|
|
43
|
+
.sort_by { |e| [e[:success] ? 0 : 1, -e[:execution_time]] }
|
|
44
|
+
.first(10)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def add_tool_usage(tool_name, params, result)
|
|
@@ -54,7 +54,7 @@ module RCrewAI
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
@tool_usage.unshift(usage_data)
|
|
57
|
-
@tool_usage = @tool_usage.first(50)
|
|
57
|
+
@tool_usage = @tool_usage.first(50) # Keep last 50 tool usages
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def relevant_executions(task, limit = 3)
|
|
@@ -75,18 +75,16 @@ module RCrewAI
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# Check long-term memory
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
candidates << { execution: execution, similarity: similarity } if similarity > @similarity_threshold
|
|
82
|
-
end
|
|
78
|
+
@long_term[task_type]&.each do |execution|
|
|
79
|
+
similarity = calculate_similarity(task, execution)
|
|
80
|
+
candidates << { execution: execution, similarity: similarity } if similarity > @similarity_threshold
|
|
83
81
|
end
|
|
84
82
|
|
|
85
83
|
# Sort by similarity and success, return top results
|
|
86
84
|
relevant = candidates
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
.sort_by { |c| [-c[:similarity], c[:execution][:success] ? 0 : 1] }
|
|
86
|
+
.first(limit)
|
|
87
|
+
.map { |c| format_execution_for_context(c[:execution]) }
|
|
90
88
|
|
|
91
89
|
relevant.empty? ? nil : relevant.join("\n---\n")
|
|
92
90
|
end
|
|
@@ -123,13 +121,23 @@ module RCrewAI
|
|
|
123
121
|
|
|
124
122
|
def classify_task_type(task)
|
|
125
123
|
description = task.description.downcase
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
124
|
+
|
|
125
|
+
if description.include?('research') || description.include?('find') || description.include?('search')
|
|
126
|
+
return :research
|
|
127
|
+
end
|
|
128
|
+
if description.include?('analyze') || description.include?('examine') || description.include?('study')
|
|
129
|
+
return :analysis
|
|
130
|
+
end
|
|
131
|
+
if description.include?('write') || description.include?('create') || description.include?('compose')
|
|
132
|
+
return :writing
|
|
133
|
+
end
|
|
134
|
+
if description.include?('code') || description.include?('program') || description.include?('develop')
|
|
135
|
+
return :coding
|
|
136
|
+
end
|
|
137
|
+
if description.include?('plan') || description.include?('strategy') || description.include?('organize')
|
|
138
|
+
return :planning
|
|
139
|
+
end
|
|
140
|
+
|
|
133
141
|
:general
|
|
134
142
|
end
|
|
135
143
|
|
|
@@ -142,17 +150,17 @@ module RCrewAI
|
|
|
142
150
|
# Simple similarity based on common words and task type
|
|
143
151
|
task_words = extract_keywords(task.description)
|
|
144
152
|
execution_words = extract_keywords(execution[:task_description])
|
|
145
|
-
|
|
153
|
+
|
|
146
154
|
common_words = (task_words & execution_words).length
|
|
147
155
|
total_words = (task_words | execution_words).length
|
|
148
|
-
|
|
149
|
-
return 0.0 if total_words
|
|
150
|
-
|
|
156
|
+
|
|
157
|
+
return 0.0 if total_words.zero?
|
|
158
|
+
|
|
151
159
|
word_similarity = common_words.to_f / total_words
|
|
152
|
-
|
|
160
|
+
|
|
153
161
|
# Boost similarity if task types match
|
|
154
|
-
type_bonus =
|
|
155
|
-
|
|
162
|
+
type_bonus = classify_task_type(task) == execution[:task_type] ? 0.2 : 0.0
|
|
163
|
+
|
|
156
164
|
[word_similarity + type_bonus, 1.0].min
|
|
157
165
|
end
|
|
158
166
|
|
|
@@ -163,7 +171,7 @@ module RCrewAI
|
|
|
163
171
|
end
|
|
164
172
|
|
|
165
173
|
def format_execution_for_context(execution)
|
|
166
|
-
success_indicator = execution[:success] ?
|
|
174
|
+
success_indicator = execution[:success] ? '✓' : '✗'
|
|
167
175
|
<<~CONTEXT
|
|
168
176
|
#{success_indicator} Task: #{execution[:task_name]}
|
|
169
177
|
Description: #{execution[:task_description]}
|
|
@@ -174,7 +182,7 @@ module RCrewAI
|
|
|
174
182
|
end
|
|
175
183
|
|
|
176
184
|
def format_tool_usage_for_context(usage)
|
|
177
|
-
success_indicator = usage[:success] ?
|
|
185
|
+
success_indicator = usage[:success] ? '✓' : '✗'
|
|
178
186
|
params_str = usage[:params].map { |k, v| "#{k}=#{v}" }.join(', ')
|
|
179
187
|
<<~CONTEXT
|
|
180
188
|
#{success_indicator} Tool: #{usage[:tool_name]}
|
|
@@ -186,9 +194,9 @@ module RCrewAI
|
|
|
186
194
|
|
|
187
195
|
def calculate_success_rate
|
|
188
196
|
return 0.0 if @short_term.empty?
|
|
189
|
-
|
|
197
|
+
|
|
190
198
|
successful = @short_term.count { |e| e[:success] }
|
|
191
199
|
(successful.to_f / @short_term.length * 100).round(1)
|
|
192
200
|
end
|
|
193
201
|
end
|
|
194
|
-
end
|
|
202
|
+
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,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
module Pricing
|
|
5
|
+
# Prices in USD per 1M tokens. List prices as of 2026-05; users can override.
|
|
6
|
+
DEFAULT_PRICES = {
|
|
7
|
+
# OpenAI
|
|
8
|
+
'gpt-4o' => { input: 2.50, output: 10.00 },
|
|
9
|
+
'gpt-4o-mini' => { input: 0.15, output: 0.60 },
|
|
10
|
+
'gpt-4-turbo' => { input: 10.00, output: 30.00 },
|
|
11
|
+
'gpt-4' => { input: 30.00, output: 60.00 },
|
|
12
|
+
'gpt-3.5-turbo' => { input: 0.50, output: 1.50 },
|
|
13
|
+
# Anthropic
|
|
14
|
+
'claude-opus-4-7' => { input: 15.00, output: 75.00 },
|
|
15
|
+
'claude-sonnet-4-6' => { input: 3.00, output: 15.00 },
|
|
16
|
+
'claude-haiku-4-5' => { input: 0.80, output: 4.00 },
|
|
17
|
+
'claude-3-5-sonnet-20241022' => { input: 3.00, output: 15.00 },
|
|
18
|
+
'claude-3-haiku-20240307' => { input: 0.25, output: 1.25 },
|
|
19
|
+
# Google
|
|
20
|
+
'gemini-1.5-pro' => { input: 1.25, output: 5.00 },
|
|
21
|
+
'gemini-1.5-flash' => { input: 0.075, output: 0.30 }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def cost_for(model, prompt_tokens:, completion_tokens:)
|
|
27
|
+
table = RCrewAI.configuration.pricing || {}
|
|
28
|
+
entry = table[model] || DEFAULT_PRICES[model]
|
|
29
|
+
return nil unless entry
|
|
30
|
+
|
|
31
|
+
((prompt_tokens * entry[:input]) + (completion_tokens * entry[:output])) / 1_000_000.0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|