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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +106 -4
- data/Rakefile +2 -1
- data/docs/api/core/robot.md +336 -1
- 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 +4 -0
- data/docs/guides/building-robots.md +18 -0
- data/docs/guides/creating-networks.md +39 -0
- data/docs/guides/index.md +10 -0
- data/docs/guides/knowledge.md +182 -0
- data/docs/guides/mcp-integration.md +180 -2
- data/docs/guides/memory.md +2 -0
- data/docs/guides/observability.md +486 -0
- data/docs/guides/ractor-parallelism.md +364 -0
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
- data/examples/14_rusty_circuit/.gitignore +1 -0
- data/examples/14_rusty_circuit/open_mic.rb +1 -1
- data/examples/19_token_tracking.rb +128 -0
- data/examples/20_circuit_breaker.rb +153 -0
- data/examples/21_learning_loop.rb +164 -0
- data/examples/22_context_compression.rb +179 -0
- data/examples/23_convergence.rb +137 -0
- data/examples/24_structured_delegation.rb +150 -0
- data/examples/25_history_search/conversation.jsonl +30 -0
- data/examples/25_history_search.rb +136 -0
- data/examples/26_document_store/api_versioning_adr.md +52 -0
- data/examples/26_document_store/incident_postmortem.md +46 -0
- data/examples/26_document_store/postgres_runbook.md +49 -0
- data/examples/26_document_store/redis_caching_guide.md +48 -0
- data/examples/26_document_store/sidekiq_guide.md +51 -0
- data/examples/26_document_store.rb +147 -0
- data/examples/27_incident_response/incident_response.rb +244 -0
- data/examples/28_mcp_discovery.rb +112 -0
- data/examples/29_ractor_tools.rb +243 -0
- data/examples/30_ractor_network.rb +256 -0
- data/examples/README.md +136 -0
- data/examples/prompts/skill_with_mcp_test.md +9 -0
- data/examples/prompts/skill_with_robot_name_test.md +5 -0
- data/examples/prompts/skill_with_tools_test.md +6 -0
- data/lib/robot_lab/bus_poller.rb +149 -0
- data/lib/robot_lab/convergence.rb +69 -0
- data/lib/robot_lab/delegation_future.rb +93 -0
- data/lib/robot_lab/document_store.rb +155 -0
- data/lib/robot_lab/error.rb +25 -0
- data/lib/robot_lab/history_compressor.rb +205 -0
- data/lib/robot_lab/mcp/client.rb +23 -9
- data/lib/robot_lab/mcp/connection_poller.rb +187 -0
- data/lib/robot_lab/mcp/server.rb +26 -3
- data/lib/robot_lab/mcp/server_discovery.rb +110 -0
- data/lib/robot_lab/mcp/transports/base.rb +10 -2
- data/lib/robot_lab/mcp/transports/stdio.rb +58 -26
- data/lib/robot_lab/memory.rb +103 -6
- data/lib/robot_lab/network.rb +44 -9
- data/lib/robot_lab/ractor_boundary.rb +42 -0
- data/lib/robot_lab/ractor_job.rb +37 -0
- data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
- data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
- data/lib/robot_lab/ractor_worker_pool.rb +117 -0
- data/lib/robot_lab/robot/bus_messaging.rb +43 -65
- data/lib/robot_lab/robot/history_search.rb +69 -0
- data/lib/robot_lab/robot/mcp_management.rb +61 -4
- data/lib/robot_lab/robot.rb +351 -11
- data/lib/robot_lab/robot_result.rb +26 -5
- data/lib/robot_lab/run_config.rb +1 -1
- data/lib/robot_lab/text_analysis.rb +103 -0
- data/lib/robot_lab/tool.rb +42 -3
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +49 -29
- data/lib/robot_lab.rb +25 -0
- data/mkdocs.yml +1 -0
- 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, @
|
|
9
|
-
# @
|
|
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
|
-
# ==
|
|
12
|
+
# == Delivery Serialization
|
|
13
13
|
#
|
|
14
|
-
# TypedBus delivers messages in concurrent Async fibers.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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|
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|