swarm_sdk 2.7.6 → 2.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5806264bb99cea0a71727c89c5839ff3027c17cd725e4261244e14ae9079f513
4
- data.tar.gz: f3d489dd1d6045dec6b97b499b2d6e27d78440d1b2289a19c2f3bedb27440c8d
3
+ metadata.gz: 28f97819b8742950ef0a081490c54c1ff25c5e69e6befde0e48af177160b42cf
4
+ data.tar.gz: d31dc35d85816fd10f02de92bf1f047278b2ad91c176cd3dc17d6f137ca7b73a
5
5
  SHA512:
6
- metadata.gz: a37edf046435540ba32aa47b5552ef48e1c599c69a0bf0540ccfa2199a01947006ffe98eb8df1848175e11b673bebbf787156815dc4e5ea9403ab0db984997bd
7
- data.tar.gz: 2ca616d67f41b19fe596d86fdd36aa6255cb314480c34253aacb7d8276196fd2e2bbd4fb3057b42faec5b000474754df7cce56cddbeb26fcc3301e6fa2a89fcb
6
+ metadata.gz: 44a557f8935a59242fe3a22bebdf0bd10492852c6a9c7b54124ab1496ced76fc75b3ad5d2fa53c190cf65de3f5332238aa49de96270f390bfad648cd520538a5
7
+ data.tar.gz: c52b1b1b502b7bf3113e68730cc1cfde970e022144bb6481cc7dfabf94cb5051a874cc7d5857b61f51a5f0ff92f71742f28d5672322dfbeb15cb369abcd57737
@@ -52,23 +52,23 @@ module SwarmSDK
52
52
  # Execute request
53
53
  @app.call(env).on_complete do |response_env|
54
54
  end_time = Time.now
55
- duration = end_time - start_time
55
+
56
+ # Determine if this was a streaming request based on whether chunks were accumulated
57
+ # This is more reliable than parsing response content
58
+ is_streaming = accumulated_raw_chunks.any?
56
59
 
57
60
  # For streaming: use accumulated raw SSE chunks
58
61
  # For non-streaming: use response body
59
- raw_body = if accumulated_raw_chunks.any?
60
- accumulated_raw_chunks.join
61
- else
62
- response_env.body
63
- end
62
+ raw_body = is_streaming ? accumulated_raw_chunks.join : response_env.body
64
63
 
65
64
  # Store SSE body in Fiber-local for citation extraction
66
65
  # This allows append_citations_to_content to access the full SSE body
67
66
  # even though response.body is empty for streaming responses
68
- Fiber[:last_sse_body] = raw_body if accumulated_raw_chunks.any?
67
+ Fiber[:last_sse_body] = raw_body if is_streaming
69
68
 
70
69
  # Emit response event
71
- emit_response_event(response_env, start_time, end_time, duration, raw_body)
70
+ timing = { start_time: start_time, end_time: end_time, duration: end_time - start_time }
71
+ emit_response_event(response_env, timing, raw_body, is_streaming)
72
72
  end
73
73
  end
74
74
 
@@ -96,21 +96,17 @@ module SwarmSDK
96
96
  # Emit response event
97
97
  #
98
98
  # @param env [Faraday::Env] Response environment
99
- # @param start_time [Time] Request start time
100
- # @param end_time [Time] Request end time
101
- # @param duration [Float] Request duration in seconds
99
+ # @param timing [Hash] Timing information with :start_time, :end_time, :duration keys
102
100
  # @param raw_body [String, nil] Raw response body (SSE stream for streaming, JSON for non-streaming)
101
+ # @param streaming [Boolean] Whether this was a streaming response (determined by chunk accumulation)
103
102
  # @return [void]
104
- def emit_response_event(env, start_time, end_time, duration, raw_body)
105
- # Detect if this is a streaming response (starts with "data:")
106
- streaming = raw_body.is_a?(String) && raw_body.start_with?("data:")
107
-
103
+ def emit_response_event(env, timing, raw_body, streaming)
108
104
  response_data = {
109
105
  provider: @provider_name,
110
106
  body: parse_body(raw_body),
111
107
  streaming: streaming,
112
- duration_seconds: duration.round(3),
113
- timestamp: end_time.utc.iso8601,
108
+ duration_seconds: timing[:duration].round(3),
109
+ timestamp: timing[:end_time].utc.iso8601,
114
110
  status: env.status,
115
111
  }
116
112
 
@@ -166,6 +162,9 @@ module SwarmSDK
166
162
 
167
163
  # Parse request/response body
168
164
  #
165
+ # For requests: returns parsed JSON hash
166
+ # For responses: returns full body (JSON parsed or raw string for SSE)
167
+ #
169
168
  # @param body [String, Hash, nil] HTTP body
170
169
  # @return [Hash, String, nil] Parsed body
171
170
  def parse_body(body)
@@ -177,8 +176,9 @@ module SwarmSDK
177
176
  # Try to parse JSON
178
177
  JSON.parse(body)
179
178
  rescue JSON::ParserError
180
- # Return truncated string if not JSON
181
- body.to_s[0..1000]
179
+ # Return full body for SSE/non-JSON responses
180
+ # Don't truncate - let consumers decide how to handle large bodies
181
+ body.to_s
182
182
  rescue StandardError
183
183
  nil
184
184
  end
@@ -92,6 +92,7 @@ module SwarmSDK
92
92
  mcp_log_level: ["SWARM_SDK_MCP_LOG_LEVEL", -> { Defaults::Logging::MCP_LOG_LEVEL }],
93
93
  default_execution_timeout: ["SWARM_SDK_DEFAULT_EXECUTION_TIMEOUT", -> { Defaults::Timeouts::EXECUTION_TIMEOUT_SECONDS }],
94
94
  default_turn_timeout: ["SWARM_SDK_DEFAULT_TURN_TIMEOUT", -> { Defaults::Timeouts::TURN_TIMEOUT_SECONDS }],
95
+ mcp_request_timeout: ["SWARM_SDK_MCP_REQUEST_TIMEOUT", -> { Defaults::Timeouts::MCP_REQUEST_SECONDS }],
95
96
  }.freeze
96
97
 
97
98
  # WebFetch and control settings
@@ -94,6 +94,47 @@ module SwarmSDK
94
94
  # Time-to-live for cached response IDs. 5 minutes allows conversation
95
95
  # continuity while preventing stale cache issues.
96
96
  RESPONSES_API_TTL_SECONDS = 300
97
+
98
+ # MCP client request timeout (seconds)
99
+ #
100
+ # Default timeout for MCP server connections. 5 minutes accommodates
101
+ # long-running SSE streams and tool executions. This timeout applies to
102
+ # the entire operation (operation_timeout in HTTPX), so it must be long
103
+ # enough for SSE connections that may run for extended periods.
104
+ MCP_REQUEST_SECONDS = 300
105
+ end
106
+
107
+ # MCP reconnection configuration
108
+ #
109
+ # Settings for automatic reconnection when SSE/streamable connections drop.
110
+ # Note: The background SSE notification stream uses operation_timeout which
111
+ # limits total connection duration. Since this stream is meant to stay open
112
+ # indefinitely for server notifications, we configure aggressive reconnection
113
+ # so timeouts are transparent to users. Tool calls use separate connections
114
+ # and are unaffected by SSE stream timeouts.
115
+ module McpReconnection
116
+ # Maximum number of reconnection attempts
117
+ #
118
+ # Very high value (effectively infinite) because the SSE notification stream
119
+ # is expected to timeout periodically due to operation_timeout limitations.
120
+ # Reconnection is transparent - tool calls continue working regardless.
121
+ MAX_RETRIES = 1000
122
+
123
+ # Initial delay between reconnection attempts (milliseconds)
124
+ #
125
+ # Fast initial reconnect (500ms) to minimize notification gaps.
126
+ INITIAL_DELAY_MS = 500
127
+
128
+ # Exponential backoff growth factor
129
+ #
130
+ # Slow growth (1.2x) because we expect frequent reconnections.
131
+ # 500ms -> 600ms -> 720ms -> 864ms -> 1037ms -> ...
132
+ DELAY_GROW_FACTOR = 1.2
133
+
134
+ # Maximum delay between reconnection attempts (milliseconds)
135
+ #
136
+ # Caps at 10 seconds to ensure responsive reconnection even after many retries.
137
+ MAX_DELAY_MS = 10_000
97
138
  end
98
139
 
99
140
  # Output and content size limits
@@ -117,38 +117,92 @@ module SwarmSDK
117
117
  #
118
118
  # Agents that are ONLY delegates with shared_across_delegations: false
119
119
  # are NOT created here - they'll be created as delegation instances in pass 2a.
120
+ #
121
+ # Agent creation is parallelized using Async::Barrier for faster initialization.
120
122
  def pass_1_create_agents
121
123
  # Create plugin storages for agents
122
124
  create_plugin_storages
123
125
 
124
126
  tool_configurator = ToolConfigurator.new(@swarm, @swarm.scratchpad_storage, @swarm.plugin_storages)
125
127
 
126
- @swarm.agent_definitions.each do |name, agent_definition|
127
- # Skip if this agent will only exist as delegation instances
128
- next if should_skip_primary_creation?(name, agent_definition)
128
+ # Filter agents that need primary creation
129
+ agents_to_create = @swarm.agent_definitions.reject do |name, agent_definition|
130
+ should_skip_primary_creation?(name, agent_definition)
131
+ end
132
+
133
+ # Create agents in parallel using Async::Barrier
134
+ results = create_agents_in_parallel(agents_to_create, tool_configurator)
129
135
 
130
- chat = create_agent_chat(name, agent_definition, tool_configurator)
136
+ # Store results and notify plugins (sequential for safety)
137
+ results.each do |name, chat, agent_definition|
131
138
  @agents[name] = chat
132
-
133
- # Notify plugins that agent was initialized
134
139
  notify_plugins_agent_initialized(name, chat, agent_definition, tool_configurator)
135
140
  end
136
141
  end
137
142
 
138
- # Pass 2: Create delegation instances and wire delegation tools
143
+ # Create multiple agents in parallel using Async fibers
139
144
  #
140
- # This pass has three sub-steps that must happen in order:
141
- # 2a. Create delegation instances (ONLY for agents with shared_across_delegations: false)
142
- # 2b. Wire primary agents to delegation instances OR shared primaries
143
- # 2c. Wire delegation instances to their delegates (nested delegation support)
144
- def pass_2_register_delegation_tools
145
- tool_configurator = ToolConfigurator.new(@swarm, @swarm.scratchpad_storage, @swarm.plugin_storages)
145
+ # @param agents_to_create [Hash] Hash of { name => agent_definition }
146
+ # @param tool_configurator [ToolConfigurator] Shared tool configurator
147
+ # @return [Array<Array>] Array of [name, chat, agent_definition] tuples
148
+ def create_agents_in_parallel(agents_to_create, tool_configurator)
149
+ return [] if agents_to_create.empty?
150
+
151
+ results = []
152
+ errors = []
153
+ mutex = Mutex.new
154
+
155
+ Sync do
156
+ barrier = Async::Barrier.new
157
+
158
+ agents_to_create.each do |name, agent_definition|
159
+ barrier.async do
160
+ chat = create_agent_chat(name, agent_definition, tool_configurator)
161
+ mutex.synchronize { results << [name, chat, agent_definition] }
162
+ rescue StandardError => e
163
+ # Catch errors to avoid Async warning logs (which fail in tests with StringIO)
164
+ mutex.synchronize { errors << [name, e] }
165
+ end
166
+ end
167
+
168
+ barrier.wait
169
+ end
170
+
171
+ # Re-raise first error if any occurred
172
+ unless errors.empty?
173
+ # Emit events for all errors (not just the first)
174
+ errors.each do |agent_name, err|
175
+ LogStream.emit(
176
+ type: "agent_initialization_error",
177
+ agent: agent_name,
178
+ error_class: err.class.name,
179
+ error_message: err.message,
180
+ timestamp: Time.now.utc.iso8601,
181
+ )
182
+ end
183
+
184
+ # Re-raise first error with context
185
+ name, error = errors.first
186
+ raise error.class, "Agent '#{name}' initialization failed: #{error.message}", error.backtrace
187
+ end
188
+
189
+ results
190
+ end
191
+
192
+ # Collect all delegation instances that need to be created
193
+ #
194
+ # Validates delegation configs and returns a list of instances to create.
195
+ # This is done sequentially to fail fast on configuration errors.
196
+ #
197
+ # @return [Array<Hash>] Array of { instance_name:, base_name:, definition: }
198
+ def collect_delegation_instances_to_create
199
+ instances = []
146
200
 
147
- # Sub-pass 2a: Create delegation instances for isolated agents
148
201
  @swarm.agent_definitions.each do |delegator_name, delegator_def|
149
202
  delegator_def.delegation_configs.each do |delegation_config|
150
203
  delegate_base_name = delegation_config[:agent]
151
204
 
205
+ # Validate delegate exists
152
206
  unless @swarm.agent_definitions.key?(delegate_base_name)
153
207
  raise ConfigurationError,
154
208
  "Agent '#{delegator_name}' delegates to unknown agent '#{delegate_base_name}'"
@@ -156,24 +210,95 @@ module SwarmSDK
156
210
 
157
211
  delegate_definition = @swarm.agent_definitions[delegate_base_name]
158
212
 
159
- # Check isolation mode of the DELEGATE agent
160
- # If delegate wants to be shared, skip instance creation (use primary)
213
+ # Skip if delegate wants to be shared (use primary instead)
161
214
  next if delegate_definition.shared_across_delegations
162
215
 
163
- # Create unique delegation instance (isolated mode)
164
216
  instance_name = "#{delegate_base_name}@#{delegator_name}"
165
217
 
166
- # V7.0: Use existing register_all_tools (no new method needed!)
167
- delegation_chat = create_agent_chat_for_delegation(
218
+ instances << {
168
219
  instance_name: instance_name,
169
220
  base_name: delegate_base_name,
170
- agent_definition: delegate_definition,
171
- tool_configurator: tool_configurator,
172
- )
221
+ definition: delegate_definition,
222
+ }
223
+ end
224
+ end
225
+
226
+ instances
227
+ end
228
+
229
+ # Create multiple delegation instances in parallel using Async fibers
230
+ #
231
+ # @param instances_to_create [Array<Hash>] Array of instance configs
232
+ # @param tool_configurator [ToolConfigurator] Shared tool configurator
233
+ # @return [Array<Array>] Array of [instance_name, chat] tuples
234
+ def create_delegation_instances_in_parallel(instances_to_create, tool_configurator)
235
+ return [] if instances_to_create.empty?
236
+
237
+ results = []
238
+ errors = []
239
+ mutex = Mutex.new
240
+
241
+ Sync do
242
+ barrier = Async::Barrier.new
243
+
244
+ instances_to_create.each do |config|
245
+ barrier.async do
246
+ delegation_chat = create_agent_chat_for_delegation(
247
+ instance_name: config[:instance_name],
248
+ base_name: config[:base_name],
249
+ agent_definition: config[:definition],
250
+ tool_configurator: tool_configurator,
251
+ )
252
+ mutex.synchronize { results << [config[:instance_name], delegation_chat] }
253
+ rescue StandardError => e
254
+ # Catch errors to avoid Async warning logs (which fail in tests with StringIO)
255
+ mutex.synchronize { errors << [config[:instance_name], e] }
256
+ end
257
+ end
258
+
259
+ barrier.wait
260
+ end
173
261
 
174
- # Store in delegation_instances hash
175
- @swarm.delegation_instances[instance_name] = delegation_chat
262
+ # Re-raise first error if any occurred
263
+ unless errors.empty?
264
+ # Emit events for all errors (not just the first)
265
+ errors.each do |inst_name, err|
266
+ LogStream.emit(
267
+ type: "delegation_instance_initialization_error",
268
+ instance_name: inst_name,
269
+ error_class: err.class.name,
270
+ error_message: err.message,
271
+ timestamp: Time.now.utc.iso8601,
272
+ )
176
273
  end
274
+
275
+ # Re-raise first error with context
276
+ instance_name, error = errors.first
277
+ raise error.class, "Delegation instance '#{instance_name}' initialization failed: #{error.message}", error.backtrace
278
+ end
279
+
280
+ results
281
+ end
282
+
283
+ # Pass 2: Create delegation instances and wire delegation tools
284
+ #
285
+ # This pass has three sub-steps that must happen in order:
286
+ # 2a. Create delegation instances (ONLY for agents with shared_across_delegations: false)
287
+ # 2b. Wire primary agents to delegation instances OR shared primaries
288
+ # 2c. Wire delegation instances to their delegates (nested delegation support)
289
+ #
290
+ # Sub-pass 2a is parallelized using Async::Barrier for faster initialization.
291
+ def pass_2_register_delegation_tools
292
+ tool_configurator = ToolConfigurator.new(@swarm, @swarm.scratchpad_storage, @swarm.plugin_storages)
293
+
294
+ # Sub-pass 2a: Create delegation instances for isolated agents (parallelized)
295
+ delegation_instances_to_create = collect_delegation_instances_to_create
296
+
297
+ results = create_delegation_instances_in_parallel(delegation_instances_to_create, tool_configurator)
298
+
299
+ # Store results after all parallel creation completes
300
+ results.each do |instance_name, delegation_chat|
301
+ @swarm.delegation_instances[instance_name] = delegation_chat
177
302
  end
178
303
 
179
304
  # Sub-pass 2b: Wire primary agents to delegation instances OR shared primaries OR registered swarms
@@ -82,6 +82,7 @@ module SwarmSDK
82
82
  stub = Tools::McpToolStub.new(
83
83
  client: client,
84
84
  name: tool_name.to_s,
85
+ server_name: server_config[:name],
85
86
  )
86
87
  chat.tool_registry.register(
87
88
  stub,
@@ -129,7 +130,8 @@ module SwarmSDK
129
130
  # @return [RubyLLM::MCP::Client] Initialized MCP client
130
131
  def initialize_mcp_client(config)
131
132
  # Convert timeout from seconds to milliseconds
132
- timeout_seconds = config[:timeout] || 30
133
+ # Use explicit config[:timeout] if provided, otherwise use global default
134
+ timeout_seconds = config[:timeout] || SwarmSDK.config.mcp_request_timeout
133
135
  timeout_ms = timeout_seconds * 1000
134
136
 
135
137
  # Determine transport type
@@ -178,11 +180,16 @@ module SwarmSDK
178
180
  # @param config [Hash] MCP server configuration
179
181
  # @return [Hash] SSE configuration
180
182
  def build_sse_config(config)
181
- {
183
+ sse_config = {
182
184
  url: config[:url],
183
185
  headers: config[:headers] || {},
184
186
  version: config[:version]&.to_sym || :http2,
185
187
  }
188
+
189
+ # Add reconnection options for resilient SSE connections
190
+ sse_config[:reconnection] = build_reconnection_options(config)
191
+
192
+ sse_config
186
193
  end
187
194
 
188
195
  # Build streamable (HTTP) transport configuration
@@ -199,9 +206,30 @@ module SwarmSDK
199
206
  # Only include rate_limit if present
200
207
  streamable_config[:rate_limit] = config[:rate_limit] if config[:rate_limit]
201
208
 
209
+ # Add reconnection options for resilient streamable connections
210
+ streamable_config[:reconnection] = build_reconnection_options(config)
211
+
202
212
  streamable_config
203
213
  end
204
214
 
215
+ # Build reconnection options from config or defaults
216
+ #
217
+ # Provides exponential backoff reconnection for SSE/streamable transports.
218
+ # Can be customized per-server or uses global defaults.
219
+ #
220
+ # @param config [Hash] MCP server configuration
221
+ # @return [Hash] Reconnection options
222
+ def build_reconnection_options(config)
223
+ reconnection_config = config[:reconnection] || {}
224
+
225
+ {
226
+ max_retries: reconnection_config[:max_retries] || Defaults::McpReconnection::MAX_RETRIES,
227
+ initial_reconnection_delay: reconnection_config[:initial_delay] || Defaults::McpReconnection::INITIAL_DELAY_MS,
228
+ reconnection_delay_grow_factor: reconnection_config[:delay_grow_factor] || Defaults::McpReconnection::DELAY_GROW_FACTOR,
229
+ max_reconnection_delay: reconnection_config[:max_delay] || Defaults::McpReconnection::MAX_DELAY_MS,
230
+ }
231
+ end
232
+
205
233
  # Emit MCP server initialization start event
206
234
  #
207
235
  # @param agent_name [Symbol] Agent name
@@ -33,22 +33,24 @@ module SwarmSDK
33
33
  class McpToolStub < Base
34
34
  removable true # MCP tools can be controlled by skills
35
35
 
36
- attr_reader :name, :client
36
+ attr_reader :name, :client, :server_name
37
37
 
38
38
  # Create a new MCP tool stub
39
39
  #
40
40
  # @param client [RubyLLM::MCP::Client] MCP client instance
41
41
  # @param name [String] Tool name
42
+ # @param server_name [String, nil] MCP server name for error context
42
43
  # @param description [String, nil] Tool description (optional, fetched if nil)
43
44
  # @param schema [Hash, nil] Tool input schema (optional, fetched if nil)
44
45
  #
45
46
  # @example Minimal stub (lazy description + schema)
46
- # McpToolStub.new(client: client, name: "search")
47
+ # McpToolStub.new(client: client, name: "search", server_name: "codebase")
47
48
  #
48
49
  # @example With description (lazy schema only)
49
50
  # McpToolStub.new(
50
51
  # client: client,
51
52
  # name: "search",
53
+ # server_name: "codebase",
52
54
  # description: "Search the codebase"
53
55
  # )
54
56
  #
@@ -56,14 +58,16 @@ module SwarmSDK
56
58
  # McpToolStub.new(
57
59
  # client: client,
58
60
  # name: "search",
61
+ # server_name: "codebase",
59
62
  # description: "Search the codebase",
60
63
  # schema: { type: "object", properties: {...} }
61
64
  # )
62
- def initialize(client:, name:, description: nil, schema: nil)
65
+ def initialize(client:, name:, server_name: nil, description: nil, schema: nil)
63
66
  super()
64
67
  @client = client
65
68
  @name = name
66
69
  @mcp_name = name
70
+ @server_name = server_name || "unknown"
67
71
  @description = description || "MCP tool: #{name}"
68
72
  @input_schema = schema
69
73
  @schema_loaded = !schema.nil?
@@ -93,6 +97,9 @@ module SwarmSDK
93
97
  #
94
98
  # @param params [Hash] Tool parameters
95
99
  # @return [String, Hash] Tool result content or error hash
100
+ # @raise [MCPTimeoutError] When the MCP server times out
101
+ # @raise [MCPTransportError] When there's a transport-level error
102
+ # @raise [MCPError] When any other MCP error occurs
96
103
  def execute(**params)
97
104
  # Use client.call_tool (client has internal coordinator)
98
105
  result = @client.call_tool(
@@ -102,6 +109,23 @@ module SwarmSDK
102
109
 
103
110
  # client.call_tool returns the result content directly
104
111
  result
112
+ rescue RubyLLM::MCP::Errors::TimeoutError => e
113
+ raise MCPTimeoutError, format_mcp_error(
114
+ "MCP request timed out",
115
+ original_message: e.message,
116
+ request_id: e.request_id,
117
+ )
118
+ rescue RubyLLM::MCP::Errors::TransportError => e
119
+ raise MCPTransportError, format_mcp_error(
120
+ "MCP transport error",
121
+ original_message: e.message,
122
+ code: e.code,
123
+ )
124
+ rescue RubyLLM::MCP::Errors::BaseError => e
125
+ raise MCPError, format_mcp_error(
126
+ "MCP error",
127
+ original_message: e.message,
128
+ )
105
129
  end
106
130
 
107
131
  private
@@ -112,6 +136,9 @@ module SwarmSDK
112
136
  # Multiple concurrent fibers will only trigger one fetch.
113
137
  #
114
138
  # @return [void]
139
+ # @raise [MCPTimeoutError] When the MCP server times out during schema fetch
140
+ # @raise [MCPTransportError] When there's a transport-level error
141
+ # @raise [MCPError] When any other MCP error occurs
115
142
  def ensure_schema_loaded!
116
143
  return if @schema_loaded
117
144
 
@@ -131,6 +158,40 @@ module SwarmSDK
131
158
 
132
159
  @schema_loaded = true
133
160
  end
161
+ rescue RubyLLM::MCP::Errors::TimeoutError => e
162
+ raise MCPTimeoutError, format_mcp_error(
163
+ "MCP schema fetch timed out",
164
+ original_message: e.message,
165
+ request_id: e.request_id,
166
+ )
167
+ rescue RubyLLM::MCP::Errors::TransportError => e
168
+ raise MCPTransportError, format_mcp_error(
169
+ "MCP transport error during schema fetch",
170
+ original_message: e.message,
171
+ code: e.code,
172
+ )
173
+ rescue RubyLLM::MCP::Errors::BaseError => e
174
+ raise MCPError, format_mcp_error(
175
+ "MCP error during schema fetch",
176
+ original_message: e.message,
177
+ )
178
+ end
179
+
180
+ # Format MCP error message with contextual information
181
+ #
182
+ # @param prefix [String] Error message prefix
183
+ # @param original_message [String] Original error message from RubyLLM::MCP
184
+ # @param request_id [String, nil] MCP request ID (for timeout errors)
185
+ # @param code [Integer, nil] HTTP status code (for transport errors)
186
+ # @return [String] Formatted error message with full context
187
+ def format_mcp_error(prefix, original_message:, request_id: nil, code: nil)
188
+ parts = [prefix]
189
+ parts << "[server: #{@server_name}]"
190
+ parts << "[tool: #{@mcp_name}]"
191
+ parts << "[request_id: #{request_id}]" if request_id
192
+ parts << "[code: #{code}]" if code
193
+ parts << "- #{original_message}"
194
+ parts.join(" ")
134
195
  end
135
196
  end
136
197
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- VERSION = "2.7.6"
4
+ VERSION = "2.7.8"
5
5
  end
data/lib/swarm_sdk.rb CHANGED
@@ -71,6 +71,15 @@ module SwarmSDK
71
71
  # Raised when agent turn exceeds turn_timeout
72
72
  class TurnTimeoutError < TimeoutError; end
73
73
 
74
+ # Base class for MCP-related errors (provides context about server/tool)
75
+ class MCPError < Error; end
76
+
77
+ # Raised when MCP request times out
78
+ class MCPTimeoutError < MCPError; end
79
+
80
+ # Raised when MCP transport fails (connection, HTTP errors)
81
+ class MCPTransportError < MCPError; end
82
+
74
83
  class << self
75
84
  # Get the global configuration instance
76
85
  #
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.6
4
+ version: 2.7.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda