rcrewai 0.8.1 → 0.9.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 +20 -0
- data/ROADMAP.md +70 -25
- data/lib/rcrewai/agent.rb +8 -2
- data/lib/rcrewai/crew.rb +3 -1
- data/lib/rcrewai/knowledge/embedder.rb +25 -7
- data/lib/rcrewai/process.rb +34 -5
- data/lib/rcrewai/tool_runner.rb +77 -32
- data/lib/rcrewai/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d8a012bca72730f032aa41c0b8b9baeda9bc2dffaad12b3d742e1904fa2b879
|
|
4
|
+
data.tar.gz: 3bffa51d2181e09b651eeeb4b109005016048a7595f1d1a4a13a96175b6473dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7a78f7ea2652e8511324c737041bdd290e3df2ea5af75ee736292aa57ff47f26669dcb3fd09788b18f8f731341d46f9cc384468add953f05a8b2840b3fde79c
|
|
7
|
+
data.tar.gz: 9526bde229c78e8d4344a1c1cdb3343db511cc8145c8b6bd97dc2e047af23d063e3afe17cef6af51fefbfa70df3c91e6696c43b1d3cbfbbcb3a5f24e22dd978a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
Concurrency. Three points where independent, IO-bound work ran in sequence now
|
|
13
|
+
fan out across threads, each bounded and each preserving input order.
|
|
14
|
+
|
|
15
|
+
| | Before | After |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Tool calls in one turn (3 x 150ms) | 0.45s | 0.165s |
|
|
18
|
+
| Consensus with 3 agents | 1.22s | 0.22s |
|
|
19
|
+
|
|
20
|
+
Entirely additive: no API changes, and every fan-out can be disabled or
|
|
21
|
+
re-bounded. Concurrency is deliberately capped rather than unlimited — an
|
|
22
|
+
unbounded fan-out trades a latency problem for a rate-limit one.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Parallel tool execution: when a model requests several tools in one turn, `ToolRunner` now runs them concurrently instead of in sequence, so the turn costs the slowest call rather than their sum. Results are collected by index, so the message history stays aligned with the `tool_call` ids regardless of completion order. Bounded by `max_tool_concurrency` (default 8); a single tool call still runs inline with no thread. Disable per agent with `Agent.new(parallel_tools: false)`, or per runner with `ToolRunner.new(parallel_tools: false)`. The legacy `USE_TOOL[]` runner is unaffected — it parses one directive at a time.
|
|
26
|
+
- Tool events emitted from worker threads carry the enclosing run span, so the 0.8.0 event hierarchy stays intact under concurrency.
|
|
27
|
+
- Concurrent embedding: providers without a batch embeddings endpoint (`:google`, `:ollama`) issued one HTTP request per text, in sequence, so building a knowledge base cost the sum of every chunk's round-trip. Those requests now run concurrently, bounded by `max_concurrency` (default 8, configurable via `Knowledge::Embedder.new(max_concurrency:)`). Vectors are returned in input order, since `Knowledge::Base#build!` zips them back against their chunks positionally. An error in any request propagates rather than yielding a knowledge base with silently missing vectors. `:openai` and `:azure` already batch into a single request and are unchanged.
|
|
28
|
+
- Concurrent consensus: the `:consensual` process ran its proposal and scoring rounds one LLM call at a time — with the default three agents that is 3 proposals plus 9 scoring calls, twelve sequential round-trips per task. Proposals now run concurrently, and scoring fans out across the whole (candidate × voter) grid rather than per candidate (measured: 1.22s → 0.22s for three agents). Bounded by `consensus_max_concurrency` (default 8), since scoring is quadratic in participants — six agents would otherwise put 36 requests in flight at once. Results keep input order, so ties still break toward the task's assigned agent deterministically.
|
|
29
|
+
|
|
10
30
|
## [0.8.1] - 2026-09-10
|
|
11
31
|
|
|
12
32
|
Dependency cleanup. No API or behavior change.
|
data/ROADMAP.md
CHANGED
|
@@ -5,7 +5,7 @@ This roadmap tracks feature parity between **RCrewAI** (Ruby) and the upstream
|
|
|
5
5
|
|
|
6
6
|
## Current status
|
|
7
7
|
|
|
8
|
-
- **RCrewAI:** `0.
|
|
8
|
+
- **RCrewAI:** `0.8.1` released; 1.0.0 concurrency work merged to `main`, unreleased
|
|
9
9
|
- **Upstream crewai:** `1.15.21`
|
|
10
10
|
|
|
11
11
|
RCrewAI is a faithful port of CrewAI's **"Crews"** mental model (Agents / Tasks /
|
|
@@ -15,12 +15,12 @@ human-in-the-loop), and it carries CrewAI's second pillar (**Flows**) plus
|
|
|
15
15
|
**training/testing**. In one area — cognitive memory (semantic recall, SQLite
|
|
16
16
|
persistence, four memory types) — the gem went past what was originally ported.
|
|
17
17
|
|
|
18
|
-
**Status:
|
|
18
|
+
**Status: roadmap complete.** An earlier revision of this file declared
|
|
19
19
|
parity "complete" against a matrix that only covered CrewAI through roughly
|
|
20
20
|
`1.0` (October 2025) while quoting `1.15.x` in its header; everything upstream
|
|
21
|
-
added across `1.1`–`1.15` was unmeasured. That delta was re-derived, and
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
added across `1.1`–`1.15` was unmeasured. That delta was re-derived, and all four
|
|
22
|
+
scheduled milestones have since shipped. What remains are two additive items
|
|
23
|
+
(Bedrock/Responses streaming, SigV4) and one deferred concept (A2A).
|
|
24
24
|
|
|
25
25
|
## Parity matrix
|
|
26
26
|
|
|
@@ -57,7 +57,8 @@ is outstanding, and it is blocked on an open decision — see below.
|
|
|
57
57
|
|
|
58
58
|
| Concept | crewai | RCrewAI | Plan |
|
|
59
59
|
|---|---|---|---|
|
|
60
|
-
|
|
|
60
|
+
| Concurrent tool calls | ✅ (1.4–1.6) | ✅ (#47) | — |
|
|
61
|
+
| Concurrent embedding / consensus | ✅ (1.4–1.6) | ✅ (#49, #50) | — |
|
|
61
62
|
| OTel export for the event hierarchy | ✅ (1.10+) | ❌ | 1.0.x |
|
|
62
63
|
| Streaming for Bedrock / Responses | ✅ | ❌ | 1.0.x |
|
|
63
64
|
| Bedrock SigV4 signing | ✅ | ❌ (hook workaround) | 1.0.x |
|
|
@@ -115,24 +116,65 @@ Two deliberate limitations carried forward to 1.0.x: Bedrock does not implement
|
|
|
115
116
|
SigV4 (a hard `aws-sigv4` dependency for one provider is not worth it; sign via
|
|
116
117
|
a `before_request` hook), and Bedrock/Responses are non-streaming only.
|
|
117
118
|
|
|
118
|
-
### 1.0.0 —
|
|
119
|
+
### 1.0.0 — Concurrency ✅ shipped
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
async *through* the LLM and tool calls (1.4–1.6), covering flows, crews, tasks,
|
|
124
|
-
knowledge, and memory.
|
|
121
|
+
**Decision made: threads, not fibers.** The roadmap previously left this open.
|
|
122
|
+
Prototyping settled it, and not the way the fiber option's low apparent cost
|
|
123
|
+
suggested.
|
|
125
124
|
|
|
126
|
-
|
|
125
|
+
The attraction of fibers was that it looked cheap: keep Faraday, wrap calls in
|
|
126
|
+
`Async`, get non-blocking IO for free. That premise is false on this stack. On
|
|
127
|
+
Ruby 3.1.4 with `async` 2.24.0, a Faraday/`net_http` call inside a fiber
|
|
128
|
+
**never returns** — the task is silently abandoned and the process exits `0` as
|
|
129
|
+
though it succeeded. Raw `Net::HTTP` under `Async` raises `NoMethodError`
|
|
130
|
+
rather than yielding. A first benchmark appeared to show a 1.53s → 0.0s win;
|
|
131
|
+
it was measuring five failed requests that never reached the server.
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
Making fibers work would mean replacing Faraday with `async-http` across all
|
|
134
|
+
nine provider clients, adding a hard runtime dependency on a stack whose
|
|
135
|
+
observed failure mode is *silent abandonment* — the worst possible behavior in
|
|
136
|
+
an agent framework — and likely raising `required_ruby_version` from `3.0`,
|
|
137
|
+
since this fragility lives exactly in 3.0/3.1 scheduler support.
|
|
132
138
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
Threads cost none of that: `concurrent-ruby` is already a dependency, the Ruby
|
|
140
|
+
floor is unchanged, and failures are loud.
|
|
141
|
+
|
|
142
|
+
Upstream's async/await shape is a Python idiom; porting its *form* rather than
|
|
143
|
+
its *effect* would buy nothing here.
|
|
144
|
+
|
|
145
|
+
#### Shipped
|
|
146
|
+
|
|
147
|
+
Three fan-out points, all on threads, each bounded and each preserving input
|
|
148
|
+
order (every one of them feeds a positional zip or a deterministic tie-break
|
|
149
|
+
downstream):
|
|
150
|
+
|
|
151
|
+
- **Parallel tool calls** (#47) — a turn's tool calls run concurrently, so a
|
|
152
|
+
turn costs the slowest call rather than their sum. Measured 0.45s → 0.165s
|
|
153
|
+
for three 150ms tools. `Agent.new(parallel_tools: false)` opts out.
|
|
154
|
+
- **Concurrent embedding** (#49) — providers without a batch endpoint
|
|
155
|
+
(`:google`, `:ollama`) issued one request per text in sequence, so building a
|
|
156
|
+
knowledge base cost the sum of every chunk's round-trip. Bounded by
|
|
157
|
+
`Knowledge::Embedder.new(max_concurrency:)`.
|
|
158
|
+
- **Concurrent consensus** (#50) — the `:consensual` process made twelve
|
|
159
|
+
sequential LLM calls per task with three agents. Proposals and the whole
|
|
160
|
+
(candidate × voter) scoring grid now fan out. Measured 1.22s → 0.22s.
|
|
161
|
+
Bounded by `Crew.new(consensus_max_concurrency:)`.
|
|
162
|
+
|
|
163
|
+
Bounding was not optional: the first consensus implementation was unbounded and
|
|
164
|
+
put 36 concurrent LLM calls in flight with six agents — a thundering herd that
|
|
165
|
+
trips provider rate limits, which is a worse problem than the latency it
|
|
166
|
+
solves. Every fan-out here has a ceiling and a spec asserting it.
|
|
167
|
+
|
|
168
|
+
#### Remaining
|
|
169
|
+
|
|
170
|
+
An audit of `lib/` for per-item IO loops now turns up only CPU-bound work
|
|
171
|
+
(lexical similarity), so the task-boundary goal is met. What is left is not
|
|
172
|
+
concurrency work as such:
|
|
173
|
+
|
|
174
|
+
- Streaming for Bedrock and Responses (both non-streaming today).
|
|
175
|
+
- Bedrock SigV4 signing, currently a `before_request` hook workaround.
|
|
176
|
+
|
|
177
|
+
Neither blocks a 1.0.0 release; both are tracked in the gaps table.
|
|
136
178
|
|
|
137
179
|
### Deferred — A2A
|
|
138
180
|
|
|
@@ -147,12 +189,15 @@ no current RCrewAI user has asked for. Revisit once the items above land.
|
|
|
147
189
|
| 0.8.0 | Interceptors + observability | Low | ✅ merged (#39) |
|
|
148
190
|
| 0.9.0 | Checkpointing | Moderate | ✅ merged (#42) |
|
|
149
191
|
| 0.9.x | Providers, Responses API | Low | ✅ merged (#41) |
|
|
150
|
-
| 1.0.0 |
|
|
192
|
+
| 1.0.0 | Concurrency (threads) | Moderate | ✅ merged (#47, #49, #50) |
|
|
151
193
|
|
|
152
194
|
The three shipped milestones are on `main` and unreleased; they want a version
|
|
153
195
|
bump and a release before or alongside 1.0.0 work.
|
|
154
196
|
|
|
155
|
-
**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
197
|
+
**All scheduled milestones are merged.** The concurrency work landed on threads
|
|
198
|
+
across three fan-out points; see the milestone above for the prototype evidence
|
|
199
|
+
that ruled out fibers.
|
|
200
|
+
|
|
201
|
+
`main` carries the 1.0.0 concurrency work unreleased on top of `0.8.1`. The
|
|
202
|
+
remaining gaps — Bedrock/Responses streaming and SigV4 — are additive and do
|
|
203
|
+
not block cutting a release.
|
data/lib/rcrewai/agent.rb
CHANGED
|
@@ -37,6 +37,7 @@ module RCrewAI
|
|
|
37
37
|
@logger = Logger.new($stdout)
|
|
38
38
|
@logger.level = verbose ? Logger::DEBUG : Logger::INFO
|
|
39
39
|
@reasoning = options.fetch(:reasoning, false)
|
|
40
|
+
@parallel_tools = options.fetch(:parallel_tools, true)
|
|
40
41
|
@max_reasoning_attempts = options.fetch(:max_reasoning_attempts, 3)
|
|
41
42
|
@respect_context_window = options.fetch(:respect_context_window, false)
|
|
42
43
|
@memory = build_memory(options[:memory])
|
|
@@ -60,11 +61,16 @@ module RCrewAI
|
|
|
60
61
|
runner_class = pick_runner_class
|
|
61
62
|
@logger.info "[rcrewai] agent=#{name} runner=#{runner_class.name.split('::').last}"
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
runner_opts = {
|
|
64
65
|
agent: self, llm: @llm_client, tools: @tools,
|
|
65
66
|
max_iterations: opts.fetch(:max_iterations, max_iterations),
|
|
66
67
|
event_sink: sink
|
|
67
|
-
|
|
68
|
+
}
|
|
69
|
+
# Only the native-tool runner executes tool calls concurrently; the
|
|
70
|
+
# legacy runner parses one USE_TOOL[] directive at a time.
|
|
71
|
+
runner_opts[:parallel_tools] = @parallel_tools if runner_class == ToolRunner
|
|
72
|
+
|
|
73
|
+
runner = runner_class.new(**runner_opts)
|
|
68
74
|
|
|
69
75
|
runner_result = runner.run(messages: initial_messages)
|
|
70
76
|
execution_time = Time.now - start_time
|
data/lib/rcrewai/crew.rb
CHANGED
|
@@ -23,6 +23,7 @@ module RCrewAI
|
|
|
23
23
|
@planning_llm = options[:planning_llm]
|
|
24
24
|
@planned = false
|
|
25
25
|
@consensus_agents = options.fetch(:consensus_agents, 3)
|
|
26
|
+
@consensus_max_concurrency = options[:consensus_max_concurrency]
|
|
26
27
|
@knowledge = build_knowledge(options[:knowledge], options[:knowledge_sources])
|
|
27
28
|
@before_kickoff_hooks = []
|
|
28
29
|
@after_kickoff_hooks = []
|
|
@@ -35,7 +36,8 @@ module RCrewAI
|
|
|
35
36
|
validate_process_type!
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
attr_reader :knowledge, :stream_sink, :last_inputs, :consensus_agents, :run_id
|
|
39
|
+
attr_reader :knowledge, :stream_sink, :last_inputs, :consensus_agents, :run_id,
|
|
40
|
+
:consensus_max_concurrency
|
|
39
41
|
|
|
40
42
|
def planning?
|
|
41
43
|
@planning
|
|
@@ -22,9 +22,16 @@ module RCrewAI
|
|
|
22
22
|
GOOGLE_BASE = 'https://generativelanguage.googleapis.com/v1beta'
|
|
23
23
|
OLLAMA_DEFAULT_URL = 'http://localhost:11434'
|
|
24
24
|
|
|
25
|
+
# Providers without a batch embeddings endpoint issue one request per
|
|
26
|
+
# text. Those are independent and IO-bound, so they run concurrently --
|
|
27
|
+
# bounded, since a large knowledge base would otherwise open a request
|
|
28
|
+
# per chunk at once.
|
|
29
|
+
DEFAULT_MAX_CONCURRENCY = 8
|
|
30
|
+
|
|
25
31
|
attr_reader :provider, :model
|
|
26
32
|
|
|
27
|
-
def initialize(provider: :openai, model: nil, api_key: nil, config: RCrewAI.configuration
|
|
33
|
+
def initialize(provider: :openai, model: nil, api_key: nil, config: RCrewAI.configuration,
|
|
34
|
+
max_concurrency: DEFAULT_MAX_CONCURRENCY)
|
|
28
35
|
@provider = provider.to_sym
|
|
29
36
|
if @provider == :anthropic
|
|
30
37
|
raise EmbeddingError,
|
|
@@ -34,6 +41,7 @@ module RCrewAI
|
|
|
34
41
|
@config = config
|
|
35
42
|
@model = model || DEFAULT_MODELS[@provider] || DEFAULT_MODELS[:openai]
|
|
36
43
|
@api_key = api_key
|
|
44
|
+
@max_concurrency = max_concurrency
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
def embed(texts)
|
|
@@ -62,19 +70,29 @@ module RCrewAI
|
|
|
62
70
|
|
|
63
71
|
def embed_google(texts)
|
|
64
72
|
key = api_key_for(:google)
|
|
65
|
-
texts
|
|
73
|
+
concurrent_map(texts) do |text|
|
|
66
74
|
url = "#{GOOGLE_BASE}/models/#{@model}:embedContent?key=#{key}"
|
|
67
75
|
payload = { model: "models/#{@model}", content: { parts: [{ text: text }] } }
|
|
68
|
-
|
|
69
|
-
body.dig('embedding', 'values')
|
|
76
|
+
post_json(url, payload).dig('embedding', 'values')
|
|
70
77
|
end
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
def embed_ollama(texts)
|
|
74
81
|
base = @config.base_url || OLLAMA_DEFAULT_URL
|
|
75
|
-
texts
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
concurrent_map(texts) do |text|
|
|
83
|
+
post_json("#{base}/api/embeddings", { model: @model, prompt: text })['embedding']
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Maps over texts in bounded parallel, preserving input order: the caller
|
|
88
|
+
# zips vectors back against their chunks, so completion order must not
|
|
89
|
+
# leak into the result. An error in any request propagates -- a knowledge
|
|
90
|
+
# base with silently missing vectors is worse than a failed build.
|
|
91
|
+
def concurrent_map(texts, &block)
|
|
92
|
+
return texts.map(&block) if texts.length < 2 || @max_concurrency < 2
|
|
93
|
+
|
|
94
|
+
texts.each_slice(@max_concurrency).flat_map do |slice|
|
|
95
|
+
slice.map { |text| Thread.new { block.call(text) } }.map(&:value)
|
|
78
96
|
end
|
|
79
97
|
end
|
|
80
98
|
|
data/lib/rcrewai/process.rb
CHANGED
|
@@ -410,6 +410,11 @@ module RCrewAI
|
|
|
410
410
|
class Consensual < Base
|
|
411
411
|
DEFAULT_CONSENSUS_AGENTS = 3
|
|
412
412
|
|
|
413
|
+
# Scoring is quadratic in participants (agents x candidates), so an
|
|
414
|
+
# unbounded fan-out would put a request per pair in flight at once --
|
|
415
|
+
# six agents is already 36 concurrent LLM calls. Cap it.
|
|
416
|
+
DEFAULT_MAX_CONCURRENCY = 8
|
|
417
|
+
|
|
413
418
|
def execute
|
|
414
419
|
log_execution_start
|
|
415
420
|
@logger.info 'Consensual execution - agents propose, vote, and pick'
|
|
@@ -458,23 +463,47 @@ module RCrewAI
|
|
|
458
463
|
chosen
|
|
459
464
|
end
|
|
460
465
|
|
|
466
|
+
# Proposals are independent LLM calls, so they run concurrently: with the
|
|
467
|
+
# default three agents this turns three sequential round-trips into one.
|
|
468
|
+
# filter_map order is preserved so ties still break toward the assigned
|
|
469
|
+
# agent deterministically.
|
|
461
470
|
def gather_proposals(task, participants)
|
|
462
|
-
participants.
|
|
471
|
+
bounded_parallel(participants.map { |a| [a] }) do |agent|
|
|
463
472
|
content = extract_content(agent.execute_task(task, stream: crew.stream_sink))
|
|
464
473
|
{ agent: agent, content: content }
|
|
465
474
|
rescue StandardError => e
|
|
466
475
|
@logger.warn "Agent #{agent.name} failed to propose: #{e.message}"
|
|
467
476
|
nil
|
|
468
|
-
end
|
|
477
|
+
end.compact
|
|
469
478
|
end
|
|
470
479
|
|
|
480
|
+
# Every (candidate, voter) pair is an independent LLM call. Scoring is the
|
|
481
|
+
# quadratic half of consensus -- three agents means nine calls -- so it
|
|
482
|
+
# fans out over the whole grid rather than per candidate.
|
|
471
483
|
def score_candidates(task, candidates, participants)
|
|
472
|
-
candidates.map
|
|
473
|
-
|
|
474
|
-
|
|
484
|
+
pairs = candidates.flat_map { |c| participants.map { |v| [c, v] } }
|
|
485
|
+
scores = bounded_parallel(pairs) { |c, v| score(v, task, c[:content]) }
|
|
486
|
+
|
|
487
|
+
candidates.each_with_index.map do |candidate, i|
|
|
488
|
+
row = scores[(i * participants.length), participants.length]
|
|
489
|
+
candidate.merge(score: row.sum)
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Runs the block over items in bounded parallel, preserving input order.
|
|
494
|
+
def bounded_parallel(items)
|
|
495
|
+
span = Events.current_parent
|
|
496
|
+
items.each_slice(max_concurrency).flat_map do |slice|
|
|
497
|
+
slice.map { |item| Thread.new { Events.with_parent(span) { yield(*item) } } }
|
|
498
|
+
.map(&:value)
|
|
475
499
|
end
|
|
476
500
|
end
|
|
477
501
|
|
|
502
|
+
def max_concurrency
|
|
503
|
+
crew.respond_to?(:consensus_max_concurrency) && crew.consensus_max_concurrency ||
|
|
504
|
+
DEFAULT_MAX_CONCURRENCY
|
|
505
|
+
end
|
|
506
|
+
|
|
478
507
|
def score(voter, task, candidate_content)
|
|
479
508
|
prompt = <<~PROMPT
|
|
480
509
|
Score how well the following answer satisfies the task, from 0 to 10.
|
data/lib/rcrewai/tool_runner.rb
CHANGED
|
@@ -7,14 +7,18 @@ require_relative 'provider_schema'
|
|
|
7
7
|
module RCrewAI
|
|
8
8
|
class ToolRunner
|
|
9
9
|
DEFAULT_MAX_ITERATIONS = 10
|
|
10
|
+
DEFAULT_TOOL_CONCURRENCY = 8
|
|
10
11
|
|
|
11
|
-
def initialize(agent:, llm:, tools:,
|
|
12
|
+
def initialize(agent:, llm:, tools:, **opts)
|
|
12
13
|
@agent = agent
|
|
13
14
|
@llm = llm
|
|
14
15
|
@tools = tools
|
|
15
16
|
@tools_by_name = tools.each_with_object({}) { |t, h| h[t.name] = t }
|
|
16
|
-
@max_iterations = max_iterations
|
|
17
|
-
@sink = event_sink || ->(_) {}
|
|
17
|
+
@max_iterations = opts.fetch(:max_iterations, DEFAULT_MAX_ITERATIONS)
|
|
18
|
+
@sink = opts[:event_sink] || ->(_) {}
|
|
19
|
+
@parallel_tools = opts.fetch(:parallel_tools, true)
|
|
20
|
+
@max_tool_concurrency = opts.fetch(:max_tool_concurrency, DEFAULT_TOOL_CONCURRENCY)
|
|
21
|
+
@tool_usage_lock = Mutex.new
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
def run(messages:)
|
|
@@ -23,7 +27,7 @@ module RCrewAI
|
|
|
23
27
|
|
|
24
28
|
private
|
|
25
29
|
|
|
26
|
-
def run_loop(messages:)
|
|
30
|
+
def run_loop(messages:)
|
|
27
31
|
msgs = messages.dup
|
|
28
32
|
history = []
|
|
29
33
|
iter = 0
|
|
@@ -48,34 +52,9 @@ module RCrewAI
|
|
|
48
52
|
|
|
49
53
|
msgs << { role: 'assistant', content: response[:content], tool_calls: response[:tool_calls] }
|
|
50
54
|
|
|
51
|
-
response[:tool_calls].each do |
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
tool: tc[:name], args: tc[:arguments], call_id: tc[:id])
|
|
55
|
-
|
|
56
|
-
if tool.nil?
|
|
57
|
-
err = "tool not found: #{tc[:name]}"
|
|
58
|
-
emit(Events::ToolCallError, iteration: iter,
|
|
59
|
-
tool: tc[:name], call_id: tc[:id], error: err)
|
|
60
|
-
msgs << tool_result_message(tc[:id], "ERROR: #{err}")
|
|
61
|
-
next
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
started = monotonic_ms
|
|
65
|
-
begin
|
|
66
|
-
result = tool.execute_with_validation(tc[:arguments] || {})
|
|
67
|
-
duration = monotonic_ms - started
|
|
68
|
-
@agent.memory.add_tool_usage(tc[:name], tc[:arguments], result) if @agent.respond_to?(:memory) && @agent.memory
|
|
69
|
-
emit(Events::ToolCallResult, iteration: iter,
|
|
70
|
-
tool: tc[:name], call_id: tc[:id], result: result,
|
|
71
|
-
duration_ms: duration)
|
|
72
|
-
history << { tool: tc[:name], args: tc[:arguments], result: result, duration_ms: duration }
|
|
73
|
-
msgs << tool_result_message(tc[:id], result.to_s)
|
|
74
|
-
rescue StandardError => e
|
|
75
|
-
emit(Events::ToolCallError, iteration: iter,
|
|
76
|
-
tool: tc[:name], call_id: tc[:id], error: e.message)
|
|
77
|
-
msgs << tool_result_message(tc[:id], "ERROR: #{e.message}")
|
|
78
|
-
end
|
|
55
|
+
run_tool_calls(response[:tool_calls], iter).each do |outcome|
|
|
56
|
+
history << outcome[:history] if outcome[:history]
|
|
57
|
+
msgs << outcome[:message]
|
|
79
58
|
end
|
|
80
59
|
|
|
81
60
|
emit(Events::IterationEnd, iteration: iter, finish_reason: :tool_calls)
|
|
@@ -95,6 +74,72 @@ module RCrewAI
|
|
|
95
74
|
{ role: 'tool', tool_call_id: call_id, content: content }
|
|
96
75
|
end
|
|
97
76
|
|
|
77
|
+
# Executes one turn's tool calls, concurrently when there is more than one.
|
|
78
|
+
#
|
|
79
|
+
# Models routinely request several independent tools in a single turn;
|
|
80
|
+
# running them in sequence makes the turn cost the sum of their latencies
|
|
81
|
+
# rather than the max. Results are collected by index, so the order the
|
|
82
|
+
# model asked for is preserved no matter which finishes first -- the
|
|
83
|
+
# message history must stay aligned with the tool_call ids.
|
|
84
|
+
def run_tool_calls(tool_calls, iter)
|
|
85
|
+
return tool_calls.map { |tc| execute_tool_call(tc, iter) } unless parallel?(tool_calls)
|
|
86
|
+
|
|
87
|
+
# Events are emitted from worker threads, so carry the run span across
|
|
88
|
+
# the boundary: Events.with_parent is thread-local by design.
|
|
89
|
+
span = Events.current_parent
|
|
90
|
+
tool_calls.each_slice(@max_tool_concurrency).flat_map do |slice|
|
|
91
|
+
slice.map { |tc| Thread.new { Events.with_parent(span) { execute_tool_call(tc, iter) } } }
|
|
92
|
+
.map(&:value)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parallel?(tool_calls)
|
|
97
|
+
@parallel_tools && tool_calls.length > 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Runs one tool call and returns what the caller should record. Never
|
|
101
|
+
# raises: a failing tool becomes an ERROR message fed back to the model,
|
|
102
|
+
# exactly as it did when this ran inline.
|
|
103
|
+
def execute_tool_call(call, iter)
|
|
104
|
+
tool = @tools_by_name[call[:name]]
|
|
105
|
+
emit(Events::ToolCallStart, iteration: iter,
|
|
106
|
+
tool: call[:name], args: call[:arguments], call_id: call[:id])
|
|
107
|
+
|
|
108
|
+
if tool.nil?
|
|
109
|
+
err = "tool not found: #{call[:name]}"
|
|
110
|
+
emit(Events::ToolCallError, iteration: iter,
|
|
111
|
+
tool: call[:name], call_id: call[:id], error: err)
|
|
112
|
+
return { message: tool_result_message(call[:id], "ERROR: #{err}") }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
started = monotonic_ms
|
|
116
|
+
begin
|
|
117
|
+
result = tool.execute_with_validation(call[:arguments] || {})
|
|
118
|
+
duration = monotonic_ms - started
|
|
119
|
+
record_tool_usage(call, result)
|
|
120
|
+
emit(Events::ToolCallResult, iteration: iter,
|
|
121
|
+
tool: call[:name], call_id: call[:id], result: result,
|
|
122
|
+
duration_ms: duration)
|
|
123
|
+
{
|
|
124
|
+
history: { tool: call[:name], args: call[:arguments], result: result, duration_ms: duration },
|
|
125
|
+
message: tool_result_message(call[:id], result.to_s)
|
|
126
|
+
}
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
emit(Events::ToolCallError, iteration: iter,
|
|
129
|
+
tool: call[:name], call_id: call[:id], error: e.message)
|
|
130
|
+
{ message: tool_result_message(call[:id], "ERROR: #{e.message}") }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Agent memory is shared across the worker threads of one turn.
|
|
135
|
+
def record_tool_usage(call, result)
|
|
136
|
+
return unless @agent.respond_to?(:memory) && @agent.memory
|
|
137
|
+
|
|
138
|
+
@tool_usage_lock.synchronize do
|
|
139
|
+
@agent.memory.add_tool_usage(call[:name], call[:arguments], result)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
98
143
|
def emit(klass, iteration:, **attrs)
|
|
99
144
|
type_sym = klass.name.split('::').last
|
|
100
145
|
.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
|
data/lib/rcrewai/version.rb
CHANGED