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 +4 -4
- data/lib/swarm_sdk/agent/builder.rb +25 -0
- data/lib/swarm_sdk/agent/chat.rb +46 -40
- data/lib/swarm_sdk/agent/definition.rb +8 -1
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +15 -3
- data/lib/swarm_sdk/builders/base_builder.rb +5 -0
- data/lib/swarm_sdk/concerns/cleanupable.rb +3 -0
- data/lib/swarm_sdk/config.rb +2 -1
- data/lib/swarm_sdk/configuration/translator.rb +2 -0
- data/lib/swarm_sdk/observer/manager.rb +12 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +2 -0
- data/lib/swarm_sdk/result.rb +29 -0
- data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +27 -22
- data/lib/swarm_sdk/ruby_llm_patches/init.rb +3 -0
- data/lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb +144 -0
- data/lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb +3 -4
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +9 -0
- data/lib/swarm_sdk/swarm/executor.rb +176 -20
- data/lib/swarm_sdk/swarm/hook_triggers.rb +11 -0
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +1 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +20 -0
- data/lib/swarm_sdk/swarm.rb +132 -2
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +3 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22ea4e05517149f4cb6f6f18e896e9493e9ed75f69e82bd6891a9b0b471e314e
|
|
4
|
+
data.tar.gz: 94f2f1b7c28fe626889aa4f1fbb611bb36ac4d73ab7de778f76e553538589cbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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?
|
data/lib/swarm_sdk/agent/chat.rb
CHANGED
|
@@ -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
|
-
|
|
661
|
+
@hook_swarm&.mark_agent_active(@agent_name, self)
|
|
662
662
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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
|
data/lib/swarm_sdk/config.rb
CHANGED
|
@@ -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
|
|
data/lib/swarm_sdk/result.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
160
|
-
|
|
160
|
+
loop do
|
|
161
|
+
response = execute_llm_request(&block)
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
emit(:new_message) unless block_given?
|
|
163
164
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
#
|
|
242
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/swarm_sdk/swarm.rb
CHANGED
|
@@ -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
|
-
@
|
|
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:
|
data/lib/swarm_sdk/version.rb
CHANGED
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.
|
|
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
|