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
@@ -5,21 +5,16 @@ module RobotLab
5
5
  # Inter-robot communication via TypedBus.
6
6
  #
7
7
  # Expects the including class to provide:
8
- # @bus, @message_counter, @outbox, @message_handler,
9
- # @bus_subscriber_id, @bus_processing, @bus_queue, @name
10
- # and the `run` instance method
8
+ # @bus, @bus_poller, @bus_poller_group, @message_counter,
9
+ # @outbox, @message_handler, @bus_subscriber_id, @name
10
+ # and the `run` instance method.
11
11
  #
12
- # == Processing Guard
12
+ # == Delivery Serialization
13
13
  #
14
- # TypedBus delivers messages in concurrent Async fibers. When a robot's
15
- # +run()+ yields during HTTP I/O, the Async scheduler can switch to
16
- # another fiber delivering a new bus message to the same robot. This
17
- # would interleave user messages between +tool_use+ / +tool_result+
18
- # pairs in +@chat+, corrupting Anthropic API message ordering.
19
- #
20
- # The processing guard serializes delivery handling: deliveries that
21
- # arrive while the robot is already processing are queued and drained
22
- # sequentially after the current one completes.
14
+ # TypedBus delivers messages in concurrent Async fibers. Robots
15
+ # enqueue deliveries into a BusPoller rather than handling them
16
+ # inline. The BusPoller drains each group's queue sequentially on
17
+ # a dedicated OS thread, so robot.run() calls never interleave.
23
18
  #
24
19
  module BusMessaging
25
20
  # Send a message to another robot via the bus.
@@ -84,19 +79,6 @@ module RobotLab
84
79
  # @param options [Hash] additional options passed to RobotLab.build
85
80
  # @return [Robot] the newly created robot
86
81
  #
87
- # @example Spawn from a bus-less robot (bus and name created automatically)
88
- # bot = RobotLab.build
89
- # bot2 = bot.spawn(system_prompt: "You are helpful.")
90
- #
91
- # @example Spawn a specialist from a message handler
92
- # on_message do |message|
93
- # specialist = spawn(
94
- # name: "fact_checker",
95
- # system_prompt: "You verify factual claims. Be concise."
96
- # )
97
- # specialist.send_message(to: name.to_sym, content: specialist.run(message.content).last_text_content)
98
- # end
99
- #
100
82
  def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **options)
101
83
  ensure_bus
102
84
 
@@ -120,12 +102,6 @@ module RobotLab
120
102
  # @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
121
103
  # @return [self]
122
104
  #
123
- # @example Join an existing bus
124
- # bot = RobotLab.build.with_bus(some_bus)
125
- #
126
- # @example Create a bus on demand
127
- # bot = RobotLab.build.with_bus
128
- #
129
105
  def with_bus(bus = nil)
130
106
  return self if bus && @bus == bus
131
107
 
@@ -135,6 +111,22 @@ module RobotLab
135
111
  self
136
112
  end
137
113
 
114
+ # Assign a shared BusPoller from a Network.
115
+ #
116
+ # Stops any private poller this robot auto-created, then adopts
117
+ # the network's shared poller for the given group.
118
+ #
119
+ # @param poller [BusPoller] the network's shared poller
120
+ # @param group [Symbol] poller group for this robot (default: :default)
121
+ # @return [void]
122
+ #
123
+ def assign_bus_poller(poller, group: :default)
124
+ @private_bus_poller&.stop
125
+ @private_bus_poller = nil
126
+ @bus_poller = poller
127
+ @bus_poller_group = group
128
+ end
129
+
138
130
  private
139
131
 
140
132
  # Create a bus if one doesn't exist and connect this robot to it
@@ -143,46 +135,42 @@ module RobotLab
143
135
  end
144
136
 
145
137
 
146
- # Create a typed channel on the bus and subscribe to it
138
+ # Create a typed channel on the bus and subscribe to it.
139
+ # Auto-creates a private BusPoller if none has been assigned.
147
140
  def setup_bus_channel
141
+ unless @bus_poller
142
+ @private_bus_poller = BusPoller.new.start
143
+ @bus_poller = @private_bus_poller
144
+ @bus_poller_group = :default
145
+ end
146
+
148
147
  channel_name = @name.to_sym
149
148
  @bus.add_channel(channel_name, type: RobotMessage) unless @bus.channel?(channel_name)
150
- @bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| handle_incoming_delivery(delivery) }
149
+ @bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| enqueue_delivery(delivery) }
151
150
  end
152
151
 
153
152
 
154
- # Unsubscribe from the bus channel
153
+ # Unsubscribe from the bus channel and stop the private poller if any.
155
154
  def teardown_bus_channel
156
155
  channel_name = @name.to_sym
157
156
  @bus.unsubscribe(channel_name, @bus_subscriber_id) if @bus_subscriber_id
158
157
  @bus_subscriber_id = nil
159
- end
160
158
 
159
+ @private_bus_poller&.stop
160
+ @private_bus_poller = nil
161
+ @bus_poller = nil
162
+ @bus_poller_group = :default
163
+ end
161
164
 
162
- # Dispatch incoming bus delivery to handler.
163
- #
164
- # Uses a processing guard to serialize delivery handling. When
165
- # the robot is already processing a delivery (e.g., inside a
166
- # run() call that yields during HTTP I/O), new deliveries are
167
- # queued and drained sequentially after the current one completes.
168
- #
169
- # Auto-ack when the handler takes 1 arg (message only);
170
- # manual ack/nack when the handler takes 2 args (delivery, message).
171
- def handle_incoming_delivery(delivery)
172
- if @bus_processing
173
- @bus_queue << delivery
174
- return
175
- end
176
165
 
177
- process_delivery(delivery)
178
- drain_bus_queue
166
+ # Enqueue a delivery to the robot's assigned poller.
167
+ def enqueue_delivery(delivery)
168
+ @bus_poller.enqueue(robot: self, delivery: delivery, group: @bus_poller_group)
179
169
  end
180
170
 
181
171
 
182
- # Process a single delivery (called under the processing guard)
172
+ # Process a single delivery (called by BusPoller drain thread).
183
173
  def process_delivery(delivery)
184
- @bus_processing = true
185
-
186
174
  message = delivery.message
187
175
 
188
176
  # Correlate replies with outbox entries
@@ -205,16 +193,6 @@ module RobotLab
205
193
  rescue => e
206
194
  delivery.nack! if delivery.pending?
207
195
  raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
208
- ensure
209
- @bus_processing = false
210
- end
211
-
212
-
213
- # Drain queued deliveries sequentially
214
- def drain_bus_queue
215
- while (queued = @bus_queue.shift)
216
- process_delivery(queued)
217
- end
218
196
  end
219
197
 
220
198
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot
5
+ # Semantic search over a robot's conversation history.
6
+ #
7
+ # Scores each message in @chat.messages against the query using stemmed
8
+ # term-frequency cosine similarity (via the +classifier+ gem). Returns the
9
+ # top-N messages ranked by relevance.
10
+ #
11
+ # Requires the optional 'classifier' gem (~> 2.3).
12
+ #
13
+ # @example
14
+ # results = robot.search_history("quarterly revenue", limit: 3)
15
+ # results.each do |r|
16
+ # puts "[#{r.role}] (score #{r.score.round(3)}) #{r.text}"
17
+ # end
18
+ module HistorySearch
19
+ # Minimum text length (characters) for a message to be considered.
20
+ MIN_SCORE_LENGTH = 20
21
+
22
+ # Value object returned by {#search_history}.
23
+ HistoryResult = Data.define(:text, :role, :score, :index)
24
+
25
+ # Search the robot's conversation history for messages relevant to +query+.
26
+ #
27
+ # @param query [String] natural-language search query
28
+ # @param limit [Integer] maximum number of results to return (default 5)
29
+ # @return [Array<HistoryResult>] results sorted by score descending
30
+ # @raise [RobotLab::DependencyError] if the 'classifier' gem is not installed
31
+ def search_history(query, limit: 5)
32
+ TextAnalysis.require_classifier!
33
+
34
+ query_vec = TextAnalysis.l2_normalize(query.word_hash)
35
+ return [] if query_vec.empty?
36
+
37
+ results = []
38
+
39
+ @chat.messages.each_with_index do |msg, idx|
40
+ text = extract_history_text(msg)
41
+ next if text.nil? || text.strip.length < MIN_SCORE_LENGTH
42
+
43
+ vec = TextAnalysis.l2_normalize(text.word_hash)
44
+ score = TextAnalysis.cosine_similarity(query_vec, vec)
45
+ next if score.zero?
46
+
47
+ results << HistoryResult.new(text: text, role: msg.role, score: score, index: idx)
48
+ end
49
+
50
+ results.sort_by { |r| -r.score }.first(limit)
51
+ end
52
+
53
+ private
54
+
55
+ # Extract plain text from a message's content field.
56
+ #
57
+ # @param msg [Object] a RubyLLM message-like object
58
+ # @return [String, nil]
59
+ def extract_history_text(msg)
60
+ content = msg.content
61
+ case content
62
+ when String then content
63
+ when Array then content.filter_map { |p| p[:text] || p["text"] }.join(" ")
64
+ else nil
65
+ end
66
+ end
67
+ end
68
+ end
69
+ 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