swarm_sdk 2.7.12 → 2.7.14

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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches ruby_llm-mcp HTTPX connections to configure SSL verification
4
+ #
5
+ # OpenSSL 3.6 enforces CRL (Certificate Revocation List) checking by default.
6
+ # Most certificates don't provide accessible CRL endpoints (industry moved to OCSP),
7
+ # which breaks HTTPS MCP connections with:
8
+ # "certificate verify failed (unable to get certificate CRL)"
9
+ #
10
+ # This patch injects SSL options into all 5 HTTPX connection creation paths:
11
+ #
12
+ # Via HTTPClient.build_connection (paths 1-3):
13
+ # 1. StreamableHTTP#create_connection
14
+ # 2. StreamableHTTP#create_connection_with_streaming_callbacks
15
+ # 3. SSE#send_request
16
+ #
17
+ # Direct HTTPX.plugin calls (paths 4-5):
18
+ # 4. StreamableHTTP#create_connection_with_sse_callbacks
19
+ # 5. SSE#create_sse_client
20
+ #
21
+ # Default: VERIFY_PEER (validates cert chain without CRL checking)
22
+ # Configurable: VERIFY_NONE for local development via SwarmSDK.config.mcp_ssl_verify
23
+
24
+ require "openssl"
25
+
26
+ module SwarmSDK
27
+ # Module-level SSL configuration for MCP HTTPX connections
28
+ #
29
+ # Set ssl_options before creating MCP clients. The patched HTTPX methods
30
+ # read from this accessor to configure SSL on every connection.
31
+ #
32
+ # @example Default (validates cert chain, skips CRL)
33
+ # McpSslPatch.ssl_options #=> { verify_mode: OpenSSL::SSL::VERIFY_PEER }
34
+ #
35
+ # @example Disable SSL verification (local dev only)
36
+ # McpSslPatch.ssl_options = { verify_mode: OpenSSL::SSL::VERIFY_NONE }
37
+ module McpSslPatch
38
+ @ssl_options = { verify_mode: OpenSSL::SSL::VERIFY_PEER }
39
+
40
+ class << self
41
+ # @return [Hash] SSL options hash passed to HTTPX .with(ssl: ...)
42
+ attr_accessor :ssl_options
43
+
44
+ # Clear the thread-local HTTPX connection cache
45
+ #
46
+ # Must be called after changing ssl_options so that HTTPClient.build_connection
47
+ # runs again with the updated options.
48
+ #
49
+ # @return [void]
50
+ def reset_connection!
51
+ Thread.current[:ruby_llm_mcp_client_connection] = nil
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # Patch 1: HTTPClient.build_connection
58
+ #
59
+ # Covers paths 1-3: StreamableHTTP#create_connection,
60
+ # StreamableHTTP#create_connection_with_streaming_callbacks, SSE#send_request
61
+ #
62
+ # These all chain from HTTPClient.connection which calls build_connection.
63
+ module RubyLLM
64
+ module MCP
65
+ module Transports
66
+ module Support
67
+ class HTTPClient
68
+ class << self
69
+ def build_connection
70
+ HTTPX.with(
71
+ pool_options: {
72
+ max_connections: RubyLLM::MCP.config.max_connections,
73
+ pool_timeout: RubyLLM::MCP.config.pool_timeout,
74
+ },
75
+ ssl: SwarmSDK::McpSslPatch.ssl_options,
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # Patch 2: StreamableHTTP#create_connection_with_sse_callbacks
86
+ #
87
+ # This method calls HTTPX.plugin(:callbacks) directly, bypassing HTTPClient.
88
+ # Merges SSL options with ALPN protocol when version is :http1.
89
+ module SwarmSDK
90
+ module McpSslPatch
91
+ module StreamableHttpSslPatch
92
+ private
93
+
94
+ def create_connection_with_sse_callbacks(options, headers)
95
+ client = HTTPX.plugin(:callbacks)
96
+ client = add_on_response_body_chunk_callback(client, options)
97
+
98
+ ssl = SwarmSDK::McpSslPatch.ssl_options.dup
99
+ ssl[:alpn_protocols] = ["http/1.1"] if @version == :http1
100
+
101
+ client = client.with(
102
+ timeout: {
103
+ connect_timeout: 10,
104
+ read_timeout: @request_timeout / 1000,
105
+ write_timeout: @request_timeout / 1000,
106
+ operation_timeout: @request_timeout / 1000,
107
+ },
108
+ headers: headers,
109
+ ssl: ssl,
110
+ )
111
+
112
+ register_client(client)
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Patch 3: SSE#create_sse_client
119
+ #
120
+ # This method calls HTTPX.plugin(:stream) directly, bypassing HTTPClient.
121
+ # Merges SSL options with ALPN protocol when version is :http1.
122
+ module SwarmSDK
123
+ module McpSslPatch
124
+ module SseSslPatch
125
+ private
126
+
127
+ def create_sse_client
128
+ stream_headers = build_request_headers
129
+
130
+ ssl = SwarmSDK::McpSslPatch.ssl_options.dup
131
+ ssl[:alpn_protocols] = ["http/1.1"] if @version == :http1
132
+
133
+ HTTPX.plugin(:stream).with(
134
+ headers: stream_headers,
135
+ ssl: ssl,
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ # Apply prepend patches for direct HTTPX.plugin calls
143
+ RubyLLM::MCP::Transports::StreamableHTTP.prepend(SwarmSDK::McpSslPatch::StreamableHttpSslPatch)
144
+ RubyLLM::MCP::Transports::SSE.prepend(SwarmSDK::McpSslPatch::SseSslPatch)
@@ -88,11 +88,15 @@ module RubyLLM
88
88
  "Add `gem 'async'` to your Gemfile. Original error: #{e.message}"
89
89
  end
90
90
 
91
- def run_with_sync(&)
92
- if defined?(Sync)
93
- Sync(&)
91
+ def run_with_sync(&block)
92
+ if Async::Task.current?
93
+ # Already inside an async reactor (SwarmSDK always runs in one).
94
+ # Just yield — no Sync, no nested reactor, no Promise mutex issues.
95
+ yield
94
96
  else
95
- Async(&).wait
97
+ # Outside async context (e.g., standalone RubyLLM usage).
98
+ # Sync handles reactor creation and cleanup.
99
+ Sync(&block)
96
100
  end
97
101
  end
98
102
 
@@ -145,14 +149,13 @@ module RubyLLM
145
149
 
146
150
  private
147
151
 
148
- # Override handle_tool_calls to support concurrent execution
149
- # This method is called when tool_concurrency is set
152
+ # Override handle_tool_calls to support concurrent execution.
153
+ # Returns halt result or nil the trampoline loop in complete() handles the next iteration.
150
154
  def handle_tool_calls(response, &block)
151
155
  return super unless @tool_concurrency
152
156
 
153
157
  tool_calls = response.tool_calls
154
- halt_result = execute_tools_concurrently(tool_calls)
155
- halt_result || complete(&block)
158
+ execute_tools_concurrently(tool_calls)
156
159
  end
157
160
 
158
161
  def execute_tools_concurrently(tool_calls)
@@ -37,6 +37,7 @@ module SwarmSDK
37
37
  @disable_default_tools = nil
38
38
  @streaming = nil
39
39
  @thinking = nil
40
+ @disable_environment_info = nil
40
41
  end
41
42
 
42
43
  # Set model for all agents
@@ -100,6 +101,13 @@ module SwarmSDK
100
101
  @streaming = value
101
102
  end
102
103
 
104
+ # Disable environment info for all agents
105
+ #
106
+ # @param enabled [Boolean] Whether to disable environment info in system prompts
107
+ def disable_environment_info(enabled)
108
+ @disable_environment_info = enabled
109
+ end
110
+
103
111
  # Configure extended thinking for all agents
104
112
  #
105
113
  # @param effort [Symbol, String, nil] Reasoning effort (:low, :medium, :high) — OpenAI
@@ -186,6 +194,7 @@ module SwarmSDK
186
194
  disable_default_tools: @disable_default_tools,
187
195
  streaming: @streaming,
188
196
  thinking: @thinking,
197
+ disable_environment_info: @disable_environment_info,
189
198
  tools: @tools_list,
190
199
  permissions: @permissions_config,
191
200
  }.compact
@@ -6,9 +6,19 @@ module SwarmSDK
6
6
  #
7
7
  # Extracted from Swarm#execute to reduce complexity and eliminate code duplication.
8
8
  # The core execution loop, error handling, and cleanup logic are unified here.
9
+ #
10
+ # ## Stop Mechanism
11
+ #
12
+ # Supports hard-stop via `swarm.stop` using IO.pipe for thread-safe signaling:
13
+ # 1. `swarm.stop` writes to pipe and sets `@stop_requested`
14
+ # 2. A listener task reads from the pipe (async-aware I/O)
15
+ # 3. Listener calls `barrier.stop` within the Async reactor
16
+ # 4. All child tasks receive `Async::Stop` exception
17
+ # 5. `execute_in_task` catches `Async::Stop`, sets interrupted flag, emits events
9
18
  class Executor
10
19
  def initialize(swarm)
11
20
  @swarm = swarm
21
+ @interrupted_result = nil
12
22
  end
13
23
 
14
24
  # Execute the swarm with a prompt
@@ -18,7 +28,7 @@ module SwarmSDK
18
28
  # @param logs [Array] Log collection array
19
29
  # @param has_logging [Boolean] Whether logging is enabled
20
30
  # @param original_fiber_storage [Hash] Original Fiber storage values to restore
21
- # @return [Async::Task] The execution task
31
+ # @return [Result, Async::Task] Result if wait: true, Async::Task if wait: false
22
32
  def run(prompt, wait:, logs:, has_logging:, original_fiber_storage:)
23
33
  @original_fiber_storage = original_fiber_storage
24
34
  if wait
@@ -31,19 +41,39 @@ module SwarmSDK
31
41
  private
32
42
 
33
43
  # Blocking execution using Sync
44
+ #
45
+ # Wraps execution in an Async::Barrier so `swarm.stop` can cancel all tasks.
46
+ # A stop listener task watches the IO.pipe for stop signals.
34
47
  def run_blocking(prompt, logs:, has_logging:)
35
48
  result = nil
49
+ start_time = Time.now
50
+ @swarm.prepare_for_execution
51
+
36
52
  Sync do |task|
37
- start_time = Time.now
53
+ barrier = Async::Barrier.new
54
+ @swarm.register_execution_barrier(barrier)
55
+ stop_listener = setup_stop_listener(task, barrier)
56
+
57
+ begin
58
+ result = barrier.async do
59
+ if @swarm.execution_timeout
60
+ execute_with_execution_timeout(task, prompt, logs, has_logging, start_time)
61
+ else
62
+ execute_in_task(prompt, logs: logs, has_logging: has_logging) do |lead, current_prompt|
63
+ lead.ask(current_prompt)
64
+ end
65
+ end
66
+ end.wait
38
67
 
39
- result = if @swarm.execution_timeout
40
- execute_with_execution_timeout(task, prompt, logs, has_logging, start_time)
41
- else
42
- execute_in_task(prompt, logs: logs, has_logging: has_logging) do |lead, current_prompt|
43
- # Execute directly - no child task needed
44
- # This keeps execution in same fiber context for better control
45
- lead.ask(current_prompt)
46
- end
68
+ # barrier child .wait returns nil when stopped
69
+ result = @interrupted_result if result.nil? && @swarm.stop_requested?
70
+ rescue Async::Stop
71
+ # Non-blocking path (rare - user called task.stop on Sync root)
72
+ result = @interrupted_result
73
+ ensure
74
+ barrier.stop unless barrier.empty?
75
+ stop_listener&.stop
76
+ @swarm.clear_execution_barrier
47
77
  end
48
78
  ensure
49
79
  # Always wait for observer tasks, even if main execution raises
@@ -53,32 +83,77 @@ module SwarmSDK
53
83
 
54
84
  result
55
85
  ensure
56
- # Restore original fiber storage (preserves parent context for nested swarms)
86
+ @interrupted_result = nil
87
+ @swarm.cleanup_stop_signal
57
88
  restore_fiber_storage
58
89
  end
59
90
 
60
91
  # Non-blocking execution using parent async task
92
+ #
93
+ # Same barrier + stop listener pattern as run_blocking.
61
94
  def run_async(prompt, logs:, has_logging:)
62
95
  parent = Async::Task.current
63
96
  raise ConfigurationError, "wait: false requires an async context. Use Sync { swarm.execute(..., wait: false) }" unless parent
64
97
 
98
+ @swarm.prepare_for_execution
99
+
65
100
  # NOTE: The block receives |task| as the spawned Async::Task when arity > 0
66
101
  parent.async(finished: false) do |task|
67
102
  start_time = Time.now
103
+ barrier = Async::Barrier.new
104
+ @swarm.register_execution_barrier(barrier)
105
+ stop_listener = setup_stop_listener(task, barrier)
106
+
107
+ begin
108
+ result = barrier.async do
109
+ if @swarm.execution_timeout
110
+ execute_with_execution_timeout(task, prompt, logs, has_logging, start_time)
111
+ else
112
+ execute_in_task(prompt, logs: logs, has_logging: has_logging) do |lead, current_prompt|
113
+ lead.ask(current_prompt)
114
+ end
115
+ end
116
+ end.wait
68
117
 
69
- if @swarm.execution_timeout
70
- execute_with_execution_timeout(task, prompt, logs, has_logging, start_time)
71
- else
72
- execute_in_task(prompt, logs: logs, has_logging: has_logging) do |lead, current_prompt|
73
- # Execute directly - no child task needed
74
- lead.ask(current_prompt)
75
- end
118
+ result = @interrupted_result if result.nil? && @swarm.stop_requested?
119
+ result
120
+ rescue Async::Stop
121
+ @interrupted_result
122
+ ensure
123
+ barrier.stop unless barrier.empty?
124
+ stop_listener&.stop
125
+ @swarm.clear_execution_barrier
126
+ @interrupted_result = nil
127
+ @swarm.cleanup_stop_signal
128
+ @swarm.wait_for_observers
76
129
  end
77
130
  end
78
131
  end
79
132
 
133
+ # Setup a listener task that watches for stop signals via IO.pipe
134
+ #
135
+ # The listener reads from the pipe (async-aware I/O that yields to scheduler).
136
+ # When data arrives (from `swarm.stop`), it stops the barrier to cancel all tasks.
137
+ #
138
+ # @param task [Async::Task] Parent task to spawn listener under
139
+ # @param barrier [Async::Barrier] Execution barrier to stop
140
+ # @return [Async::Task, nil] The listener task, or nil if no pipe
141
+ def setup_stop_listener(task, barrier)
142
+ return unless @swarm.stop_signal_read
143
+
144
+ task.async do
145
+ @swarm.stop_signal_read.read(1) # Async-aware I/O, yields to scheduler
146
+ barrier.stop unless barrier.empty?
147
+ rescue IOError, Async::Stop
148
+ # Pipe closed or listener stopped - normal cleanup
149
+ end
150
+ end
151
+
80
152
  # Core execution logic (unified, no duplication)
81
153
  #
154
+ # Handles InterruptedError and Async::Stop to properly track interruption state.
155
+ # The interrupted flag drives cleanup behavior (event emission, result building).
156
+ #
82
157
  # @param prompt [String] Initial prompt
83
158
  # @param logs [Array] Log collection
84
159
  # @param has_logging [Boolean] Whether logging is enabled
@@ -89,6 +164,7 @@ module SwarmSDK
89
164
  result = nil
90
165
  swarm_stop_triggered = false
91
166
  current_prompt = prompt
167
+ interrupted = false
92
168
 
93
169
  begin
94
170
  # Notify plugins that swarm is starting
@@ -100,6 +176,12 @@ module SwarmSDK
100
176
  # Re-raise configuration errors and timeouts - these should not be caught here
101
177
  # Timeouts are handled by execute_with_execution_timeout wrapper
102
178
  raise
179
+ rescue InterruptedError
180
+ interrupted = true
181
+ raise
182
+ rescue Async::Stop
183
+ interrupted = true
184
+ raise # Must re-raise for Async task cleanup
103
185
  rescue TypeError => e
104
186
  result = handle_type_error(e, logs, start_time)
105
187
  rescue StandardError => e
@@ -108,17 +190,30 @@ module SwarmSDK
108
190
  # Notify plugins that swarm is stopping (called even on error)
109
191
  PluginRegistry.emit_event(:on_swarm_stopped, swarm: @swarm)
110
192
 
111
- cleanup_after_execution(result, start_time, logs, swarm_stop_triggered, has_logging)
193
+ result = cleanup_after_execution(
194
+ result,
195
+ start_time,
196
+ logs,
197
+ swarm_stop_triggered,
198
+ has_logging,
199
+ interrupted: interrupted,
200
+ )
201
+ @interrupted_result = result if interrupted
112
202
  end
113
203
 
114
204
  result
115
205
  end
116
206
 
117
207
  # Main execution loop with reprompting support
208
+ #
209
+ # Checks for stop requests at the top of each iteration to prevent
210
+ # unnecessary LLM calls after stop is requested.
118
211
  def execution_loop(initial_prompt, logs, start_time)
119
212
  current_prompt = initial_prompt
120
213
 
121
214
  loop do
215
+ raise InterruptedError, "Swarm execution was interrupted" if @swarm.stop_requested?
216
+
122
217
  lead = @swarm.agents[@swarm.lead_agent]
123
218
  response = yield(lead, current_prompt)
124
219
 
@@ -197,7 +292,31 @@ module SwarmSDK
197
292
  end
198
293
 
199
294
  # Cleanup after execution (ensure block logic)
200
- def cleanup_after_execution(result, start_time, logs, swarm_stop_triggered, has_logging)
295
+ #
296
+ # When interrupted, emits agent_stop events for active agents, builds
297
+ # an interrupted result, and triggers swarm_stop hook with interrupted context.
298
+ #
299
+ # @param result [Result, nil] Current execution result
300
+ # @param start_time [Time] Execution start time
301
+ # @param logs [Array] Collected logs
302
+ # @param swarm_stop_triggered [Boolean] Whether swarm_stop hook already fired
303
+ # @param has_logging [Boolean] Whether logging is enabled
304
+ # @param interrupted [Boolean] Whether execution was interrupted
305
+ # @return [Result] Final result (may be replaced with interrupted result)
306
+ def cleanup_after_execution(result, start_time, logs, swarm_stop_triggered, has_logging, interrupted: false)
307
+ if interrupted && !swarm_stop_triggered
308
+ emit_interrupted_agent_events
309
+ result = build_interrupted_result(logs, start_time)
310
+
311
+ # Trigger swarm_stop hook with interrupted result (emits swarm_stop event)
312
+ begin
313
+ @swarm.trigger_swarm_stop(result)
314
+ rescue StandardError => e
315
+ LogStream.emit_error(e, source: "executor", context: "interrupted_swarm_stop")
316
+ end
317
+ swarm_stop_triggered = true
318
+ end
319
+
201
320
  # Trigger swarm_stop if not already triggered (handles error cases)
202
321
  unless swarm_stop_triggered
203
322
  @swarm.trigger_swarm_stop_final(result, start_time, logs)
@@ -214,6 +333,43 @@ module SwarmSDK
214
333
 
215
334
  # Reset logging state for next execution if we set it up
216
335
  reset_logging if has_logging
336
+
337
+ result
338
+ end
339
+
340
+ # Emit agent_stop events for all agents that were actively executing when interrupted
341
+ #
342
+ # @return [void]
343
+ def emit_interrupted_agent_events
344
+ @swarm.active_agent_chats.each do |name, _chat|
345
+ LogStream.emit(
346
+ type: "agent_stop",
347
+ agent: name,
348
+ swarm_id: @swarm.swarm_id,
349
+ parent_swarm_id: @swarm.parent_swarm_id,
350
+ finish_reason: "interrupted",
351
+ content: nil,
352
+ tool_calls: [],
353
+ usage: {},
354
+ metadata: { interrupted: true },
355
+ )
356
+ end
357
+ end
358
+
359
+ # Build an interrupted result
360
+ #
361
+ # @param logs [Array] Collected logs
362
+ # @param start_time [Time] Execution start time
363
+ # @return [Result] Result marked as interrupted
364
+ def build_interrupted_result(logs, start_time)
365
+ Result.new(
366
+ content: nil,
367
+ agent: @swarm.lead_agent&.to_s || "unknown",
368
+ error: InterruptedError.new("Swarm execution was interrupted"),
369
+ logs: logs,
370
+ duration: Time.now - start_time,
371
+ metadata: { interrupted: true, finish_reason: "interrupted" },
372
+ )
217
373
  end
218
374
 
219
375
  # Restore Fiber-local storage to original values (preserves parent context)
@@ -63,6 +63,16 @@ module SwarmSDK
63
63
  # @param result [Result] Execution result
64
64
  # @return [Hooks::Context] Hook context for swarm_stop event
65
65
  def build_swarm_stop_context(result)
66
+ finish_reason = if @stop_requested
67
+ "interrupted"
68
+ elsif result&.error.is_a?(ExecutionTimeoutError)
69
+ "timeout"
70
+ elsif result&.success?
71
+ "finished"
72
+ else
73
+ "error"
74
+ end
75
+
66
76
  Hooks::Context.new(
67
77
  event: :swarm_stop,
68
78
  agent_name: @lead_agent.to_s,
@@ -79,6 +89,7 @@ module SwarmSDK
79
89
  agents_involved: result.agents_involved,
80
90
  per_agent_usage: result.per_agent_usage,
81
91
  result: result,
92
+ finish_reason: finish_reason,
82
93
  timestamp: Time.now.utc.iso8601,
83
94
  },
84
95
  )
@@ -204,6 +204,7 @@ module SwarmSDK
204
204
  last_agent: context.metadata[:last_agent],
205
205
  content: context.metadata[:content],
206
206
  success: context.metadata[:success],
207
+ finish_reason: context.metadata[:finish_reason] || "finished",
207
208
  duration: context.metadata[:duration],
208
209
  total_cost: context.metadata[:total_cost],
209
210
  total_tokens: context.metadata[:total_tokens],
@@ -129,6 +129,9 @@ module SwarmSDK
129
129
  # @param config [Hash] MCP server configuration
130
130
  # @return [RubyLLM::MCP::Client] Initialized MCP client
131
131
  def initialize_mcp_client(config)
132
+ # Configure SSL before creating the client so HTTPX connections use the right options
133
+ configure_mcp_ssl(config)
134
+
132
135
  # Convert timeout from seconds to milliseconds
133
136
  # Use explicit config[:timeout] if provided, otherwise use global default
134
137
  timeout_seconds = config[:timeout] || SwarmSDK.config.mcp_request_timeout
@@ -230,6 +233,23 @@ module SwarmSDK
230
233
  }
231
234
  end
232
235
 
236
+ # Configure SSL options for MCP HTTPX connections
237
+ #
238
+ # Sets McpSslPatch.ssl_options based on per-server ssl_verify config
239
+ # or global SwarmSDK.config.mcp_ssl_verify. Resets the thread-local
240
+ # connection cache so build_connection picks up the new options.
241
+ #
242
+ # @param config [Hash] MCP server configuration
243
+ # @option config [Boolean] :ssl_verify Override global SSL verify setting
244
+ # @return [void]
245
+ def configure_mcp_ssl(config)
246
+ ssl_verify = config.fetch(:ssl_verify, SwarmSDK.config.mcp_ssl_verify)
247
+ verify_mode = ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
248
+
249
+ McpSslPatch.ssl_options = { verify_mode: verify_mode }
250
+ McpSslPatch.reset_connection!
251
+ end
252
+
233
253
  # Emit MCP server initialization start event
234
254
  #
235
255
  # @param agent_name [Symbol] Agent name