swarm_sdk 2.7.13 → 2.7.15
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 +47 -40
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +4 -0
- 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 +9 -0
- data/lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb +144 -0
- data/lib/swarm_sdk/ruby_llm_patches/openai_thought_signature_patch.rb +98 -0
- data/lib/swarm_sdk/ruby_llm_patches/streaming_error_patch.rb +50 -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 +4 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Patches RubyLLM::Providers::OpenAI::Tools to preserve thought_signature
|
|
4
|
+
# through the OpenAI-compatible streaming pipeline.
|
|
5
|
+
#
|
|
6
|
+
# Vertex AI Gemini 3 models with "thinking" enabled return a thought_signature
|
|
7
|
+
# in tool call responses via extra_content.google.thought_signature. This must
|
|
8
|
+
# be echoed back in subsequent requests or the API rejects the request with:
|
|
9
|
+
#
|
|
10
|
+
# "function call is missing a thought_signature"
|
|
11
|
+
#
|
|
12
|
+
# The native Gemini provider handles this correctly, but the OpenAI provider
|
|
13
|
+
# (used for OpenAI-compatible proxies) drops thought_signature in both
|
|
14
|
+
# parse_tool_calls and format_tool_calls. The rest of the pipeline
|
|
15
|
+
# (StreamAccumulator, ToolCall) already supports thought_signature.
|
|
16
|
+
#
|
|
17
|
+
# This patch:
|
|
18
|
+
# - Extracts thought_signature from extra_content during tool call parsing
|
|
19
|
+
# - Echoes thought_signature back in extra_content during serialization
|
|
20
|
+
|
|
21
|
+
module RubyLLM
|
|
22
|
+
module Providers
|
|
23
|
+
class OpenAI
|
|
24
|
+
module Tools
|
|
25
|
+
# rubocop:disable Style/ModuleFunction -- required to replace singleton method copy
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Parse tool calls from OpenAI-format response data
|
|
30
|
+
#
|
|
31
|
+
# @param tool_calls [Array<Hash>] Raw tool call data from API response
|
|
32
|
+
# @param parse_arguments [Boolean] Whether to JSON-parse arguments (false during streaming)
|
|
33
|
+
# @return [Hash{String => ToolCall}, nil] Parsed tool calls keyed by ID
|
|
34
|
+
def parse_tool_calls(tool_calls, parse_arguments: true)
|
|
35
|
+
return unless tool_calls&.any?
|
|
36
|
+
|
|
37
|
+
tool_calls.to_h do |tc|
|
|
38
|
+
thought_sig = tc.dig("extra_content", "google", "thought_signature")
|
|
39
|
+
|
|
40
|
+
[
|
|
41
|
+
tc["id"],
|
|
42
|
+
ToolCall.new(
|
|
43
|
+
id: tc["id"],
|
|
44
|
+
name: tc.dig("function", "name"),
|
|
45
|
+
arguments: if parse_arguments
|
|
46
|
+
parse_tool_call_arguments(tc)
|
|
47
|
+
else
|
|
48
|
+
tc.dig("function", "arguments")
|
|
49
|
+
end,
|
|
50
|
+
thought_signature: thought_sig,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Serialize tool calls into OpenAI-format request data
|
|
57
|
+
#
|
|
58
|
+
# @param tool_calls [Hash{String => ToolCall}] Tool calls to serialize
|
|
59
|
+
# @return [Array<Hash>, nil] Serialized tool calls for API request
|
|
60
|
+
def format_tool_calls(tool_calls)
|
|
61
|
+
return unless tool_calls&.any?
|
|
62
|
+
|
|
63
|
+
tool_calls.map do |_, tc|
|
|
64
|
+
entry = {
|
|
65
|
+
id: tc.id,
|
|
66
|
+
type: "function",
|
|
67
|
+
function: {
|
|
68
|
+
name: tc.name,
|
|
69
|
+
arguments: JSON.generate(tc.arguments),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if tc.thought_signature
|
|
74
|
+
entry[:extra_content] = { google: { thought_signature: tc.thought_signature } }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
entry
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parse tool call arguments from raw hash
|
|
82
|
+
#
|
|
83
|
+
# @param tool_call [Hash] Raw tool call hash
|
|
84
|
+
# @return [Hash] Parsed arguments
|
|
85
|
+
def parse_tool_call_arguments(tool_call)
|
|
86
|
+
arguments = tool_call.dig("function", "arguments")
|
|
87
|
+
|
|
88
|
+
if arguments.nil? || arguments.empty?
|
|
89
|
+
{}
|
|
90
|
+
else
|
|
91
|
+
JSON.parse(arguments)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
# rubocop:enable Style/ModuleFunction
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hardens RubyLLM::Providers::OpenAI::Streaming#parse_streaming_error against
|
|
4
|
+
# non-standard error response shapes returned by OpenAI-compatible proxies
|
|
5
|
+
# (e.g. Gemini via Vertex AI).
|
|
6
|
+
#
|
|
7
|
+
# The upstream implementation assumes `error_data['error']` is always a Hash,
|
|
8
|
+
# but some proxies return a bare String ({"error": "message"}) or an Array
|
|
9
|
+
# top-level, causing TypeError: no implicit conversion of String into Integer.
|
|
10
|
+
#
|
|
11
|
+
# This patch adds type guards while preserving the exact original behavior
|
|
12
|
+
# for well-formed OpenAI error responses.
|
|
13
|
+
#
|
|
14
|
+
# Upstream issue: https://github.com/crmne/ruby_llm/issues/XXX
|
|
15
|
+
|
|
16
|
+
module RubyLLM
|
|
17
|
+
module Providers
|
|
18
|
+
class OpenAI
|
|
19
|
+
module Streaming
|
|
20
|
+
# rubocop:disable Style/ModuleFunction -- module_function is required here
|
|
21
|
+
# to replace both the singleton and instance method copies created by the
|
|
22
|
+
# original module_function call in upstream RubyLLM. extend self would only
|
|
23
|
+
# add a delegation layer and not override the existing singleton method.
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def parse_streaming_error(data)
|
|
28
|
+
error_data = JSON.parse(data)
|
|
29
|
+
return unless error_data.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
error = error_data["error"]
|
|
32
|
+
return unless error
|
|
33
|
+
|
|
34
|
+
# Some proxies return {"error": "message"} instead of {"error": {"type": ..., "message": ...}}
|
|
35
|
+
return [500, error.to_s] unless error.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
case error["type"]
|
|
38
|
+
when "server_error"
|
|
39
|
+
[500, error["message"]]
|
|
40
|
+
when "rate_limit_exceeded", "insufficient_quota"
|
|
41
|
+
[429, error["message"]]
|
|
42
|
+
else
|
|
43
|
+
[400, error["message"]]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
# rubocop:enable Style/ModuleFunction
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -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
|