swarm_sdk 2.7.13 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 427d736e32c386d76df7481b50b95e272045b88eb93290353528fd48c2525d91
4
- data.tar.gz: fc3cf69eaa253871f0db9f6332dd62669af2941082f622e266f65e035f6b2b21
3
+ metadata.gz: 22ea4e05517149f4cb6f6f18e896e9493e9ed75f69e82bd6891a9b0b471e314e
4
+ data.tar.gz: 94f2f1b7c28fe626889aa4f1fbb611bb36ac4d73ab7de778f76e553538589cbb
5
5
  SHA512:
6
- metadata.gz: d08958fe437094ce91bdae63e6e20290158417270d1d39446fbb9cc45b2ea02bfbbae99dccfb36097fb053a2190f4cee1fd101c876eda1a388c19ba3ba4cdf4c
7
- data.tar.gz: cf06e09fe354ca95035d27e5ee4d710be461757829c1448c5d178b9f8a9b9372419e27bc036101d9ad0add625459784fa766413695f3f030efcba8fa97ee8467
6
+ metadata.gz: 2360f50b71a7c2342f2301a8dc5362b0e92402f16bdaee19dc7932b395a33f37e12a385fe752b69411e481ed10418396b7245a5d61669f7d41d12b6f7d0c3f40
7
+ data.tar.gz: 076b1ca993b017aeabfe5e35daa08be81e4cd815e314b2e28b594f298ae2b5b430101a0c9ea01d5263689b21ad888e4fb17d06c4a59904aa38734308a21ad130
@@ -63,6 +63,7 @@ module SwarmSDK
63
63
  @shared_across_delegations = nil # nil = not set (will default to false in Definition)
64
64
  @streaming = nil # nil = not set (will use global config default)
65
65
  @thinking = nil # nil = not set (extended thinking disabled)
66
+ @disable_environment_info = nil # nil = not set (will default to false in Definition)
66
67
  @context_management_config = nil # Context management DSL hooks
67
68
  end
68
69
 
@@ -529,6 +530,29 @@ module SwarmSDK
529
530
  !@coding_agent.nil?
530
531
  end
531
532
 
533
+ # Disable environment info (date, platform, OS, working directory) in system prompt
534
+ #
535
+ # When true, omits the environment information section from the agent's system prompt.
536
+ # Defaults to false if not set.
537
+ #
538
+ # @param enabled [Boolean] Whether to disable environment info
539
+ # @return [void]
540
+ #
541
+ # @example
542
+ # disable_environment_info true # Omit environment info from prompt
543
+ def disable_environment_info(enabled)
544
+ @disable_environment_info = enabled
545
+ end
546
+
547
+ # Check if disable_environment_info has been explicitly set
548
+ #
549
+ # Used by Swarm::Builder to determine if all_agents disable_environment_info should apply.
550
+ #
551
+ # @return [Boolean] true if disable_environment_info was explicitly set
552
+ def disable_environment_info_set?
553
+ !@disable_environment_info.nil?
554
+ end
555
+
532
556
  # Check if parameters have been set
533
557
  #
534
558
  # Used by Swarm::Builder for merging all_agents parameters.
@@ -586,6 +610,7 @@ module SwarmSDK
586
610
  agent_config[:shared_across_delegations] = @shared_across_delegations unless @shared_across_delegations.nil?
587
611
  agent_config[:streaming] = @streaming unless @streaming.nil?
588
612
  agent_config[:thinking] = @thinking if @thinking
613
+ agent_config[:disable_environment_info] = @disable_environment_info unless @disable_environment_info.nil?
589
614
 
590
615
  # Convert DSL hooks to HookDefinition format
591
616
  agent_config[:hooks] = convert_hooks_to_definitions if @hooks.any?
@@ -658,57 +658,63 @@ module SwarmSDK
658
658
 
659
659
  # Execute ask without timeout (original ask implementation)
660
660
  def execute_ask(prompt, options)
661
- is_first = first_message?
661
+ @hook_swarm&.mark_agent_active(@agent_name, self)
662
662
 
663
- # Collect system reminders to inject as ephemeral content
664
- reminders = collect_system_reminders(prompt, is_first)
665
-
666
- # Trigger user_prompt hook (with clean prompt, not reminders)
667
- source = options.delete(:source) || "user"
668
- final_prompt = prompt
669
- if @hook_executor
670
- hook_result = trigger_user_prompt(prompt, source: source)
663
+ begin
664
+ is_first = first_message?
665
+
666
+ # Collect system reminders to inject as ephemeral content
667
+ reminders = collect_system_reminders(prompt, is_first)
668
+
669
+ # Trigger user_prompt hook (with clean prompt, not reminders)
670
+ source = options.delete(:source) || "user"
671
+ final_prompt = prompt
672
+ if @hook_executor
673
+ hook_result = trigger_user_prompt(prompt, source: source)
674
+
675
+ if hook_result[:halted]
676
+ return RubyLLM::Message.new(
677
+ role: :assistant,
678
+ content: hook_result[:halt_message],
679
+ model_id: model_id,
680
+ )
681
+ end
671
682
 
672
- if hook_result[:halted]
673
- return RubyLLM::Message.new(
674
- role: :assistant,
675
- content: hook_result[:halt_message],
676
- model_id: model_id,
677
- )
683
+ final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
678
684
  end
679
685
 
680
- final_prompt = hook_result[:modified_prompt] if hook_result[:modified_prompt]
681
- end
682
-
683
- # Add CLEAN user message to history (no reminders embedded)
684
- @llm_chat.add_message(role: :user, content: final_prompt)
686
+ # Add CLEAN user message to history (no reminders embedded)
687
+ @llm_chat.add_message(role: :user, content: final_prompt)
685
688
 
686
- # Track reminders as ephemeral content for this LLM call only
687
- # They'll be injected by around_llm_request hook but not stored
688
- reminders.each do |reminder|
689
- @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
690
- end
691
-
692
- # Execute complete() which handles tool loop and ephemeral injection
693
- response = execute_with_global_semaphore do
694
- catch(:finish_agent) do
695
- catch(:finish_swarm) do
696
- if @streaming_enabled
697
- # Reset chunk type tracking for new streaming request
698
- @last_chunk_type = nil
689
+ # Track reminders as ephemeral content for this LLM call only
690
+ # They'll be injected by around_llm_request hook but not stored
691
+ reminders.each do |reminder|
692
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: @llm_chat.messages)
693
+ end
699
694
 
700
- @llm_chat.complete(**options) do |chunk|
701
- emit_content_chunk(chunk)
695
+ # Execute complete() which handles tool loop and ephemeral injection
696
+ response = execute_with_global_semaphore do
697
+ catch(:finish_agent) do
698
+ catch(:finish_swarm) do
699
+ if @streaming_enabled
700
+ # Reset chunk type tracking for new streaming request
701
+ @last_chunk_type = nil
702
+
703
+ @llm_chat.complete(**options) do |chunk|
704
+ emit_content_chunk(chunk)
705
+ end
706
+ else
707
+ @llm_chat.complete(**options)
702
708
  end
703
- else
704
- @llm_chat.complete(**options)
705
709
  end
706
710
  end
707
711
  end
708
- end
709
712
 
710
- # Handle finish markers from hooks
711
- handle_finish_marker(response)
713
+ # Handle finish markers from hooks
714
+ handle_finish_marker(response)
715
+ ensure
716
+ @hook_swarm&.mark_agent_inactive(@agent_name)
717
+ end
712
718
  end
713
719
 
714
720
  # --- Tool Execution Hook ---
@@ -43,7 +43,8 @@ module SwarmSDK
43
43
  :plugin_configs,
44
44
  :shared_across_delegations,
45
45
  :streaming,
46
- :thinking
46
+ :thinking,
47
+ :disable_environment_info
47
48
 
48
49
  attr_accessor :bypass_permissions, :max_concurrent_tools
49
50
 
@@ -118,6 +119,9 @@ module SwarmSDK
118
119
  # Extended thinking configuration (nil = disabled)
119
120
  @thinking = config[:thinking]
120
121
 
122
+ # When true, omits date/platform/OS/working directory from system prompts
123
+ @disable_environment_info = config.fetch(:disable_environment_info, false)
124
+
121
125
  # Build system prompt after directory and memory are set
122
126
  @system_prompt = build_full_system_prompt(config[:system_prompt])
123
127
 
@@ -201,6 +205,7 @@ module SwarmSDK
201
205
  hooks: @hooks,
202
206
  shared_across_delegations: @shared_across_delegations,
203
207
  streaming: @streaming,
208
+ disable_environment_info: @disable_environment_info,
204
209
  # Permissions are core SDK functionality (not plugin-specific)
205
210
  default_permissions: @default_permissions,
206
211
  permissions: @agent_permissions,
@@ -301,6 +306,7 @@ module SwarmSDK
301
306
  disable_default_tools: @disable_default_tools,
302
307
  directory: @directory,
303
308
  definition: self,
309
+ disable_environment_info: @disable_environment_info,
304
310
  )
305
311
  end
306
312
 
@@ -394,6 +400,7 @@ module SwarmSDK
394
400
  :shared_across_delegations,
395
401
  :streaming,
396
402
  :directories,
403
+ :disable_environment_info,
397
404
  ]
398
405
 
399
406
  config.reject { |k, _| standard_keys.include?(k.to_sym) }
@@ -28,24 +28,29 @@ module SwarmSDK
28
28
  # @param disable_default_tools [Boolean, Array, nil] Default tools disable configuration
29
29
  # @param directory [String] Agent's working directory
30
30
  # @param definition [Definition] Full definition for plugin contributions
31
+ # @param disable_environment_info [Boolean] Whether to omit environment info from prompt
31
32
  # @return [String] Complete system prompt
32
- def build(custom_prompt:, coding_agent:, disable_default_tools:, directory:, definition:)
33
+ def build(custom_prompt:, coding_agent:, disable_default_tools:, directory:, definition:,
34
+ disable_environment_info: false)
33
35
  new(
34
36
  custom_prompt: custom_prompt,
35
37
  coding_agent: coding_agent,
36
38
  disable_default_tools: disable_default_tools,
37
39
  directory: directory,
38
40
  definition: definition,
41
+ disable_environment_info: disable_environment_info,
39
42
  ).build
40
43
  end
41
44
  end
42
45
 
43
- def initialize(custom_prompt:, coding_agent:, disable_default_tools:, directory:, definition:)
46
+ def initialize(custom_prompt:, coding_agent:, disable_default_tools:, directory:, definition:,
47
+ disable_environment_info: false)
44
48
  @custom_prompt = custom_prompt
45
49
  @coding_agent = coding_agent
46
50
  @disable_default_tools = disable_default_tools
47
51
  @directory = directory
48
52
  @definition = definition
53
+ @disable_environment_info = disable_environment_info
49
54
  end
50
55
 
51
56
  def build
@@ -80,7 +85,11 @@ module SwarmSDK
80
85
  non_coding_base = render_non_coding_base_prompt
81
86
 
82
87
  if @custom_prompt && !@custom_prompt.strip.empty?
83
- "#{non_coding_base}\n\n#{@custom_prompt}"
88
+ if non_coding_base.empty?
89
+ @custom_prompt.to_s
90
+ else
91
+ "#{non_coding_base}\n\n#{@custom_prompt}"
92
+ end
84
93
  else
85
94
  non_coding_base
86
95
  end
@@ -91,6 +100,7 @@ module SwarmSDK
91
100
  end
92
101
 
93
102
  def render_base_system_prompt
103
+ disable_environment_info = @disable_environment_info
94
104
  cwd = @directory || Dir.pwd
95
105
  platform = RUBY_PLATFORM
96
106
  os_version = begin
@@ -105,6 +115,8 @@ module SwarmSDK
105
115
  end
106
116
 
107
117
  def render_non_coding_base_prompt
118
+ return "" if @disable_environment_info
119
+
108
120
  cwd = @directory || Dir.pwd
109
121
  platform = RUBY_PLATFORM
110
122
  os_version = begin
@@ -265,6 +265,7 @@ module SwarmSDK
265
265
  builder.parameters(config[:parameters]) if config[:parameters]
266
266
  builder.headers(config[:headers]) if config[:headers]
267
267
  builder.coding_agent(config[:coding_agent]) unless config[:coding_agent].nil?
268
+ builder.disable_environment_info(config[:disable_environment_info]) unless config[:disable_environment_info].nil?
268
269
  builder.bypass_permissions(config[:bypass_permissions]) if config[:bypass_permissions]
269
270
  builder.disable_default_tools(config[:disable_default_tools]) unless config[:disable_default_tools].nil?
270
271
 
@@ -451,6 +452,10 @@ module SwarmSDK
451
452
  agent_builder.streaming(all_agents_hash[:streaming])
452
453
  end
453
454
 
455
+ if !all_agents_hash[:disable_environment_info].nil? && !agent_builder.disable_environment_info_set?
456
+ agent_builder.disable_environment_info(all_agents_hash[:disable_environment_info])
457
+ end
458
+
454
459
  if all_agents_hash[:thinking] && !agent_builder.thinking_set?
455
460
  agent_builder.thinking(**all_agents_hash[:thinking])
456
461
  end
@@ -31,6 +31,9 @@ module SwarmSDK
31
31
 
32
32
  @mcp_clients.clear
33
33
 
34
+ # Shutdown sub-swarms if registry exists
35
+ @swarm_registry&.shutdown_all
36
+
34
37
  # Clear delegation instances
35
38
  delegation_instances_hash&.clear
36
39
  end
@@ -105,6 +105,7 @@ module SwarmSDK
105
105
  allow_filesystem_tools: ["SWARM_SDK_ALLOW_FILESYSTEM_TOOLS", true],
106
106
  env_interpolation: ["SWARM_SDK_ENV_INTERPOLATION", true],
107
107
  streaming: ["SWARM_SDK_STREAMING", true],
108
+ mcp_ssl_verify: ["SWARM_SDK_MCP_SSL_VERIFY", true],
108
109
  }.freeze
109
110
 
110
111
  class << self
@@ -345,7 +346,7 @@ module SwarmSDK
345
346
  # @return [Integer, Float, Boolean, String] The parsed value
346
347
  def parse_env_value(value, key)
347
348
  case key
348
- when :allow_filesystem_tools, :env_interpolation, :streaming
349
+ when :allow_filesystem_tools, :env_interpolation, :streaming, :mcp_ssl_verify
349
350
  # Convert string to boolean
350
351
  case value.to_s.downcase
351
352
  when "true", "yes", "1", "on", "enabled"
@@ -100,6 +100,7 @@ module SwarmSDK
100
100
  coding_agent(all_agents_cfg[:coding_agent]) unless all_agents_cfg[:coding_agent].nil?
101
101
  disable_default_tools(all_agents_cfg[:disable_default_tools]) unless all_agents_cfg[:disable_default_tools].nil?
102
102
  streaming(all_agents_cfg[:streaming]) unless all_agents_cfg[:streaming].nil?
103
+ disable_environment_info(all_agents_cfg[:disable_environment_info]) unless all_agents_cfg[:disable_environment_info].nil?
103
104
  thinking(**all_agents_cfg[:thinking]) if all_agents_cfg[:thinking]
104
105
 
105
106
  if all_agents_hks.any?
@@ -165,6 +166,7 @@ module SwarmSDK
165
166
  disable_default_tools(config[:disable_default_tools]) unless config[:disable_default_tools].nil?
166
167
  shared_across_delegations(config[:shared_across_delegations]) unless config[:shared_across_delegations].nil?
167
168
  streaming(config[:streaming]) unless config[:streaming].nil?
169
+ disable_environment_info(config[:disable_environment_info]) unless config[:disable_environment_info].nil?
168
170
  thinking(**config[:thinking]) if config[:thinking]
169
171
 
170
172
  if config[:tools]&.any?
@@ -75,6 +75,18 @@ module SwarmSDK
75
75
  end
76
76
  end
77
77
 
78
+ # Stop all observer tasks immediately
79
+ #
80
+ # Interrupts in-flight observer LLM calls by stopping the barrier.
81
+ # Called during swarm interruption instead of wait_for_completion.
82
+ #
83
+ # @return [void]
84
+ def stop
85
+ @barrier&.stop
86
+ rescue StandardError => e
87
+ RubyLLM.logger.debug("SwarmSDK: Error stopping observer barrier: #{e.message}")
88
+ end
89
+
78
90
  # Cleanup all subscriptions
79
91
  #
80
92
  # Unsubscribes from LogCollector to prevent memory leaks.
@@ -90,6 +90,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
90
90
  You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
91
91
 
92
92
 
93
+ <% unless disable_environment_info %>
93
94
  # Today's date
94
95
 
95
96
  <today-date>
@@ -104,6 +105,7 @@ Working directory: <%= cwd %>
104
105
  Platform: <%= platform %>
105
106
  OS Version: <%= os_version %>
106
107
  </env>
108
+ <% end %>
107
109
 
108
110
  IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
109
111
 
@@ -23,6 +23,35 @@ module SwarmSDK
23
23
  !success?
24
24
  end
25
25
 
26
+ # Check if execution was interrupted via swarm.stop
27
+ #
28
+ # @return [Boolean] true if execution was interrupted
29
+ #
30
+ # @example
31
+ # result = swarm.execute("Build auth")
32
+ # result.interrupted? # => false
33
+ #
34
+ # # After calling swarm.stop during execution:
35
+ # result.interrupted? # => true
36
+ def interrupted?
37
+ @metadata[:interrupted] == true
38
+ end
39
+
40
+ # Get the reason execution finished
41
+ #
42
+ # Possible values:
43
+ # - "finished" - Normal completion
44
+ # - "interrupted" - Stopped via swarm.stop
45
+ # - "error" - Execution failed with an error
46
+ #
47
+ # @return [String] The finish reason
48
+ #
49
+ # @example
50
+ # result.finish_reason # => "finished"
51
+ def finish_reason
52
+ @metadata[:finish_reason] || (success? ? "finished" : "error")
53
+ end
54
+
26
55
  # Calculate total cost from logs
27
56
  #
28
57
  # Delegates to total_cost for consistency. This attribute is calculated
@@ -153,30 +153,34 @@ module RubyLLM
153
153
  end
154
154
  end
155
155
 
156
- # Override complete to use emit() and support around_llm_request hook
157
- # Follows fork pattern: tool call handling wraps message addition
156
+ # Override complete to use emit() and support around_llm_request hook.
157
+ # Uses a trampoline loop instead of mutual recursion with handle_tool_calls
158
+ # to avoid stack growth during multi-round tool-call conversations.
158
159
  def complete(&block)
159
- # Execute LLM request (potentially wrapped by around_llm_request hook)
160
- response = execute_llm_request(&block)
160
+ loop do
161
+ response = execute_llm_request(&block)
161
162
 
162
- emit(:new_message) unless block_given?
163
+ emit(:new_message) unless block_given?
163
164
 
164
- if @schema && response.content.is_a?(String)
165
- begin
166
- response.content = JSON.parse(response.content)
167
- rescue JSON::ParserError
168
- # If parsing fails, keep content as string
165
+ if @schema && response.content.is_a?(String)
166
+ begin
167
+ response.content = JSON.parse(response.content)
168
+ rescue JSON::ParserError
169
+ # If parsing fails, keep content as string
170
+ end
169
171
  end
170
- end
171
172
 
172
- add_message(response)
173
- emit(:end_message, response)
174
- if response.tool_call?
175
- # For tool calls: add message, emit end_message, then handle tools
176
- handle_tool_calls(response, &block)
177
- else
178
- # For final responses: add message and emit end_message
179
- response
173
+ add_message(response)
174
+ emit(:end_message, response)
175
+
176
+ if response.tool_call?
177
+ halt_result = handle_tool_calls(response, &block)
178
+ return halt_result if halt_result
179
+
180
+ # Loop continues: next LLM call with zero stack growth
181
+ else
182
+ return response
183
+ end
180
184
  end
181
185
  end
182
186
 
@@ -238,8 +242,9 @@ module RubyLLM
238
242
  end
239
243
  end
240
244
 
241
- # Override handle_tool_calls to use emit and support around_tool_execution hook
242
- def handle_tool_calls(response, &block)
245
+ # Execute tool calls and return halt result (or nil to continue the loop).
246
+ # Does NOT recurse back into complete() — the trampoline loop handles that.
247
+ def handle_tool_calls(response, &_block)
243
248
  halt_result = nil
244
249
 
245
250
  response.tool_calls.each_value do |tool_call|
@@ -259,7 +264,7 @@ module RubyLLM
259
264
  halt_result = result if result.is_a?(Tool::Halt)
260
265
  end
261
266
 
262
- halt_result || complete(&block)
267
+ halt_result
263
268
  end
264
269
 
265
270
  # Execute tool with around_tool_execution hook if set
@@ -39,3 +39,6 @@ require_relative "message_management_patch"
39
39
 
40
40
  # 7. Responses API patch (depends on configuration, uses error classes)
41
41
  require_relative "responses_api_patch"
42
+
43
+ # 8. MCP SSL patch (configures SSL for HTTPX connections in ruby_llm-mcp)
44
+ require_relative "mcp_ssl_patch"
@@ -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)
@@ -149,14 +149,13 @@ module RubyLLM
149
149
 
150
150
  private
151
151
 
152
- # Override handle_tool_calls to support concurrent execution
153
- # 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.
154
154
  def handle_tool_calls(response, &block)
155
155
  return super unless @tool_concurrency
156
156
 
157
157
  tool_calls = response.tool_calls
158
- halt_result = execute_tools_concurrently(tool_calls)
159
- halt_result || complete(&block)
158
+ execute_tools_concurrently(tool_calls)
160
159
  end
161
160
 
162
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
@@ -72,7 +72,7 @@ module SwarmSDK
72
72
  # Default tools available to all agents
73
73
  DEFAULT_TOOLS = ToolConfigurator::DEFAULT_TOOLS
74
74
 
75
- attr_reader :name, :agents, :lead_agent, :mcp_clients, :delegation_instances, :agent_definitions, :swarm_id, :parent_swarm_id, :swarm_registry, :scratchpad_storage, :allow_filesystem_tools, :hook_registry, :global_semaphore, :plugin_storages, :config_for_hooks, :observer_configs, :execution_timeout
75
+ attr_reader :name, :agents, :lead_agent, :mcp_clients, :delegation_instances, :agent_definitions, :swarm_id, :parent_swarm_id, :swarm_registry, :scratchpad_storage, :allow_filesystem_tools, :hook_registry, :global_semaphore, :plugin_storages, :config_for_hooks, :observer_configs, :execution_timeout, :stop_signal_read
76
76
 
77
77
  # Check if scratchpad tools are enabled
78
78
  #
@@ -217,6 +217,13 @@ module SwarmSDK
217
217
  # Observer agent configurations
218
218
  @observer_configs = []
219
219
  @observer_manager = nil
220
+
221
+ # Stop mechanism state
222
+ @stop_requested = false
223
+ @execution_barrier = nil
224
+ @stop_signal_read = nil
225
+ @stop_signal_write = nil
226
+ @active_agent_chats = {}
220
227
  end
221
228
 
222
229
  # Add an agent to the swarm
@@ -471,12 +478,17 @@ module SwarmSDK
471
478
 
472
479
  # Wait for all observer tasks to complete
473
480
  #
481
+ # If a stop was requested, stops observer tasks immediately instead of waiting.
474
482
  # Called by Executor to wait for observer agents before cleanup.
475
483
  # Safe to call even if no observers are configured.
476
484
  #
477
485
  # @return [void]
478
486
  def wait_for_observers
479
- @observer_manager&.wait_for_completion
487
+ if @stop_requested
488
+ stop_observers
489
+ else
490
+ @observer_manager&.wait_for_completion
491
+ end
480
492
  end
481
493
 
482
494
  # Cleanup observer subscriptions
@@ -490,6 +502,124 @@ module SwarmSDK
490
502
  @observer_manager = nil
491
503
  end
492
504
 
505
+ # Stop all swarm execution immediately
506
+ #
507
+ # Thread-safe method that signals the execution to stop. Uses IO.pipe
508
+ # for cross-thread signaling, which wakes the Async scheduler from any
509
+ # thread. The stop listener task then calls barrier.stop within the
510
+ # reactor to cancel all executing tasks.
511
+ #
512
+ # Safe to call from event callbacks, other threads, or signal handlers.
513
+ # No-op if no execution is in progress or stop was already requested.
514
+ #
515
+ # @return [void]
516
+ #
517
+ # @example Stop from event callback
518
+ # swarm.execute("Build auth") do |event|
519
+ # swarm.stop if event[:type] == "tool_call" && event[:tool] == "Dangerous"
520
+ # end
521
+ #
522
+ # @example Stop from another thread
523
+ # Thread.new { swarm.execute("Build auth") }
524
+ # sleep 10
525
+ # swarm.stop
526
+ def stop
527
+ return if @stop_requested
528
+
529
+ @stop_requested = true
530
+ begin
531
+ @stop_signal_write&.write("x") unless @stop_signal_write&.closed?
532
+ @stop_signal_write&.close unless @stop_signal_write&.closed?
533
+ rescue IOError, Errno::EPIPE
534
+ # Pipe already closed - normal during cleanup
535
+ end
536
+ end
537
+
538
+ # Check if a stop has been requested
539
+ #
540
+ # @return [Boolean] true if stop was requested
541
+ def stop_requested?
542
+ @stop_requested
543
+ end
544
+
545
+ # Prepare stop signaling for a new execution
546
+ #
547
+ # Resets the stop flag and creates a new IO.pipe for signaling.
548
+ # Called by Executor at the start of each execution.
549
+ #
550
+ # @return [void]
551
+ def prepare_for_execution
552
+ @stop_requested = false
553
+ @stop_signal_read, @stop_signal_write = IO.pipe
554
+ @active_agent_chats = {}
555
+ end
556
+
557
+ # Close the stop signal pipe
558
+ #
559
+ # Called by Executor after execution completes.
560
+ #
561
+ # @return [void]
562
+ def cleanup_stop_signal
563
+ @stop_signal_read&.close unless @stop_signal_read&.closed?
564
+ @stop_signal_write&.close unless @stop_signal_write&.closed?
565
+ @stop_signal_read = nil
566
+ @stop_signal_write = nil
567
+ end
568
+
569
+ # Register the execution barrier for stop cancellation
570
+ #
571
+ # @param barrier [Async::Barrier] The barrier wrapping execution tasks
572
+ # @return [void]
573
+ def register_execution_barrier(barrier)
574
+ @execution_barrier = barrier
575
+ end
576
+
577
+ # Clear the execution barrier reference
578
+ #
579
+ # @return [void]
580
+ def clear_execution_barrier
581
+ @execution_barrier = nil
582
+ end
583
+
584
+ # Mark an agent as actively executing an LLM call
585
+ #
586
+ # Called by Agent::Chat#execute_ask to track which agents are mid-execution.
587
+ # Used during interruption to emit agent_stop events for active agents.
588
+ #
589
+ # @param name [Symbol] Agent name
590
+ # @param chat [Agent::Chat] Agent chat instance
591
+ # @return [void]
592
+ def mark_agent_active(name, chat)
593
+ @active_agent_chats[name] = chat
594
+ end
595
+
596
+ # Mark an agent as no longer actively executing
597
+ #
598
+ # @param name [Symbol] Agent name
599
+ # @return [void]
600
+ def mark_agent_inactive(name)
601
+ @active_agent_chats.delete(name)
602
+ end
603
+
604
+ # Get a snapshot of currently active agent chats
605
+ #
606
+ # Returns a copy to avoid concurrent modification issues.
607
+ #
608
+ # @return [Hash{Symbol => Agent::Chat}] Copy of active agent chats
609
+ def active_agent_chats
610
+ @active_agent_chats.dup
611
+ end
612
+
613
+ # Stop all observer tasks immediately
614
+ #
615
+ # Interrupts in-flight observer LLM calls.
616
+ # Called during swarm interruption instead of wait_for_completion.
617
+ #
618
+ # @return [void]
619
+ def stop_observers
620
+ @observer_manager&.stop
621
+ end
622
+
493
623
  # Create snapshot of current conversation state
494
624
  #
495
625
  # Returns a Snapshot object containing:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- VERSION = "2.7.13"
4
+ VERSION = "2.7.14"
5
5
  end
data/lib/swarm_sdk.rb CHANGED
@@ -91,6 +91,9 @@ module SwarmSDK
91
91
  # Raised when agent turn exceeds turn_timeout
92
92
  class TurnTimeoutError < TimeoutError; end
93
93
 
94
+ # Raised when swarm execution is interrupted via swarm.stop
95
+ class InterruptedError < Error; end
96
+
94
97
  # Base class for MCP-related errors (provides context about server/tool)
95
98
  class MCPError < Error; end
96
99
 
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.13
4
+ version: 2.7.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -181,6 +181,7 @@ files:
181
181
  - lib/swarm_sdk/ruby_llm_patches/connection_patch.rb
182
182
  - lib/swarm_sdk/ruby_llm_patches/init.rb
183
183
  - lib/swarm_sdk/ruby_llm_patches/io_endpoint_patch.rb
184
+ - lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb
184
185
  - lib/swarm_sdk/ruby_llm_patches/message_management_patch.rb
185
186
  - lib/swarm_sdk/ruby_llm_patches/responses_api_patch.rb
186
187
  - lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb