robot_lab 0.0.7 → 0.0.9

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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -3
  3. data/README.md +125 -26
  4. data/Rakefile +39 -1
  5. data/docs/api/core/robot.md +284 -8
  6. data/docs/api/core/tool.md +28 -2
  7. data/docs/api/mcp/client.md +1 -0
  8. data/docs/api/mcp/server.md +27 -8
  9. data/docs/api/mcp/transports.md +21 -6
  10. data/docs/architecture/core-concepts.md +1 -1
  11. data/docs/architecture/robot-execution.md +20 -2
  12. data/docs/concepts.md +11 -3
  13. data/docs/examples/index.md +1 -1
  14. data/docs/examples/rails-application.md +1 -1
  15. data/docs/guides/building-robots.md +245 -11
  16. data/docs/guides/creating-networks.md +18 -0
  17. data/docs/guides/mcp-integration.md +74 -2
  18. data/docs/guides/rails-integration.md +145 -17
  19. data/docs/guides/using-tools.md +45 -4
  20. data/docs/index.md +62 -6
  21. data/examples/05_streaming.rb +113 -94
  22. data/examples/14_rusty_circuit/.gitignore +1 -0
  23. data/examples/14_rusty_circuit/open_mic.rb +1 -1
  24. data/examples/17_skills.rb +242 -0
  25. data/examples/18_rails/.envrc +3 -0
  26. data/examples/18_rails/.gitignore +5 -0
  27. data/examples/18_rails/Gemfile +10 -0
  28. data/examples/18_rails/README.md +48 -0
  29. data/examples/18_rails/Rakefile +4 -0
  30. data/examples/18_rails/app/controllers/application_controller.rb +4 -0
  31. data/examples/18_rails/app/controllers/chat_controller.rb +46 -0
  32. data/examples/18_rails/app/jobs/application_job.rb +4 -0
  33. data/examples/18_rails/app/jobs/robot_run_job.rb +79 -0
  34. data/examples/18_rails/app/models/application_record.rb +5 -0
  35. data/examples/18_rails/app/models/robot_lab_result.rb +36 -0
  36. data/examples/18_rails/app/models/robot_lab_thread.rb +23 -0
  37. data/examples/18_rails/app/robots/chat_robot.rb +14 -0
  38. data/examples/18_rails/app/tools/time_tool.rb +9 -0
  39. data/examples/18_rails/app/views/chat/_user_message.html.erb +1 -0
  40. data/examples/18_rails/app/views/chat/index.html.erb +67 -0
  41. data/examples/18_rails/app/views/layouts/application.html.erb +49 -0
  42. data/examples/18_rails/bin/dev +7 -0
  43. data/examples/18_rails/bin/rails +6 -0
  44. data/examples/18_rails/bin/setup +15 -0
  45. data/examples/18_rails/config/application.rb +33 -0
  46. data/examples/18_rails/config/cable.yml +2 -0
  47. data/examples/18_rails/config/database.yml +5 -0
  48. data/examples/18_rails/config/environment.rb +4 -0
  49. data/examples/18_rails/config/initializers/robot_lab.rb +3 -0
  50. data/examples/18_rails/config/routes.rb +6 -0
  51. data/examples/18_rails/config.ru +4 -0
  52. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +32 -0
  53. data/examples/README.md +30 -0
  54. data/examples/prompts/audit_trail.md +8 -0
  55. data/examples/prompts/incident_responder.md +18 -0
  56. data/examples/prompts/parameterized_main_test.md +6 -0
  57. data/examples/prompts/pii_redactor.md +7 -0
  58. data/examples/prompts/runbook_protocol.md +12 -0
  59. data/examples/prompts/skill_a_test.md +4 -0
  60. data/examples/prompts/skill_b_test.md +4 -0
  61. data/examples/prompts/skill_config_test.md +5 -0
  62. data/examples/prompts/skill_cycle_a_test.md +6 -0
  63. data/examples/prompts/skill_cycle_b_test.md +6 -0
  64. data/examples/prompts/skill_description_test.md +4 -0
  65. data/examples/prompts/skill_leaf_test.md +4 -0
  66. data/examples/prompts/skill_nested_test.md +6 -0
  67. data/examples/prompts/skill_refs_main_test.md +6 -0
  68. data/examples/prompts/skill_self_ref_test.md +6 -0
  69. data/examples/prompts/skill_with_params_test.md +6 -0
  70. data/examples/prompts/sre_compliance.md +10 -0
  71. data/examples/prompts/structured_output.md +7 -0
  72. data/examples/prompts/template_with_skills_test.md +6 -0
  73. data/lib/generators/robot_lab/install_generator.rb +12 -0
  74. data/lib/generators/robot_lab/templates/initializer.rb.tt +36 -22
  75. data/lib/generators/robot_lab/templates/job.rb.tt +92 -0
  76. data/lib/generators/robot_lab/templates/robot.rb.tt +6 -21
  77. data/lib/generators/robot_lab/templates/robot_test.rb.tt +5 -3
  78. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +41 -35
  79. data/lib/robot_lab/mcp/client.rb +6 -4
  80. data/lib/robot_lab/mcp/server.rb +21 -3
  81. data/lib/robot_lab/mcp/transports/base.rb +10 -2
  82. data/lib/robot_lab/mcp/transports/stdio.rb +52 -26
  83. data/lib/robot_lab/{rails → rails_integration}/engine.rb +1 -1
  84. data/lib/robot_lab/{rails → rails_integration}/railtie.rb +1 -1
  85. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +72 -0
  86. data/lib/robot_lab/robot/mcp_management.rb +61 -4
  87. data/lib/robot_lab/robot/template_rendering.rb +181 -4
  88. data/lib/robot_lab/robot.rb +196 -33
  89. data/lib/robot_lab/robot_result.rb +3 -1
  90. data/lib/robot_lab/run_config.rb +1 -1
  91. data/lib/robot_lab/tool.rb +26 -0
  92. data/lib/robot_lab/version.rb +1 -1
  93. data/lib/robot_lab.rb +6 -4
  94. metadata +55 -19
  95. data/examples/14_rusty_circuit/scout_notes.md +0 -89
@@ -4,50 +4,56 @@
4
4
  #
5
5
  # <%= robot_description %>
6
6
  #
7
- # This robot routes requests to other robots based on classification.
7
+ # This robot classifies requests and activates optional tasks in a Network.
8
+ # Use it as the first task in a network with optional downstream tasks:
8
9
  #
9
- class <%= class_name %>Robot
10
- include RobotLab::Lifecycle
11
-
10
+ # classifier = <%= class_name %>Robot.build
11
+ # billing = BillingRobot.build
12
+ # technical = TechnicalRobot.build
13
+ #
14
+ # network = RobotLab.create_network(name: "support") do
15
+ # task :classifier, classifier, depends_on: :none
16
+ # task :billing, billing, depends_on: :optional
17
+ # task :technical, technical, depends_on: :optional
18
+ # end
19
+ #
20
+ # result = network.run(message: "I was charged twice")
21
+ #
22
+ class <%= class_name %>Robot < RobotLab::Robot
12
23
  SYSTEM_PROMPT = <<~PROMPT
13
- You are a routing robot that classifies user requests and directs them
14
- to the appropriate specialist robot.
24
+ You are a routing robot that classifies user requests.
15
25
 
16
- Analyze the user's request and determine which robot should handle it.
17
- Respond with your classification decision.
26
+ Analyze the user's request and respond with ONLY the category name.
27
+ Valid categories: billing, technical, general
18
28
  PROMPT
19
29
 
20
- def self.build
21
- RobotLab.create_routing_robot(
30
+ def self.build(**options)
31
+ new(
22
32
  name: "<%= file_name %>",
23
33
  description: "<%= robot_description %>",
24
- system: SYSTEM_PROMPT,
25
- on_route: method(:route_decision)
34
+ system_prompt: SYSTEM_PROMPT,
35
+ **options
26
36
  )
27
37
  end
28
38
 
29
- # Determine which robot should handle the request
30
- #
31
- # @param result [RobotLab::RobotResult] Classification result
32
- # @param robot [RobotLab::Robot] This robot
33
- # @param network [RobotLab::Network] Parent network
34
- # @return [Array<String>, nil] Robot names to route to
35
- #
36
- def self.route_decision(result:, robot:, network:)
37
- # Extract classification from the result
38
- output = result.output.last&.content.to_s.downcase
39
-
40
- # Route based on classification
41
- # Example routing logic:
42
- # case output
43
- # when /billing|payment|invoice/
44
- # ["billing_robot"]
45
- # when /technical|bug|error/
46
- # ["technical_robot"]
47
- # else
48
- # ["general_robot"]
49
- # end
50
-
51
- nil # Return nil to end the network run
39
+ # Override call to inspect classification output and activate optional tasks.
40
+ def call(result)
41
+ context = extract_run_context(result)
42
+ message = context.delete(:message)
43
+
44
+ robot_result = run(message, **context)
45
+
46
+ new_result = result
47
+ .with_context(@name.to_sym, robot_result)
48
+ .continue(robot_result)
49
+
50
+ category = robot_result.last_text_content.to_s.strip.downcase
51
+
52
+ # Route based on classification — customize these patterns
53
+ case category
54
+ when /billing/ then new_result.activate(:billing)
55
+ when /technical/ then new_result.activate(:technical)
56
+ else new_result.activate(:general)
57
+ end
52
58
  end
53
59
  end
@@ -162,15 +162,17 @@ module RobotLab
162
162
  end
163
163
 
164
164
  def create_transport
165
+ config = @server.transport.merge(timeout: @server.timeout)
166
+
165
167
  case @server.transport_type
166
168
  when "stdio"
167
- Transports::Stdio.new(@server.transport)
169
+ Transports::Stdio.new(config)
168
170
  when "ws", "websocket"
169
- Transports::WebSocket.new(@server.transport)
171
+ Transports::WebSocket.new(config)
170
172
  when "sse"
171
- Transports::SSE.new(@server.transport)
173
+ Transports::SSE.new(config)
172
174
  when "streamable-http", "http"
173
- Transports::StreamableHTTP.new(@server.transport)
175
+ Transports::StreamableHTTP.new(config)
174
176
  else
175
177
  raise MCPError, "Unsupported transport type: #{@server.transport_type}"
176
178
  end
@@ -24,20 +24,28 @@ module RobotLab
24
24
  # Valid transport types for MCP connections
25
25
  VALID_TRANSPORT_TYPES = %w[stdio sse ws websocket streamable-http http].freeze
26
26
 
27
+ # Default timeout for MCP requests (in seconds)
28
+ DEFAULT_TIMEOUT = 15
29
+
27
30
  # @!attribute [r] name
28
31
  # @return [String] the server name
29
32
  # @!attribute [r] transport
30
33
  # @return [Hash] the transport configuration
31
- attr_reader :name, :transport
34
+ # @!attribute [r] timeout
35
+ # @return [Numeric] request timeout in seconds
36
+ attr_reader :name, :transport, :timeout
32
37
 
33
38
  # Creates a new Server configuration.
34
39
  #
35
40
  # @param name [String] the server name
36
41
  # @param transport [Hash] the transport configuration
42
+ # @param timeout [Numeric, nil] request timeout in seconds (default: 15)
43
+ # @param _extra [Hash] additional keys are silently ignored for forward compatibility
37
44
  # @raise [ArgumentError] if transport type is invalid or required fields are missing
38
- def initialize(name:, transport:)
45
+ def initialize(name:, transport:, timeout: nil, **_extra)
39
46
  @name = name.to_s
40
47
  @transport = normalize_transport(transport)
48
+ @timeout = normalize_timeout(timeout)
41
49
  validate!
42
50
  end
43
51
 
@@ -54,7 +62,8 @@ module RobotLab
54
62
  def to_h
55
63
  {
56
64
  name: name,
57
- transport: transport
65
+ transport: transport,
66
+ timeout: timeout
58
67
  }
59
68
  end
60
69
 
@@ -79,6 +88,15 @@ module RobotLab
79
88
  raise ArgumentError, "Transport requires :url" unless transport[:url]
80
89
  end
81
90
  end
91
+
92
+ def normalize_timeout(value)
93
+ return DEFAULT_TIMEOUT if value.nil?
94
+
95
+ seconds = value.to_f
96
+ # If the caller passed milliseconds (>= 1000), convert to seconds
97
+ seconds = seconds / 1000.0 if seconds >= 1000
98
+ [seconds, 1].max
99
+ end
82
100
  end
83
101
  end
84
102
  end
@@ -8,15 +8,23 @@ module RobotLab
8
8
  # @abstract Subclass and implement {#connect}, {#send_request}, {#close}
9
9
  #
10
10
  class Base
11
+ # Default timeout for request operations (in seconds)
12
+ DEFAULT_TIMEOUT = 15
13
+
11
14
  # @!attribute [r] config
12
15
  # @return [Hash] the transport configuration
13
- attr_reader :config
16
+ # @!attribute [r] timeout
17
+ # @return [Numeric] request timeout in seconds
18
+ attr_reader :config, :timeout
14
19
 
15
20
  # Creates a new transport instance.
16
21
  #
17
- # @param config [Hash] transport configuration options
22
+ # @param config [Hash] transport configuration options.
23
+ # May include a :timeout key (in seconds) that controls how long
24
+ # blocking operations wait before raising an error.
18
25
  def initialize(config)
19
26
  @config = config.transform_keys(&:to_sym)
27
+ @timeout = @config.delete(:timeout) || DEFAULT_TIMEOUT
20
28
  end
21
29
 
22
30
  # Connect to the server
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "json"
5
+ require "timeout"
5
6
 
6
7
  module RobotLab
7
8
  module MCP
@@ -9,12 +10,13 @@ module RobotLab
9
10
  # StdIO transport for local MCP servers
10
11
  #
11
12
  # Spawns a subprocess and communicates via stdin/stdout.
13
+ # All blocking I/O is wrapped with a configurable timeout
14
+ # so a missing or hung server cannot block the caller forever.
12
15
  #
13
16
  # @example
14
17
  # transport = Stdio.new(
15
- # command: "mcp-server-filesystem",
16
- # args: ["--root", "/data"],
17
- # env: { "DEBUG" => "true" }
18
+ # { command: "mcp-server-filesystem", args: ["--root", "/data"] },
19
+ # timeout: 10
18
20
  # )
19
21
  #
20
22
  class Stdio < Base
@@ -24,6 +26,7 @@ module RobotLab
24
26
  # @option config [String] :command the command to execute
25
27
  # @option config [Array<String>] :args command arguments
26
28
  # @option config [Hash] :env environment variables
29
+ # @option config [Numeric] :timeout request timeout in seconds (default: 15)
27
30
  def initialize(config)
28
31
  super
29
32
  @stdin = nil
@@ -36,7 +39,8 @@ module RobotLab
36
39
  # Connect to the MCP server via stdio.
37
40
  #
38
41
  # @return [self]
39
- # @raise [MCPError] if connection fails
42
+ # @raise [MCPError] if the server process cannot be started or does not
43
+ # respond to the MCP initialize handshake within the timeout period
40
44
  def connect
41
45
  return self if @connected
42
46
 
@@ -44,44 +48,59 @@ module RobotLab
44
48
  args = @config[:args] || []
45
49
  env = @config[:env] || {}
46
50
 
47
- # Merge with current environment
48
51
  full_env = ENV.to_h.merge(env.transform_keys(&:to_s))
49
52
 
50
53
  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(full_env, command, *args)
54
+
55
+ # Verify the process actually started
56
+ unless @wait_thread.alive?
57
+ raise MCPError, "MCP server process exited immediately (command: #{command})"
58
+ end
59
+
51
60
  @connected = true
52
61
 
53
- # Initialize MCP protocol
62
+ # Initialize MCP protocol — this must complete within the timeout
54
63
  send_initialize
55
64
 
56
65
  self
66
+ rescue Errno::ENOENT => e
67
+ cleanup_process
68
+ raise MCPError, "MCP server command not found: #{command} (#{e.message})"
69
+ rescue Timeout::Error
70
+ cleanup_process
71
+ raise MCPError, "MCP server '#{command}' did not respond within #{@timeout}s"
57
72
  end
58
73
 
59
74
  # Send a JSON-RPC request to the MCP server.
60
75
  #
61
76
  # @param message [Hash] JSON-RPC message
62
77
  # @return [Hash] the response
63
- # @raise [MCPError] if not connected or no response
78
+ # @raise [MCPError] if not connected, no response, or timeout
64
79
  def send_request(message)
65
80
  raise MCPError, "Not connected" unless @connected
66
81
 
67
- # Write JSON-RPC message
68
- json = message.to_json
69
- @stdin.puts(json)
70
- @stdin.flush
82
+ Timeout.timeout(@timeout, Timeout::Error) do
83
+ json = message.to_json
84
+ @stdin.puts(json)
85
+ @stdin.flush
71
86
 
72
- # Read response, skipping notifications
73
- loop do
74
- response_line = @stdout.gets
75
- raise MCPError, "No response from MCP server" unless response_line
87
+ loop do
88
+ response_line = @stdout.gets
89
+ raise MCPError, "No response from MCP server (EOF on stdout)" unless response_line
76
90
 
77
- parsed = JSON.parse(response_line, symbolize_names: true)
91
+ parsed = JSON.parse(response_line, symbolize_names: true)
78
92
 
79
- # Skip notifications (messages without an id)
80
- next if parsed[:method] && !parsed.key?(:id)
93
+ # Skip notifications (messages without an id)
94
+ next if parsed[:method] && !parsed.key?(:id)
81
95
 
82
- # Return responses (messages with an id)
83
- return parsed
96
+ return parsed
97
+ end
84
98
  end
99
+ rescue Timeout::Error
100
+ raise MCPError, "MCP server did not respond within #{@timeout}s"
101
+ rescue Errno::EPIPE, IOError => e
102
+ @connected = false
103
+ raise MCPError, "MCP server connection lost: #{e.message}"
85
104
  end
86
105
 
87
106
  # Close the connection to the MCP server.
@@ -90,12 +109,7 @@ module RobotLab
90
109
  def close
91
110
  return self unless @connected
92
111
 
93
- @stdin&.close
94
- @stdout&.close
95
- @stderr&.close
96
- @wait_thread&.kill if @wait_thread&.alive?
97
-
98
- @connected = false
112
+ cleanup_process
99
113
  self
100
114
  end
101
115
 
@@ -127,6 +141,18 @@ module RobotLab
127
141
  @stdin.puts({ jsonrpc: "2.0", method: "notifications/initialized" }.to_json)
128
142
  @stdin.flush
129
143
  end
144
+
145
+ def cleanup_process
146
+ @connected = false
147
+ @stdin&.close rescue nil
148
+ @stdout&.close rescue nil
149
+ @stderr&.close rescue nil
150
+ @wait_thread&.kill if @wait_thread&.alive?
151
+ @stdin = nil
152
+ @stdout = nil
153
+ @stderr = nil
154
+ @wait_thread = nil
155
+ end
130
156
  end
131
157
  end
132
158
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- module Rails
4
+ module RailsIntegration
5
5
  # Rails Engine for RobotLab integration
6
6
  #
7
7
  # Provides automatic loading of RobotLab components and
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- module Rails
4
+ module RailsIntegration
5
5
  # Railtie for RobotLab Rails integration
6
6
  #
7
7
  # Provides configuration hooks and initialization for
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module RailsIntegration
5
+ # Stateless utility module that builds callback Procs for Turbo Stream broadcasting.
6
+ #
7
+ # Safe to require even without turbo-rails installed — checks at call time
8
+ # via `defined?(Turbo::StreamsChannel)`.
9
+ #
10
+ # @example Wire streaming in a background job
11
+ # stream_name = "robot_lab_thread_#{thread_id}"
12
+ # on_content = RobotLab::RailsIntegration::TurboStreamCallbacks.build_content_callback(
13
+ # stream_name: stream_name
14
+ # )
15
+ # robot = MyRobot.build(on_content: on_content)
16
+ # robot.run(message)
17
+ #
18
+ module TurboStreamCallbacks
19
+ # Check whether Turbo Streams broadcasting is available at runtime.
20
+ #
21
+ # @return [Boolean]
22
+ #
23
+ def self.available?
24
+ defined?(Turbo::StreamsChannel) ? true : false
25
+ end
26
+
27
+ # Build a Proc that broadcasts content chunks via Turbo Streams.
28
+ #
29
+ # The returned Proc receives a chunk object (e.g. RubyLLM::Chunk) and
30
+ # broadcasts `chunk.content` as HTML-escaped text via `broadcast_append_to`.
31
+ #
32
+ # @param stream_name [String] the Turbo Stream channel name
33
+ # @param target [String] the DOM target ID (default: "robot_response")
34
+ # @return [Proc] a callback suitable for `on_content:`
35
+ #
36
+ def self.build_content_callback(stream_name:, target: "robot_response")
37
+ ->(chunk) {
38
+ content = chunk.respond_to?(:content) ? chunk.content : chunk.to_s
39
+ return unless content && TurboStreamCallbacks.available?
40
+
41
+ Turbo::StreamsChannel.broadcast_append_to(
42
+ stream_name,
43
+ target: target,
44
+ html: ERB::Util.html_escape(content)
45
+ )
46
+ }
47
+ end
48
+
49
+ # Build a Proc that broadcasts tool call badges via Turbo Streams.
50
+ #
51
+ # The returned Proc receives a tool_call object and broadcasts
52
+ # a badge showing the tool name.
53
+ #
54
+ # @param stream_name [String] the Turbo Stream channel name
55
+ # @param target [String] the DOM target ID (default: "robot_tools")
56
+ # @return [Proc] a callback suitable for `on_tool_call:`
57
+ #
58
+ def self.build_tool_call_callback(stream_name:, target: "robot_tools")
59
+ ->(tool_call) {
60
+ return unless TurboStreamCallbacks.available?
61
+
62
+ name = tool_call.respond_to?(:name) ? tool_call.name : tool_call.to_s
63
+ Turbo::StreamsChannel.broadcast_append_to(
64
+ stream_name,
65
+ target: target,
66
+ html: "<span class=\"tool-badge\">Using: #{ERB::Util.html_escape(name)}</span>"
67
+ )
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -26,17 +26,22 @@ module RobotLab
26
26
  end
27
27
 
28
28
 
29
- # Ensure MCP clients are initialized for the given server configs
29
+ # Ensure MCP clients are initialized for the given server configs.
30
+ # On subsequent calls, retries any servers that previously failed to connect.
30
31
  def ensure_mcp_clients(mcp_servers)
31
32
  return if mcp_servers.empty?
32
33
 
33
34
  needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
34
- return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
35
35
 
36
- disconnect if @mcp_initialized
36
+ if @mcp_initialized
37
+ # Already initialized — retry any servers that are needed but not connected
38
+ retry_failed_servers(mcp_servers, needed_servers)
39
+ return
40
+ end
37
41
 
38
42
  @mcp_clients = {}
39
43
  @mcp_tools = []
44
+ @failed_mcp_configs = {}
40
45
 
41
46
  mcp_servers.each do |server_config|
42
47
  init_mcp_client(server_config)
@@ -55,8 +60,48 @@ module RobotLab
55
60
  @mcp_clients[server_name] = client
56
61
  discover_mcp_tools(client, server_name)
57
62
  else
63
+ server_name = extract_server_name(server_config)
64
+ @failed_mcp_configs[server_name] = server_config
58
65
  RobotLab.config.logger.warn(
59
- "Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
66
+ "Robot '#{@name}' failed to connect to MCP server: #{server_name}"
67
+ )
68
+ end
69
+ rescue StandardError => e
70
+ server_name = extract_server_name(server_config)
71
+ @failed_mcp_configs[server_name] = server_config
72
+ RobotLab.config.logger.warn(
73
+ "Robot '#{@name}' error connecting to MCP server '#{server_name}': #{e.message}"
74
+ )
75
+ end
76
+
77
+
78
+ # Retry connecting to servers that previously failed
79
+ def retry_failed_servers(mcp_servers, needed_servers)
80
+ return if @failed_mcp_configs.nil? || @failed_mcp_configs.empty?
81
+
82
+ # Only retry servers that are still needed and still failed
83
+ to_retry = @failed_mcp_configs.select { |name, _| needed_servers.include?(name) }
84
+ return if to_retry.empty?
85
+
86
+ to_retry.each do |name, server_config|
87
+ RobotLab.config.logger.info(
88
+ "Robot '#{@name}' retrying MCP server: #{name}"
89
+ )
90
+
91
+ client = MCP::Client.new(server_config)
92
+ client.connect
93
+
94
+ if client.connected?
95
+ @mcp_clients[name] = client
96
+ @failed_mcp_configs.delete(name)
97
+ discover_mcp_tools(client, name)
98
+ RobotLab.config.logger.info(
99
+ "Robot '#{@name}' successfully connected to MCP server '#{name}' on retry"
100
+ )
101
+ end
102
+ rescue StandardError => e
103
+ RobotLab.config.logger.warn(
104
+ "Robot '#{@name}' retry failed for MCP server '#{name}': #{e.message}"
60
105
  )
61
106
  end
62
107
  end
@@ -83,6 +128,18 @@ module RobotLab
83
128
  "Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
84
129
  )
85
130
  end
131
+
132
+
133
+ def extract_server_name(server_config)
134
+ case server_config
135
+ when Hash
136
+ server_config[:name] || server_config['name'] || server_config.to_s
137
+ when MCP::Server
138
+ server_config.name
139
+ else
140
+ server_config.to_s
141
+ end
142
+ end
86
143
  end
87
144
  end
88
145
  end