rcrewai 0.2.1 → 0.3.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 +1 -0
- data/.rubocop_todo.yml +99 -0
- data/CHANGELOG.md +24 -0
- data/README.md +2 -2
- 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 +148 -287
- data/lib/rcrewai/async_executor.rb +43 -43
- data/lib/rcrewai/cli.rb +11 -11
- data/lib/rcrewai/configuration.rb +14 -9
- data/lib/rcrewai/crew.rb +56 -39
- data/lib/rcrewai/events.rb +30 -0
- data/lib/rcrewai/human_input.rb +104 -114
- data/lib/rcrewai/legacy_react_runner.rb +172 -0
- data/lib/rcrewai/llm_client.rb +1 -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/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 +56 -64
- 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 +18 -10
- data/rcrewai.gemspec +39 -39
- metadata +65 -47
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
3
5
|
require_relative 'base'
|
|
6
|
+
require_relative '../events'
|
|
7
|
+
require_relative '../sse_parser'
|
|
8
|
+
require_relative '../provider_schema'
|
|
9
|
+
require_relative '../pricing'
|
|
4
10
|
|
|
5
11
|
module RCrewAI
|
|
6
12
|
module LLMClients
|
|
@@ -12,113 +18,184 @@ module RCrewAI
|
|
|
12
18
|
@base_url = BASE_URL
|
|
13
19
|
end
|
|
14
20
|
|
|
15
|
-
def chat(messages:, **options)
|
|
21
|
+
def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
|
|
16
22
|
payload = {
|
|
17
23
|
model: config.model,
|
|
18
|
-
messages:
|
|
24
|
+
messages: messages,
|
|
19
25
|
temperature: options[:temperature] || config.temperature,
|
|
20
26
|
max_tokens: options[:max_tokens] || config.max_tokens
|
|
21
|
-
}
|
|
27
|
+
}.compact
|
|
22
28
|
|
|
23
|
-
# Add additional OpenAI-specific options
|
|
24
29
|
payload[:top_p] = options[:top_p] if options[:top_p]
|
|
25
30
|
payload[:frequency_penalty] = options[:frequency_penalty] if options[:frequency_penalty]
|
|
26
31
|
payload[:presence_penalty] = options[:presence_penalty] if options[:presence_penalty]
|
|
27
32
|
payload[:stop] = options[:stop] if options[:stop]
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
log_response(response)
|
|
34
|
-
|
|
35
|
-
result = handle_response(response)
|
|
36
|
-
format_response(result)
|
|
37
|
-
end
|
|
34
|
+
if tools && !tools.empty?
|
|
35
|
+
payload[:tools] = ProviderSchema.for_many(:openai, tools)
|
|
36
|
+
payload[:tool_choice] = tool_choice if tool_choice != :auto
|
|
37
|
+
end
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
completion_request(prompt, **options)
|
|
39
|
+
if stream
|
|
40
|
+
payload[:stream] = true
|
|
41
|
+
payload[:stream_options] = { include_usage: true }
|
|
42
|
+
stream_chat(payload, stream)
|
|
44
43
|
else
|
|
45
|
-
|
|
46
|
-
super
|
|
44
|
+
plain_chat(payload)
|
|
47
45
|
end
|
|
48
46
|
end
|
|
49
47
|
|
|
48
|
+
def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
50
52
|
def models
|
|
51
53
|
url = "#{@base_url}/models"
|
|
52
|
-
response = http_client.get(url, {}, build_headers.merge(
|
|
54
|
+
response = http_client.get(url, {}, build_headers.merge(auth_header))
|
|
53
55
|
result = handle_response(response)
|
|
54
56
|
result['data'].map { |model| model['id'] }
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
private
|
|
58
60
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
+
def chat_url
|
|
62
|
+
"#{@base_url}/chat/completions"
|
|
61
63
|
end
|
|
62
64
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
def plain_chat(payload)
|
|
66
|
+
url = chat_url
|
|
67
|
+
log_request(:post, url, payload)
|
|
68
|
+
response = http_client.post(url, payload, build_headers.merge(auth_header))
|
|
69
|
+
log_response(response)
|
|
70
|
+
body = handle_response(response)
|
|
71
|
+
normalize_non_streaming(body)
|
|
72
|
+
end
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
def stream_chat(payload, sink) # rubocop:disable Metrics/AbcSize
|
|
75
|
+
url = chat_url
|
|
72
76
|
log_request(:post, url, payload)
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
assembled_text = +''
|
|
79
|
+
tool_calls_by_index = {}
|
|
80
|
+
final_usage = nil
|
|
81
|
+
finish_reason = nil
|
|
82
|
+
|
|
83
|
+
parser = SSEParser.new do |sse|
|
|
84
|
+
data_str = sse[:data]
|
|
85
|
+
next if data_str == '[DONE]'
|
|
86
|
+
|
|
87
|
+
data = JSON.parse(data_str)
|
|
88
|
+
choice = data.dig('choices', 0) || {}
|
|
89
|
+
delta = choice['delta'] || {}
|
|
90
|
+
|
|
91
|
+
if delta['content']
|
|
92
|
+
assembled_text << delta['content']
|
|
93
|
+
sink.call(Events::TextDelta.new(
|
|
94
|
+
type: :text_delta, timestamp: Time.now, agent: nil, iteration: nil,
|
|
95
|
+
text: delta['content']
|
|
96
|
+
))
|
|
97
|
+
end
|
|
76
98
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
Array(delta['tool_calls']).each do |tc|
|
|
100
|
+
idx = tc['index']
|
|
101
|
+
tool_calls_by_index[idx] ||= { id: nil, name: nil, arguments: +'' }
|
|
102
|
+
tool_calls_by_index[idx][:id] ||= tc['id']
|
|
103
|
+
tool_calls_by_index[idx][:name] ||= tc.dig('function', 'name')
|
|
104
|
+
tool_calls_by_index[idx][:arguments] << (tc.dig('function', 'arguments') || '')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
finish_reason ||= choice['finish_reason']&.to_sym
|
|
80
108
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
if data['usage']
|
|
110
|
+
final_usage = {
|
|
111
|
+
prompt_tokens: data['usage']['prompt_tokens'],
|
|
112
|
+
completion_tokens: data['usage']['completion_tokens'],
|
|
113
|
+
total_tokens: data['usage']['total_tokens']
|
|
114
|
+
}
|
|
87
115
|
end
|
|
88
116
|
end
|
|
89
|
-
end
|
|
90
117
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
streaming_post(url, payload) { |chunk| parser.feed(chunk) }
|
|
119
|
+
|
|
120
|
+
tool_calls = tool_calls_by_index.values.map do |tc|
|
|
121
|
+
{
|
|
122
|
+
id: tc[:id],
|
|
123
|
+
name: tc[:name],
|
|
124
|
+
arguments: tc[:arguments].empty? ? {} : JSON.parse(tc[:arguments])
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if final_usage
|
|
129
|
+
sink.call(Events::Usage.new(
|
|
130
|
+
type: :usage, timestamp: Time.now, agent: nil, iteration: nil,
|
|
131
|
+
prompt_tokens: final_usage[:prompt_tokens],
|
|
132
|
+
completion_tokens: final_usage[:completion_tokens],
|
|
133
|
+
total_tokens: final_usage[:total_tokens],
|
|
134
|
+
cost_usd: Pricing.cost_for(config.model,
|
|
135
|
+
prompt_tokens: final_usage[:prompt_tokens],
|
|
136
|
+
completion_tokens: final_usage[:completion_tokens])
|
|
137
|
+
))
|
|
138
|
+
end
|
|
94
139
|
|
|
95
140
|
{
|
|
96
|
-
content:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
model:
|
|
101
|
-
provider:
|
|
141
|
+
content: assembled_text.empty? ? nil : assembled_text,
|
|
142
|
+
tool_calls: tool_calls,
|
|
143
|
+
usage: final_usage || {},
|
|
144
|
+
finish_reason: finish_reason || :stop,
|
|
145
|
+
model: config.model,
|
|
146
|
+
provider: provider_name
|
|
102
147
|
}
|
|
103
148
|
end
|
|
104
149
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
def provider_name
|
|
151
|
+
:openai
|
|
152
|
+
end
|
|
108
153
|
|
|
154
|
+
def streaming_post(url, payload, &on_chunk)
|
|
155
|
+
conn = Faraday.new do |f|
|
|
156
|
+
f.request :json
|
|
157
|
+
f.options.timeout = config.timeout
|
|
158
|
+
f.adapter Faraday.default_adapter
|
|
159
|
+
end
|
|
160
|
+
conn.post(url) do |req|
|
|
161
|
+
req.headers = build_headers.merge(auth_header)
|
|
162
|
+
req.body = payload.to_json
|
|
163
|
+
req.options.on_data = proc { |chunk, _| on_chunk.call(chunk) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def normalize_non_streaming(body)
|
|
168
|
+
choice = body.dig('choices', 0) || {}
|
|
169
|
+
msg = choice['message'] || {}
|
|
170
|
+
tool_calls = Array(msg['tool_calls']).map do |tc|
|
|
171
|
+
{
|
|
172
|
+
id: tc['id'],
|
|
173
|
+
name: tc.dig('function', 'name'),
|
|
174
|
+
arguments: JSON.parse(tc.dig('function', 'arguments') || '{}')
|
|
175
|
+
}
|
|
176
|
+
end
|
|
109
177
|
{
|
|
110
|
-
content:
|
|
111
|
-
|
|
112
|
-
usage:
|
|
113
|
-
|
|
114
|
-
|
|
178
|
+
content: msg['content'],
|
|
179
|
+
tool_calls: tool_calls,
|
|
180
|
+
usage: {
|
|
181
|
+
prompt_tokens: body.dig('usage', 'prompt_tokens'),
|
|
182
|
+
completion_tokens: body.dig('usage', 'completion_tokens'),
|
|
183
|
+
total_tokens: body.dig('usage', 'total_tokens')
|
|
184
|
+
},
|
|
185
|
+
finish_reason: (choice['finish_reason'] || 'stop').to_sym,
|
|
186
|
+
model: body['model'] || config.model,
|
|
187
|
+
provider: provider_name
|
|
115
188
|
}
|
|
116
189
|
end
|
|
117
190
|
|
|
191
|
+
def auth_header
|
|
192
|
+
{ 'Authorization' => "Bearer #{config.openai_api_key || config.api_key}" }
|
|
193
|
+
end
|
|
194
|
+
|
|
118
195
|
def validate_config!
|
|
119
|
-
raise ConfigurationError,
|
|
120
|
-
raise ConfigurationError,
|
|
196
|
+
raise ConfigurationError, 'OpenAI API key is required' unless config.openai_api_key || config.api_key
|
|
197
|
+
raise ConfigurationError, 'Model is required' unless config.model
|
|
121
198
|
end
|
|
122
199
|
end
|
|
123
200
|
end
|
|
124
|
-
end
|
|
201
|
+
end
|
|
@@ -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
|