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.
@@ -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
- # 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