swarm_sdk 2.7.10 → 2.7.11

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.
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adds concurrent tool execution support to RubyLLM::Chat
4
+ # Supports :async and :threads executors with configurable concurrency limits
5
+ #
6
+ # Fork Reference: Commit d0912c7, file lib/ruby_llm/tool_executors.rb
7
+
8
+ module RubyLLM
9
+ # Tool executor registry
10
+ class << self
11
+ def tool_executors
12
+ @tool_executors ||= {}
13
+ end
14
+
15
+ def register_tool_executor(name, &block)
16
+ tool_executors[name] = block
17
+ end
18
+
19
+ def get_tool_executor(name)
20
+ tool_executors[name] || raise(ArgumentError, "Unknown tool executor: #{name}")
21
+ end
22
+ end
23
+
24
+ # Built-in tool executors
25
+ module ToolExecutors
26
+ class << self
27
+ def register_defaults
28
+ register_threads_executor
29
+ register_async_executor
30
+ end
31
+
32
+ private
33
+
34
+ def register_threads_executor
35
+ RubyLLM.register_tool_executor(:threads) do |tool_calls, max_concurrency:, &execute|
36
+ results = {}
37
+ mutex = Mutex.new
38
+ semaphore = max_concurrency ? Thread::SizedQueue.new(max_concurrency) : nil
39
+
40
+ # Fill semaphore with permits
41
+ max_concurrency&.times { semaphore << :permit }
42
+
43
+ threads = tool_calls.map do |tool_call|
44
+ Thread.new do
45
+ permit = semaphore&.pop
46
+
47
+ begin
48
+ result = execute.call(tool_call)
49
+ mutex.synchronize { results[tool_call.id] = result }
50
+ rescue StandardError => e
51
+ error_result = "Error: #{e.class}: #{e.message}"
52
+ mutex.synchronize { results[tool_call.id] = error_result }
53
+ RubyLLM.logger.warn("[RubyLLM] Tool #{tool_call.id} failed: #{e.message}")
54
+ ensure
55
+ semaphore&.push(permit) if permit
56
+ end
57
+ end
58
+ end
59
+
60
+ threads.each(&:join)
61
+ results
62
+ end
63
+ end
64
+
65
+ def register_async_executor
66
+ RubyLLM.register_tool_executor(:async) do |tool_calls, max_concurrency:, &execute|
67
+ AsyncExecutor.execute(tool_calls, max_concurrency: max_concurrency, &execute)
68
+ end
69
+ end
70
+ end
71
+
72
+ module AsyncExecutor
73
+ class << self
74
+ def execute(tool_calls, max_concurrency:, &block)
75
+ load_async_gem
76
+ run_with_sync { execute_tools(tool_calls, max_concurrency, &block) }
77
+ end
78
+
79
+ private
80
+
81
+ def load_async_gem
82
+ require "async"
83
+ require "async/barrier"
84
+ require "async/semaphore"
85
+ rescue LoadError => e
86
+ raise LoadError,
87
+ "The async gem is required for :async tool executor. " \
88
+ "Add `gem 'async'` to your Gemfile. Original error: #{e.message}"
89
+ end
90
+
91
+ def run_with_sync(&)
92
+ if defined?(Sync)
93
+ Sync(&)
94
+ else
95
+ Async(&).wait
96
+ end
97
+ end
98
+
99
+ def execute_tools(tool_calls, max_concurrency)
100
+ semaphore = max_concurrency ? Async::Semaphore.new(max_concurrency) : nil
101
+ barrier = Async::Barrier.new
102
+ results = {}
103
+
104
+ tool_calls.each do |tool_call|
105
+ barrier.async do
106
+ results[tool_call.id] = execute_single_tool(tool_call, semaphore) { yield tool_call }
107
+ rescue StandardError => e
108
+ results[tool_call.id] = "Error: #{e.class}: #{e.message}"
109
+ RubyLLM.logger.warn("[RubyLLM] Tool #{tool_call.id} failed: #{e.message}")
110
+ end
111
+ end
112
+
113
+ barrier.wait
114
+ results
115
+ end
116
+
117
+ def execute_single_tool(_tool_call, semaphore, &)
118
+ if semaphore
119
+ semaphore.acquire(&)
120
+ else
121
+ yield
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ class Chat
129
+ attr_reader :tool_concurrency, :max_concurrency
130
+
131
+ # Module to prepend for concurrent tool execution
132
+ module ConcurrentToolExecution
133
+ def initialize(tool_concurrency: nil, max_concurrency: nil, **kwargs)
134
+ @tool_concurrency = tool_concurrency
135
+ @max_concurrency = max_concurrency
136
+ super(**kwargs)
137
+ end
138
+
139
+ # Configure tool concurrency
140
+ def with_tool_concurrency(executor, max: nil)
141
+ @tool_concurrency = executor
142
+ @max_concurrency = max
143
+ self
144
+ end
145
+
146
+ private
147
+
148
+ # Override handle_tool_calls to support concurrent execution
149
+ # This method is called when tool_concurrency is set
150
+ def handle_tool_calls(response, &block)
151
+ return super unless @tool_concurrency
152
+
153
+ tool_calls = response.tool_calls
154
+ halt_result = execute_tools_concurrently(tool_calls)
155
+ halt_result || complete(&block)
156
+ end
157
+
158
+ def execute_tools_concurrently(tool_calls)
159
+ executor = RubyLLM.get_tool_executor(@tool_concurrency)
160
+ tool_calls_array = tool_calls.values
161
+
162
+ # Execute tools concurrently, emitting events per-tool
163
+ results = executor.call(tool_calls_array, max_concurrency: @max_concurrency) do |tool_call|
164
+ execute_single_tool_with_events(tool_call)
165
+ end
166
+
167
+ # Add all tool result messages atomically
168
+ add_tool_results_atomically(tool_calls, results)
169
+
170
+ # Find first halt result by original order
171
+ find_first_halt(tool_calls, results)
172
+ end
173
+
174
+ # Execute a single tool with events (for concurrent execution)
175
+ # Emits new_message, tool_call, and tool_result events per-tool
176
+ def execute_single_tool_with_events(tool_call)
177
+ emit(:new_message)
178
+ emit(:tool_call, tool_call)
179
+ result = execute_tool_with_hook(tool_call)
180
+ emit(:tool_result, tool_call, result)
181
+ result
182
+ end
183
+
184
+ # Add all tool result messages atomically to ensure consistent state
185
+ def add_tool_results_atomically(tool_calls, results)
186
+ messages = []
187
+
188
+ tool_calls.each_key do |id|
189
+ tool_call = tool_calls[id]
190
+ result = results[id]
191
+
192
+ tool_payload = result.is_a?(Tool::Halt) ? result.content : result
193
+ content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
194
+ message = add_message(role: :tool, content: content, tool_call_id: tool_call.id)
195
+ messages << message
196
+ end
197
+
198
+ # Fire end_message events for all messages
199
+ messages.each { |msg| emit(:end_message, msg) }
200
+ end
201
+
202
+ # Find the first halt result by request order
203
+ def find_first_halt(tool_calls, results)
204
+ tool_calls.each_key do |id|
205
+ result = results[id]
206
+ return result if result.is_a?(Tool::Halt)
207
+ end
208
+ nil
209
+ end
210
+ end
211
+
212
+ # Prepend after MultiSubscriberCallbacks so we can call its methods
213
+ prepend ConcurrentToolExecution
214
+ end
215
+ end
216
+
217
+ # Register built-in executors
218
+ RubyLLM::ToolExecutors.register_defaults
@@ -309,8 +309,8 @@ module SwarmSDK
309
309
  # Reset swarm if reset_context is true
310
310
  swarm_registry.reset(@delegate_target) if reset_context
311
311
 
312
- # Execute sub-swarm's lead agent
313
- lead_agent = subswarm.agents[subswarm.lead_agent]
312
+ # Execute sub-swarm's lead agent (uses agent() to trigger lazy initialization)
313
+ lead_agent = subswarm.agent(subswarm.lead_agent)
314
314
  response = lead_agent.ask(message, source: "delegation")
315
315
  result = response.content
316
316
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- VERSION = "2.7.10"
4
+ VERSION = "2.7.11"
5
5
  end
data/lib/swarm_sdk.rb CHANGED
@@ -18,14 +18,32 @@ require "async/semaphore"
18
18
  require "ruby_llm"
19
19
  require "ruby_llm/mcp"
20
20
 
21
- # Patch ruby_llm_swarm-mcp's Zeitwerk loader to ignore railtie.rb when Rails is not present
21
+ # Load ruby_llm compatibility patches
22
+ # These patches extend upstream ruby_llm to match fork functionality used by SwarmSDK
23
+ require_relative "swarm_sdk/ruby_llm_patches/init"
24
+
25
+ # Patch Zeitwerk loaders to ignore Rails-dependent files when Rails is not present
22
26
  # This prevents NameError when eager loading outside of Rails applications
23
27
  unless defined?(Rails)
24
28
  require "zeitwerk"
29
+
30
+ # Ignore ruby_llm's ActiveRecord integration (requires ActiveSupport)
31
+ ruby_llm_loader = nil
32
+ Zeitwerk::Registry.loaders.each { |l| ruby_llm_loader = l if l.tag == "ruby_llm" }
33
+ if ruby_llm_loader
34
+ ruby_llm_gem_dir = Gem.loaded_specs["ruby_llm"]&.gem_dir
35
+ if ruby_llm_gem_dir
36
+ active_record_dir = File.join(ruby_llm_gem_dir, "lib", "ruby_llm", "active_record")
37
+ ruby_llm_loader.ignore(active_record_dir)
38
+ end
39
+ end
40
+
41
+ # Ignore ruby_llm-mcp's railtie
25
42
  mcp_loader = nil
26
43
  Zeitwerk::Registry.loaders.each { |l| mcp_loader = l if l.tag == "RubyLLM-mcp" }
27
44
  if mcp_loader
28
- mcp_gem_dir = Gem.loaded_specs["ruby_llm_swarm-mcp"]&.gem_dir
45
+ mcp_gem_dir = Gem.loaded_specs["ruby_llm-mcp"]&.gem_dir ||
46
+ Gem.loaded_specs["ruby_llm_swarm-mcp"]&.gem_dir
29
47
  if mcp_gem_dir
30
48
  railtie_path = File.join(mcp_gem_dir, "lib", "ruby_llm", "mcp", "railtie.rb")
31
49
  mcp_loader.ignore(railtie_path)
@@ -51,6 +69,8 @@ loader.inflector.inflect(
51
69
  "mcp" => "MCP",
52
70
  "openai_with_responses" => "OpenAIWithResponses",
53
71
  )
72
+ # Ignore ruby_llm_patches - these are manually required monkey patches, not autoloaded modules
73
+ loader.ignore("#{__dir__}/swarm_sdk/ruby_llm_patches")
54
74
  loader.setup
55
75
 
56
76
  module SwarmSDK
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: swarm_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.10
4
+ version: 2.7.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -52,33 +52,19 @@ dependencies:
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.4'
54
54
  - !ruby/object:Gem::Dependency
55
- name: openssl
55
+ name: ruby_llm
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: 3.3.2
60
+ version: '1.11'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: 3.3.2
68
- - !ruby/object:Gem::Dependency
69
- name: ruby_llm_swarm
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: 1.9.7
75
- type: :runtime
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: 1.9.7
67
+ version: '1.11'
82
68
  - !ruby/object:Gem::Dependency
83
69
  name: ruby_llm_swarm-mcp
84
70
  requirement: !ruby/object:Gem::Requirement
@@ -190,6 +176,14 @@ files:
190
176
  - lib/swarm_sdk/prompts/base_system_prompt.md.erb
191
177
  - lib/swarm_sdk/restore_result.rb
192
178
  - lib/swarm_sdk/result.rb
179
+ - lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb
180
+ - lib/swarm_sdk/ruby_llm_patches/configuration_patch.rb
181
+ - lib/swarm_sdk/ruby_llm_patches/connection_patch.rb
182
+ - lib/swarm_sdk/ruby_llm_patches/init.rb
183
+ - lib/swarm_sdk/ruby_llm_patches/io_endpoint_patch.rb
184
+ - lib/swarm_sdk/ruby_llm_patches/message_management_patch.rb
185
+ - lib/swarm_sdk/ruby_llm_patches/responses_api_patch.rb
186
+ - lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb
193
187
  - lib/swarm_sdk/snapshot.rb
194
188
  - lib/swarm_sdk/snapshot_from_events.rb
195
189
  - lib/swarm_sdk/state_restorer.rb