robot_lab 0.0.8 → 0.0.11

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +106 -4
  4. data/Rakefile +2 -1
  5. data/docs/api/core/robot.md +336 -1
  6. data/docs/api/mcp/client.md +1 -0
  7. data/docs/api/mcp/server.md +27 -8
  8. data/docs/api/mcp/transports.md +21 -6
  9. data/docs/architecture/core-concepts.md +1 -1
  10. data/docs/architecture/robot-execution.md +20 -2
  11. data/docs/concepts.md +4 -0
  12. data/docs/guides/building-robots.md +18 -0
  13. data/docs/guides/creating-networks.md +39 -0
  14. data/docs/guides/index.md +10 -0
  15. data/docs/guides/knowledge.md +182 -0
  16. data/docs/guides/mcp-integration.md +180 -2
  17. data/docs/guides/memory.md +2 -0
  18. data/docs/guides/observability.md +486 -0
  19. data/docs/guides/ractor-parallelism.md +364 -0
  20. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  21. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  22. data/examples/14_rusty_circuit/.gitignore +1 -0
  23. data/examples/14_rusty_circuit/open_mic.rb +1 -1
  24. data/examples/19_token_tracking.rb +128 -0
  25. data/examples/20_circuit_breaker.rb +153 -0
  26. data/examples/21_learning_loop.rb +164 -0
  27. data/examples/22_context_compression.rb +179 -0
  28. data/examples/23_convergence.rb +137 -0
  29. data/examples/24_structured_delegation.rb +150 -0
  30. data/examples/25_history_search/conversation.jsonl +30 -0
  31. data/examples/25_history_search.rb +136 -0
  32. data/examples/26_document_store/api_versioning_adr.md +52 -0
  33. data/examples/26_document_store/incident_postmortem.md +46 -0
  34. data/examples/26_document_store/postgres_runbook.md +49 -0
  35. data/examples/26_document_store/redis_caching_guide.md +48 -0
  36. data/examples/26_document_store/sidekiq_guide.md +51 -0
  37. data/examples/26_document_store.rb +147 -0
  38. data/examples/27_incident_response/incident_response.rb +244 -0
  39. data/examples/28_mcp_discovery.rb +112 -0
  40. data/examples/29_ractor_tools.rb +243 -0
  41. data/examples/30_ractor_network.rb +256 -0
  42. data/examples/README.md +136 -0
  43. data/examples/prompts/skill_with_mcp_test.md +9 -0
  44. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  45. data/examples/prompts/skill_with_tools_test.md +6 -0
  46. data/lib/robot_lab/bus_poller.rb +149 -0
  47. data/lib/robot_lab/convergence.rb +69 -0
  48. data/lib/robot_lab/delegation_future.rb +93 -0
  49. data/lib/robot_lab/document_store.rb +155 -0
  50. data/lib/robot_lab/error.rb +25 -0
  51. data/lib/robot_lab/history_compressor.rb +205 -0
  52. data/lib/robot_lab/mcp/client.rb +23 -9
  53. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  54. data/lib/robot_lab/mcp/server.rb +26 -3
  55. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  56. data/lib/robot_lab/mcp/transports/base.rb +10 -2
  57. data/lib/robot_lab/mcp/transports/stdio.rb +58 -26
  58. data/lib/robot_lab/memory.rb +103 -6
  59. data/lib/robot_lab/network.rb +44 -9
  60. data/lib/robot_lab/ractor_boundary.rb +42 -0
  61. data/lib/robot_lab/ractor_job.rb +37 -0
  62. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  63. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  64. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  65. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  66. data/lib/robot_lab/robot/history_search.rb +69 -0
  67. data/lib/robot_lab/robot/mcp_management.rb +61 -4
  68. data/lib/robot_lab/robot.rb +351 -11
  69. data/lib/robot_lab/robot_result.rb +26 -5
  70. data/lib/robot_lab/run_config.rb +1 -1
  71. data/lib/robot_lab/text_analysis.rb +103 -0
  72. data/lib/robot_lab/tool.rb +42 -3
  73. data/lib/robot_lab/tool_config.rb +1 -1
  74. data/lib/robot_lab/version.rb +1 -1
  75. data/lib/robot_lab/waiter.rb +49 -29
  76. data/lib/robot_lab.rb +25 -0
  77. data/mkdocs.yml +1 -0
  78. metadata +71 -2
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # A promise-like object returned by robot.delegate(async: true).
5
+ #
6
+ # The delegated task runs in a background thread. The caller can check
7
+ # whether the result is ready with +resolved?+ and block until it arrives
8
+ # with +value+ (or its alias +wait+).
9
+ #
10
+ # @example Fan-out to two specialists in parallel
11
+ # f1 = manager.delegate(to: summarizer, task: "summarize ...", async: true)
12
+ # f2 = manager.delegate(to: analyst, task: "analyze ...", async: true)
13
+ #
14
+ # # Other work here while both run in parallel
15
+ #
16
+ # summary = f1.value # blocks if not yet done
17
+ # analysis = f2.value
18
+ #
19
+ # @example With a timeout
20
+ # result = future.value(timeout: 10) # raises DelegationTimeout if too slow
21
+ #
22
+ class DelegationFuture
23
+ # Raised when +value(timeout: N)+ expires before the result arrives.
24
+ class DelegationTimeout < Error; end
25
+
26
+ # @return [String] name of the robot that was delegated to
27
+ attr_reader :robot_name
28
+
29
+ # @return [String] name of the robot that created this future
30
+ attr_reader :delegated_by
31
+
32
+ # @param robot_name [String]
33
+ # @param delegated_by [String]
34
+ def initialize(robot_name:, delegated_by:)
35
+ @robot_name = robot_name
36
+ @delegated_by = delegated_by
37
+ @mutex = Mutex.new
38
+ @cv = ConditionVariable.new
39
+ @result = nil
40
+ @error = nil
41
+ @resolved = false
42
+ end
43
+
44
+ # True once the delegated task has completed (successfully or with an error).
45
+ #
46
+ # @return [Boolean]
47
+ def resolved?
48
+ @mutex.synchronize { @resolved }
49
+ end
50
+
51
+ # Block until the result is available and return it.
52
+ #
53
+ # @param timeout [Numeric, nil] maximum seconds to wait; nil means wait forever
54
+ # @return [RobotResult]
55
+ # @raise [DelegationTimeout] if +timeout+ is given and expires
56
+ # @raise [StandardError] re-raises any error thrown by the delegated task
57
+ def value(timeout: nil)
58
+ @mutex.synchronize do
59
+ unless @resolved
60
+ @cv.wait(@mutex, timeout)
61
+ end
62
+
63
+ unless @resolved
64
+ raise DelegationTimeout,
65
+ "Delegation to '#{@robot_name}' timed out after #{timeout}s"
66
+ end
67
+
68
+ raise @error if @error
69
+
70
+ @result
71
+ end
72
+ end
73
+ alias_method :wait, :value
74
+
75
+ # @api private — called by Robot#delegate from the worker thread
76
+ def resolve!(result)
77
+ @mutex.synchronize do
78
+ @result = result
79
+ @resolved = true
80
+ @cv.broadcast
81
+ end
82
+ end
83
+
84
+ # @api private — called by Robot#delegate from the worker thread on error
85
+ def reject!(error)
86
+ @mutex.synchronize do
87
+ @error = error
88
+ @resolved = true
89
+ @cv.broadcast
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fastembed"
4
+
5
+ module RobotLab
6
+ # Embedding-based document store for semantic search over arbitrary text.
7
+ #
8
+ # Documents are embedded using {https://github.com/khasinski/fastembed-rb fastembed}
9
+ # (BAAI/bge-small-en-v1.5 by default) and stored in memory. Queries are
10
+ # embedded the same way, then compared by cosine similarity to find the
11
+ # closest documents.
12
+ #
13
+ # The embedding model is initialised lazily on first use — the ONNX model
14
+ # file is downloaded on that first call (cached locally afterwards).
15
+ #
16
+ # @example
17
+ # store = RobotLab::DocumentStore.new
18
+ # store.store(:q4_report, "Q4 revenue came in at $4.2M, up 18% YoY…")
19
+ # store.store(:q3_report, "Q3 showed 15% growth, driven by APAC…")
20
+ #
21
+ # results = store.search("revenue growth", limit: 2)
22
+ # results.each { |r| puts "#{r[:key]} (#{r[:score].round(3)}): #{r[:text][0..60]}" }
23
+ #
24
+ # @example Via Memory
25
+ # memory.store_document(:readme, File.read("README.md"))
26
+ # memory.search_documents("how to configure redis", limit: 3)
27
+ #
28
+ class DocumentStore
29
+ # Default embedding model used when none is specified.
30
+ DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
31
+
32
+ # @param model_name [String] fastembed model name (default: BAAI/bge-small-en-v1.5)
33
+ def initialize(model_name: DEFAULT_MODEL)
34
+ @model_name = model_name
35
+ @documents = {} # key (Symbol) => { text: String, vector: Array<Float> }
36
+ @mutex = Mutex.new
37
+ @model = nil # lazy: initialised on first embed call
38
+ end
39
+
40
+ # Embed +text+ and store it under +key+.
41
+ #
42
+ # If a document already exists under +key+ it is replaced.
43
+ #
44
+ # @param key [Symbol, String] identifier for this document
45
+ # @param text [String] the document text to embed and store
46
+ # @return [self]
47
+ def store(key, text)
48
+ key = key.to_sym
49
+ vector = passage_vector(text)
50
+ @mutex.synchronize { @documents[key] = { text: text, vector: vector } }
51
+ self
52
+ end
53
+
54
+ # Search for documents semantically similar to +query+.
55
+ #
56
+ # @param query [String] natural-language search query
57
+ # @param limit [Integer] maximum number of results (default 5)
58
+ # @return [Array<Hash>] results sorted by score descending.
59
+ # Each hash contains +:key+, +:text+, and +:score+ (Float 0.0..1.0).
60
+ def search(query, limit: 5)
61
+ return [] if empty?
62
+
63
+ query_vec = query_vector(query)
64
+ results = []
65
+
66
+ @mutex.synchronize do
67
+ @documents.each do |key, doc|
68
+ score = cosine_similarity(query_vec, doc[:vector])
69
+ results << { key: key, text: doc[:text], score: score }
70
+ end
71
+ end
72
+
73
+ results.sort_by { |r| -r[:score] }.first(limit)
74
+ end
75
+
76
+ # Number of stored documents.
77
+ #
78
+ # @return [Integer]
79
+ def size
80
+ @mutex.synchronize { @documents.size }
81
+ end
82
+
83
+ # Keys of all stored documents.
84
+ #
85
+ # @return [Array<Symbol>]
86
+ def keys
87
+ @mutex.synchronize { @documents.keys }
88
+ end
89
+
90
+ # Whether the store contains no documents.
91
+ #
92
+ # @return [Boolean]
93
+ def empty?
94
+ @mutex.synchronize { @documents.empty? }
95
+ end
96
+
97
+ # Remove the document stored under +key+.
98
+ #
99
+ # @param key [Symbol, String]
100
+ # @return [self]
101
+ def delete(key)
102
+ @mutex.synchronize { @documents.delete(key.to_sym) }
103
+ self
104
+ end
105
+
106
+ # Remove all stored documents.
107
+ #
108
+ # @return [self]
109
+ def clear
110
+ @mutex.synchronize { @documents.clear }
111
+ self
112
+ end
113
+
114
+ private
115
+
116
+ # Return (or lazily create) the fastembed model instance.
117
+ def model
118
+ @model ||= Fastembed::TextEmbedding.new(model_name: @model_name, show_progress: false)
119
+ end
120
+
121
+ # Embed a single passage string.
122
+ def passage_vector(text)
123
+ model.passage_embed([text]).to_a.first
124
+ end
125
+
126
+ # Embed a single query string.
127
+ def query_vector(text)
128
+ model.query_embed([text]).to_a.first
129
+ end
130
+
131
+ # Cosine similarity between two dense float vectors.
132
+ #
133
+ # Returns 0.0 for nil, empty, or mismatched-length vectors.
134
+ def cosine_similarity(vec_a, vec_b)
135
+ return 0.0 unless vec_a && vec_b
136
+ return 0.0 if vec_a.empty? || vec_b.empty?
137
+ return 0.0 if vec_a.length != vec_b.length
138
+
139
+ dot = 0.0
140
+ norm_a = 0.0
141
+ norm_b = 0.0
142
+
143
+ vec_a.each_with_index do |a, i|
144
+ b = vec_b[i]
145
+ dot += a * b
146
+ norm_a += a * a
147
+ norm_b += b * b
148
+ end
149
+
150
+ return 0.0 if norm_a.zero? || norm_b.zero?
151
+
152
+ dot / (Math.sqrt(norm_a) * Math.sqrt(norm_b))
153
+ end
154
+ end
155
+ end
@@ -35,4 +35,29 @@ module RobotLab
35
35
  # @example
36
36
  # raise BusError, "No bus configured on this robot"
37
37
  class BusError < Error; end
38
+
39
+ # Raised when a robot's tool call loop exceeds the configured limit.
40
+ #
41
+ # @example
42
+ # raise ToolLoopError, "Circuit breaker: 26 tool calls exceeded max_tool_rounds (25)"
43
+ class ToolLoopError < InferenceError; end
44
+
45
+ # Raised when a required optional gem dependency is not installed.
46
+ #
47
+ # @example
48
+ # raise DependencyError, "Add gem 'classifier', '~> 2.3' to your Gemfile"
49
+ class DependencyError < ConfigurationError; end
50
+
51
+ # Raised when a value cannot be made Ractor-shareable before crossing
52
+ # a Ractor boundary (e.g., a live IO, Proc, or object with mutable state).
53
+ #
54
+ # @example
55
+ # raise RactorBoundaryError, "Cannot freeze IO object"
56
+ class RactorBoundaryError < Error; end
57
+
58
+ # Raised when a tool fails during execution, including inside a Ractor worker.
59
+ #
60
+ # @example
61
+ # raise ToolError, "Tool 'MyTool' failed: division by zero"
62
+ class ToolError < Error; end
38
63
  end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Compresses a robot's conversation history using TF-IDF relevance scoring.
5
+ #
6
+ # Old conversation turns are tiered against the most recent context:
7
+ # - High relevance (score >= keep_threshold) → kept verbatim
8
+ # - Medium relevance (drop_threshold..keep_threshold) → summarized or dropped
9
+ # - Low relevance (score < drop_threshold) → dropped
10
+ #
11
+ # System messages and tool call/result messages are always preserved.
12
+ # The most recent +recent_turns+ user+assistant pairs are also always kept.
13
+ #
14
+ # Requires the optional 'classifier' gem (~> 2.3).
15
+ #
16
+ # @example Basic usage from Robot
17
+ # robot.compress_history(recent_turns: 3, keep_threshold: 0.6, drop_threshold: 0.2)
18
+ #
19
+ # @example With LLM summarizer (separate robot)
20
+ # summarizer_robot = RobotLab.build(name: "summarizer", system_prompt: "Summarize concisely.")
21
+ # robot.compress_history(
22
+ # summarizer: ->(text) { summarizer_robot.run("One sentence: #{text}").reply }
23
+ # )
24
+ class HistoryCompressor
25
+ # Minimum text length (characters) to score; shorter messages are kept as-is.
26
+ MIN_SCORE_LENGTH = 20
27
+
28
+ # Minimal duck-type for a summary replacement message compatible with
29
+ # RubyLLM::Chat's message array. Preserves the original message role so
30
+ # user/assistant turn ordering is maintained after compression.
31
+ SUMMARY_STRUCT = Struct.new(:role, :content, :tool_calls, :stop_reason) do
32
+ def text? = true
33
+ def tool_use? = false
34
+ def system? = role == :system
35
+ def user? = role == :user
36
+ def assistant? = role == :assistant
37
+ end
38
+
39
+ # @param messages [Array] full @chat.messages array
40
+ # @param recent_turns [Integer] number of user+assistant turn pairs to protect
41
+ # @param keep_threshold [Float] score >= this → keep verbatim
42
+ # @param drop_threshold [Float] score < this → drop
43
+ # @param summarizer [#call, nil] callable(text) -> String for medium-tier;
44
+ # nil means drop medium-tier
45
+ # @raise [ArgumentError] if keep_threshold <= drop_threshold
46
+ def initialize(messages:, recent_turns:, keep_threshold:, drop_threshold:, summarizer:)
47
+ if keep_threshold <= drop_threshold
48
+ raise ArgumentError,
49
+ "keep_threshold (#{keep_threshold}) must be greater than drop_threshold (#{drop_threshold})"
50
+ end
51
+
52
+ @messages = messages
53
+ @recent_turns = recent_turns
54
+ @keep_threshold = keep_threshold
55
+ @drop_threshold = drop_threshold
56
+ @summarizer = summarizer
57
+ end
58
+
59
+ # Execute compression and return the new message array.
60
+ #
61
+ # @return [Array] compressed message array
62
+ def call
63
+ return @messages if @messages.empty?
64
+
65
+ # Classify each message index as pinned (always keep) or scorable
66
+ pinned_indices = []
67
+ scorable_indices = []
68
+
69
+ @messages.each_with_index do |msg, idx|
70
+ if pinned_message?(msg)
71
+ pinned_indices << idx
72
+ else
73
+ scorable_indices << idx
74
+ end
75
+ end
76
+
77
+ # Nothing scorable, or everything fits inside the recent window: return as-is
78
+ return @messages if scorable_indices.empty?
79
+ return @messages if scorable_indices.size <= @recent_turns * 2
80
+
81
+ recent_count = @recent_turns * 2
82
+ compressible = scorable_indices[0..-(recent_count + 1)]
83
+ recent = scorable_indices[-recent_count..]
84
+
85
+ return @messages if compressible.nil? || compressible.empty?
86
+
87
+ # Build reference vector from the recent window using stemmed term frequencies.
88
+ # Term frequencies (no IDF) are used because IDF on a topic-focused corpus
89
+ # would suppress the very terms that indicate relevance to that topic.
90
+ recent_texts = recent.filter_map { |i| extract_text(@messages[i]) }
91
+ .reject { |t| t.strip.length < MIN_SCORE_LENGTH }
92
+
93
+ # No meaningful recent text → cannot score; return unchanged
94
+ return @messages if recent_texts.empty?
95
+
96
+ TextAnalysis.require_classifier!
97
+
98
+ recent_vectors = recent_texts.map { |t| TextAnalysis.l2_normalize(t.word_hash) }
99
+ reference = mean_vector(recent_vectors)
100
+
101
+ # Decide action for each compressible message
102
+ actions = {}
103
+ compressible.each do |idx|
104
+ actions[idx] = score_action(reference, @messages[idx])
105
+ end
106
+
107
+ # Reconstruct the message array in original order
108
+ build_result(actions)
109
+ end
110
+
111
+ private
112
+
113
+ # Determine the action for one compressible message.
114
+ #
115
+ # @return [Symbol, String] :keep, :drop, or a summary String
116
+ def score_action(reference, msg)
117
+ text = extract_text(msg)
118
+
119
+ # Too short to score reliably → keep
120
+ return :keep if text.nil? || text.strip.length < MIN_SCORE_LENGTH
121
+
122
+ vec = TextAnalysis.l2_normalize(text.word_hash)
123
+ score = TextAnalysis.cosine_similarity(vec, reference)
124
+
125
+ if score >= @keep_threshold
126
+ :keep
127
+ elsif score < @drop_threshold
128
+ :drop
129
+ elsif @summarizer
130
+ summary = @summarizer.call(text).to_s.strip
131
+ summary.empty? ? :drop : summary
132
+ else
133
+ :drop
134
+ end
135
+ end
136
+
137
+ # Build the final message array from the decided actions.
138
+ def build_result(actions)
139
+ result = []
140
+
141
+ @messages.each_with_index do |msg, idx|
142
+ action = actions[idx]
143
+
144
+ if action.nil?
145
+ # Pinned or recent: always include
146
+ result << msg
147
+ elsif action == :keep
148
+ result << msg
149
+ elsif action == :drop
150
+ # Omit entirely
151
+ else
152
+ # action is a summary String; preserve original role to keep turn ordering
153
+ result << SUMMARY_STRUCT.new(msg.role, action, nil, :stop)
154
+ end
155
+ end
156
+
157
+ result
158
+ end
159
+
160
+ # True if a message should never be scored or removed.
161
+ #
162
+ # System messages and tool-related messages are always pinned.
163
+ # Assistant messages with no text content (tool call dispatchers)
164
+ # are also pinned to avoid breaking tool_use/tool_result pairing.
165
+ def pinned_message?(msg)
166
+ role = msg.role
167
+
168
+ return true if role == :system
169
+ return true if role == :tool || role == :tool_result
170
+
171
+ # Assistant tool-call dispatcher: content is nil or blank
172
+ if role == :assistant
173
+ text = extract_text(msg)
174
+ return true if text.nil? || text.strip.empty?
175
+ end
176
+
177
+ false
178
+ end
179
+
180
+ # Extract plain text from a message's content field.
181
+ #
182
+ # @return [String, nil]
183
+ def extract_text(msg)
184
+ content = msg.content
185
+ case content
186
+ when String then content
187
+ when Array then content.filter_map { |p| p[:text] || p["text"] }.join(" ")
188
+ else nil
189
+ end
190
+ end
191
+
192
+ # Element-wise mean of a list of sparse vectors.
193
+ #
194
+ # @param vectors [Array<Hash{Symbol => Float}>]
195
+ # @return [Hash{Symbol => Float}]
196
+ def mean_vector(vectors)
197
+ return {} if vectors.empty?
198
+
199
+ sum = Hash.new(0.0)
200
+ vectors.each { |v| v.each { |k, val| sum[k] += val } }
201
+ n = vectors.size.to_f
202
+ sum.transform_values { |v| v / n }
203
+ end
204
+ end
205
+ end
@@ -18,13 +18,15 @@ module RobotLab
18
18
  # @return [Server] the MCP server configuration
19
19
  # @!attribute [r] connected
20
20
  # @return [Boolean] whether currently connected
21
- attr_reader :server, :connected
21
+ attr_reader :server, :connected, :transport
22
22
 
23
23
  # Creates a new MCP Client instance.
24
24
  #
25
25
  # @param server_or_config [Server, Hash] the server or configuration hash
26
+ # @param poller [MCP::ConnectionPoller, nil] optional shared IO.select poller
27
+ # for multiplexing multiple stdio transports (opt-in, default nil = per-client blocking)
26
28
  # @raise [ArgumentError] if config is invalid
27
- def initialize(server_or_config)
29
+ def initialize(server_or_config, poller: nil)
28
30
  @server = case server_or_config
29
31
  when Server
30
32
  server_or_config
@@ -33,9 +35,10 @@ module RobotLab
33
35
  else
34
36
  raise ArgumentError, "Invalid server config"
35
37
  end
36
- @connected = false
37
- @transport = nil
38
+ @connected = false
39
+ @transport = nil
38
40
  @request_id = 0
41
+ @poller = poller
39
42
  end
40
43
 
41
44
  # Connect to the MCP server
@@ -49,6 +52,9 @@ module RobotLab
49
52
  @transport.connect if @transport.respond_to?(:connect)
50
53
  @connected = true
51
54
 
55
+ # Register with shared poller after the transport is connected
56
+ @poller.register(self) if @poller
57
+
52
58
  self
53
59
  rescue StandardError => e
54
60
  RobotLab.config.logger.warn("MCP connection failed for #{@server.name}: #{e.message}")
@@ -63,6 +69,7 @@ module RobotLab
63
69
  def disconnect
64
70
  return self unless @connected
65
71
 
72
+ @poller.unregister(self) if @poller
66
73
  @transport.close if @transport.respond_to?(:close)
67
74
  @connected = false
68
75
  @transport = nil
@@ -162,15 +169,17 @@ module RobotLab
162
169
  end
163
170
 
164
171
  def create_transport
172
+ config = @server.transport.merge(timeout: @server.timeout)
173
+
165
174
  case @server.transport_type
166
175
  when "stdio"
167
- Transports::Stdio.new(@server.transport)
176
+ Transports::Stdio.new(config)
168
177
  when "ws", "websocket"
169
- Transports::WebSocket.new(@server.transport)
178
+ Transports::WebSocket.new(config)
170
179
  when "sse"
171
- Transports::SSE.new(@server.transport)
180
+ Transports::SSE.new(config)
172
181
  when "streamable-http", "http"
173
- Transports::StreamableHTTP.new(@server.transport)
182
+ Transports::StreamableHTTP.new(config)
174
183
  else
175
184
  raise MCPError, "Unsupported transport type: #{@server.transport_type}"
176
185
  end
@@ -186,7 +195,12 @@ module RobotLab
186
195
  }
187
196
  message[:params] = params if params
188
197
 
189
- response = @transport.send_request(message)
198
+ response = if @poller && @transport.is_a?(Transports::Stdio)
199
+ @poller.send_request(self, message, timeout: @server.timeout)
200
+ else
201
+ @transport.send_request(message)
202
+ end
203
+
190
204
  parse_response(response)
191
205
  end
192
206