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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbd74067513b5593791b282baf20f18c8dd8f6cd9257b02f70423ee4cba3486d
4
- data.tar.gz: 215cade06d04b032d254e792a2af9d36d7c00de073580dff31125d46e5de4b48
3
+ metadata.gz: 9d8a012bca72730f032aa41c0b8b9baeda9bc2dffaad12b3d742e1904fa2b879
4
+ data.tar.gz: 3bffa51d2181e09b651eeeb4b109005016048a7595f1d1a4a13a96175b6473dd
5
5
  SHA512:
6
- metadata.gz: 735222585596dc578801792d74a6f5435037c78352f10e80670b2e179e0cce11a6257b79624e8a1abd0a3aa6978d86ec852e02cbdd6dc22e00a1364eafbb1058
7
- data.tar.gz: 3f5b7145864b603e292b9daced5ef1c39c2d3084847658265d2b67a58ef8e3575ba8383e9494e7a2bcfcd7d6de231daadaac43ae41245afea7ed2c4f817649b9
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.7.1` released; `0.8.0`, `0.9.0` and `0.9.x` merged to `main`, unreleased
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: one milestone remaining.** An earlier revision of this file declared
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 three
22
- of the four scheduled milestones have since shipped. Only native async (1.0.0)
23
- is outstanding, and it is blocked on an open decision — see below.
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
- | Native async (LLM + tool level) | ✅ (1.4–1.6) | ⚠️ partial | 1.0.0 |
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 — Native async
119
+ ### 1.0.0 — Concurrency ✅ shipped
119
120
 
120
- The largest item, and the one with a genuine architecture decision attached.
121
- Today `AsyncExecutor` fans tasks out across a `Concurrent::ThreadPoolExecutor` in
122
- dependency-ordered phases; concurrency stops at the task boundary. CrewAI went
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
- Ruby has no direct port of that. Two candidate models:
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
- 1. **Fibers** via the `async` gem closer to upstream's shape, new runtime
129
- dependency, and every provider client's HTTP layer has to cooperate.
130
- 2. **Stay on threads** and make the client layer non-blocking — smaller
131
- conceptual change, keeps `concurrent-ruby`, less faithful to upstream.
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
- **This decision is open and blocks the milestone.** It touches all five LLM
134
- clients either way. The 8.2k lines of `lib/` are backed by 4.9k lines of spec,
135
- which is what makes a refactor at this depth tractable.
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 | Native async | High decision open | blocked |
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
- **1.0.0 is blocked on the fibers-vs-threads decision above.** It is the only
156
- remaining scheduled work, and the largest single change in this roadmap: it
157
- touches all nine provider clients and the executor. Nothing else should start
158
- before that call is made.
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
- runner = runner_class.new(
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.map do |text|
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
- body = post_json(url, payload)
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.map do |text|
76
- body = post_json("#{base}/api/embeddings", { model: @model, prompt: text })
77
- body['embedding']
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
 
@@ -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.filter_map do |agent|
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 do |candidate|
473
- total = participants.sum { |voter| score(voter, task, candidate[:content]) }
474
- candidate.merge(score: total)
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.
@@ -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:, max_iterations: DEFAULT_MAX_ITERATIONS, event_sink: nil)
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:) # rubocop:disable Metrics/AbcSize
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 |tc|
52
- tool = @tools_by_name[tc[:name]]
53
- emit(Events::ToolCallStart, iteration: iter,
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}" }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RCrewAI
4
- VERSION = '0.8.1'
4
+ VERSION = '0.9.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rcrewai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gkosmo