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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -3
- data/README.md +125 -26
- data/Rakefile +39 -1
- data/docs/api/core/robot.md +284 -8
- data/docs/api/core/tool.md +28 -2
- data/docs/api/mcp/client.md +1 -0
- data/docs/api/mcp/server.md +27 -8
- data/docs/api/mcp/transports.md +21 -6
- data/docs/architecture/core-concepts.md +1 -1
- data/docs/architecture/robot-execution.md +20 -2
- data/docs/concepts.md +11 -3
- data/docs/examples/index.md +1 -1
- data/docs/examples/rails-application.md +1 -1
- data/docs/guides/building-robots.md +245 -11
- data/docs/guides/creating-networks.md +18 -0
- data/docs/guides/mcp-integration.md +74 -2
- data/docs/guides/rails-integration.md +145 -17
- data/docs/guides/using-tools.md +45 -4
- data/docs/index.md +62 -6
- data/examples/05_streaming.rb +113 -94
- data/examples/14_rusty_circuit/.gitignore +1 -0
- data/examples/14_rusty_circuit/open_mic.rb +1 -1
- data/examples/17_skills.rb +242 -0
- data/examples/18_rails/.envrc +3 -0
- data/examples/18_rails/.gitignore +5 -0
- data/examples/18_rails/Gemfile +10 -0
- data/examples/18_rails/README.md +48 -0
- data/examples/18_rails/Rakefile +4 -0
- data/examples/18_rails/app/controllers/application_controller.rb +4 -0
- data/examples/18_rails/app/controllers/chat_controller.rb +46 -0
- data/examples/18_rails/app/jobs/application_job.rb +4 -0
- data/examples/18_rails/app/jobs/robot_run_job.rb +79 -0
- data/examples/18_rails/app/models/application_record.rb +5 -0
- data/examples/18_rails/app/models/robot_lab_result.rb +36 -0
- data/examples/18_rails/app/models/robot_lab_thread.rb +23 -0
- data/examples/18_rails/app/robots/chat_robot.rb +14 -0
- data/examples/18_rails/app/tools/time_tool.rb +9 -0
- data/examples/18_rails/app/views/chat/_user_message.html.erb +1 -0
- data/examples/18_rails/app/views/chat/index.html.erb +67 -0
- data/examples/18_rails/app/views/layouts/application.html.erb +49 -0
- data/examples/18_rails/bin/dev +7 -0
- data/examples/18_rails/bin/rails +6 -0
- data/examples/18_rails/bin/setup +15 -0
- data/examples/18_rails/config/application.rb +33 -0
- data/examples/18_rails/config/cable.yml +2 -0
- data/examples/18_rails/config/database.yml +5 -0
- data/examples/18_rails/config/environment.rb +4 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +3 -0
- data/examples/18_rails/config/routes.rb +6 -0
- data/examples/18_rails/config.ru +4 -0
- data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +32 -0
- data/examples/README.md +30 -0
- data/examples/prompts/audit_trail.md +8 -0
- data/examples/prompts/incident_responder.md +18 -0
- data/examples/prompts/parameterized_main_test.md +6 -0
- data/examples/prompts/pii_redactor.md +7 -0
- data/examples/prompts/runbook_protocol.md +12 -0
- data/examples/prompts/skill_a_test.md +4 -0
- data/examples/prompts/skill_b_test.md +4 -0
- data/examples/prompts/skill_config_test.md +5 -0
- data/examples/prompts/skill_cycle_a_test.md +6 -0
- data/examples/prompts/skill_cycle_b_test.md +6 -0
- data/examples/prompts/skill_description_test.md +4 -0
- data/examples/prompts/skill_leaf_test.md +4 -0
- data/examples/prompts/skill_nested_test.md +6 -0
- data/examples/prompts/skill_refs_main_test.md +6 -0
- data/examples/prompts/skill_self_ref_test.md +6 -0
- data/examples/prompts/skill_with_params_test.md +6 -0
- data/examples/prompts/sre_compliance.md +10 -0
- data/examples/prompts/structured_output.md +7 -0
- data/examples/prompts/template_with_skills_test.md +6 -0
- data/lib/generators/robot_lab/install_generator.rb +12 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +36 -22
- data/lib/generators/robot_lab/templates/job.rb.tt +92 -0
- data/lib/generators/robot_lab/templates/robot.rb.tt +6 -21
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +5 -3
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +41 -35
- data/lib/robot_lab/mcp/client.rb +6 -4
- data/lib/robot_lab/mcp/server.rb +21 -3
- data/lib/robot_lab/mcp/transports/base.rb +10 -2
- data/lib/robot_lab/mcp/transports/stdio.rb +52 -26
- data/lib/robot_lab/{rails → rails_integration}/engine.rb +1 -1
- data/lib/robot_lab/{rails → rails_integration}/railtie.rb +1 -1
- data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +72 -0
- data/lib/robot_lab/robot/mcp_management.rb +61 -4
- data/lib/robot_lab/robot/template_rendering.rb +181 -4
- data/lib/robot_lab/robot.rb +196 -33
- data/lib/robot_lab/robot_result.rb +3 -1
- data/lib/robot_lab/run_config.rb +1 -1
- data/lib/robot_lab/tool.rb +26 -0
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +6 -4
- metadata +55 -19
- 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
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
30
|
+
def self.build(**options)
|
|
31
|
+
new(
|
|
22
32
|
name: "<%= file_name %>",
|
|
23
33
|
description: "<%= robot_description %>",
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
system_prompt: SYSTEM_PROMPT,
|
|
35
|
+
**options
|
|
26
36
|
)
|
|
27
37
|
end
|
|
28
38
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
data/lib/robot_lab/mcp/client.rb
CHANGED
|
@@ -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(
|
|
169
|
+
Transports::Stdio.new(config)
|
|
168
170
|
when "ws", "websocket"
|
|
169
|
-
Transports::WebSocket.new(
|
|
171
|
+
Transports::WebSocket.new(config)
|
|
170
172
|
when "sse"
|
|
171
|
-
Transports::SSE.new(
|
|
173
|
+
Transports::SSE.new(config)
|
|
172
174
|
when "streamable-http", "http"
|
|
173
|
-
Transports::StreamableHTTP.new(
|
|
175
|
+
Transports::StreamableHTTP.new(config)
|
|
174
176
|
else
|
|
175
177
|
raise MCPError, "Unsupported transport type: #{@server.transport_type}"
|
|
176
178
|
end
|
data/lib/robot_lab/mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
Timeout.timeout(@timeout, Timeout::Error) do
|
|
83
|
+
json = message.to_json
|
|
84
|
+
@stdin.puts(json)
|
|
85
|
+
@stdin.flush
|
|
71
86
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
91
|
+
parsed = JSON.parse(response_line, symbolize_names: true)
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
# Skip notifications (messages without an id)
|
|
94
|
+
next if parsed[:method] && !parsed.key?(:id)
|
|
81
95
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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: #{
|
|
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
|