robot_lab 0.0.9 → 0.0.12
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 +53 -0
- data/README.md +210 -1
- data/Rakefile +2 -1
- data/docs/api/core/result.md +123 -0
- data/docs/api/core/robot.md +182 -0
- data/docs/api/errors.md +185 -0
- data/docs/guides/building-robots.md +125 -0
- data/docs/guides/creating-networks.md +21 -0
- data/docs/guides/index.md +10 -0
- data/docs/guides/knowledge.md +182 -0
- data/docs/guides/mcp-integration.md +106 -0
- 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/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 +17 -5
- data/lib/robot_lab/mcp/connection_poller.rb +187 -0
- data/lib/robot_lab/mcp/server.rb +7 -2
- data/lib/robot_lab/mcp/server_discovery.rb +110 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
- 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.rb +228 -11
- data/lib/robot_lab/robot_result.rb +24 -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 +72 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module MCP
|
|
5
|
+
# Multiplexes I/O across multiple stdio MCP transports.
|
|
6
|
+
#
|
|
7
|
+
# When a robot connects to more than one local (stdio) MCP server,
|
|
8
|
+
# the default approach blocks independently per client, each with its
|
|
9
|
+
# own Timeout.timeout wrapper. ConnectionPoller replaces this with a
|
|
10
|
+
# single IO.select call across all registered stdout file descriptors,
|
|
11
|
+
# dispatching each response to the pending request for that client.
|
|
12
|
+
#
|
|
13
|
+
# Async-based transports (SSE, WebSocket, StreamableHTTP) are
|
|
14
|
+
# unaffected — they already use the Async fiber scheduler.
|
|
15
|
+
#
|
|
16
|
+
# == Usage
|
|
17
|
+
#
|
|
18
|
+
# poller = MCP::ConnectionPoller.new.start
|
|
19
|
+
# client = MCP::Client.new(server_config, poller: poller)
|
|
20
|
+
# client.connect # registers transport with poller
|
|
21
|
+
# client.call_tool(…)
|
|
22
|
+
# poller.stop
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
class ConnectionPoller
|
|
26
|
+
POLL_INTERVAL = 0.1 # seconds between IO.select checks
|
|
27
|
+
|
|
28
|
+
# Creates a new ConnectionPoller.
|
|
29
|
+
def initialize
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@clients = {} # IO => { client:, queue: Thread::Queue }
|
|
32
|
+
@running = false
|
|
33
|
+
@thread = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Start the multiplexing thread.
|
|
37
|
+
#
|
|
38
|
+
# @return [self]
|
|
39
|
+
def start
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return self if @running
|
|
42
|
+
|
|
43
|
+
@running = true
|
|
44
|
+
@thread = Thread.new { poll_loop }
|
|
45
|
+
@thread.name = "RobotLab::MCP::ConnectionPoller"
|
|
46
|
+
end
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Stop the multiplexing thread.
|
|
51
|
+
#
|
|
52
|
+
# Cancels all pending requests with an MCPError.
|
|
53
|
+
#
|
|
54
|
+
# @param timeout [Numeric] seconds to wait for thread to finish
|
|
55
|
+
# @return [self]
|
|
56
|
+
def stop(timeout: 5)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
return self unless @running
|
|
59
|
+
|
|
60
|
+
@running = false
|
|
61
|
+
|
|
62
|
+
# Unblock any pending request queues
|
|
63
|
+
@clients.each_value do |entry|
|
|
64
|
+
entry[:queue]&.push({ error: "ConnectionPoller stopped" })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@thread&.join(timeout)
|
|
69
|
+
@thread = nil
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Register an MCP client with the poller.
|
|
74
|
+
#
|
|
75
|
+
# Only stdio clients are registered — others are silently ignored.
|
|
76
|
+
#
|
|
77
|
+
# @param client [MCP::Client]
|
|
78
|
+
# @return [void]
|
|
79
|
+
def register(client)
|
|
80
|
+
return unless stdio_client?(client)
|
|
81
|
+
|
|
82
|
+
io = client.transport.stdout
|
|
83
|
+
@mutex.synchronize { @clients[io] = { client: client, queue: nil } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Unregister a client.
|
|
87
|
+
#
|
|
88
|
+
# @param client [MCP::Client]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def unregister(client)
|
|
91
|
+
return unless stdio_client?(client)
|
|
92
|
+
|
|
93
|
+
io = client.transport.stdout
|
|
94
|
+
@mutex.synchronize { @clients.delete(io) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Send a JSON-RPC request via the poller and block until the response.
|
|
98
|
+
#
|
|
99
|
+
# Writes to the client's stdin, registers a response queue, then
|
|
100
|
+
# blocks until the poll loop dispatches the response.
|
|
101
|
+
#
|
|
102
|
+
# @param client [MCP::Client]
|
|
103
|
+
# @param message [Hash] JSON-RPC message
|
|
104
|
+
# @param timeout [Numeric] seconds before raising MCPError
|
|
105
|
+
# @return [Hash] parsed response
|
|
106
|
+
# @raise [MCPError] on timeout or connection error
|
|
107
|
+
def send_request(client, message, timeout:)
|
|
108
|
+
io = client.transport.stdout
|
|
109
|
+
queue = Thread::Queue.new
|
|
110
|
+
|
|
111
|
+
@mutex.synchronize { @clients[io][:queue] = queue }
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
client.transport.stdin.puts(message.to_json)
|
|
115
|
+
client.transport.stdin.flush
|
|
116
|
+
rescue Errno::EPIPE, IOError => e
|
|
117
|
+
@mutex.synchronize { @clients[io][:queue] = nil }
|
|
118
|
+
raise MCPError, "MCP connection lost: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
response = Timeout.timeout(timeout) { queue.pop }
|
|
122
|
+
|
|
123
|
+
if response.is_a?(Hash) && response[:error]
|
|
124
|
+
raise MCPError, response[:error]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
response
|
|
128
|
+
rescue Timeout::Error
|
|
129
|
+
@mutex.synchronize { @clients[io]&.[]= :queue, nil }
|
|
130
|
+
raise MCPError, "MCP server did not respond within #{timeout}s"
|
|
131
|
+
ensure
|
|
132
|
+
@mutex.synchronize { @clients[io]&.[]= :queue, nil }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether the poller thread is running.
|
|
136
|
+
#
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def running?
|
|
139
|
+
@mutex.synchronize { @running }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def poll_loop
|
|
145
|
+
loop do
|
|
146
|
+
ios = @mutex.synchronize { @clients.keys.reject(&:closed?) }
|
|
147
|
+
|
|
148
|
+
unless ios.empty?
|
|
149
|
+
begin
|
|
150
|
+
ready, = IO.select(ios, nil, nil, POLL_INTERVAL)
|
|
151
|
+
dispatch(ready) if ready
|
|
152
|
+
rescue Errno::EBADF
|
|
153
|
+
# A pipe was closed between the reject and IO.select — harmless, loop again
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
sleep POLL_INTERVAL
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
break unless @mutex.synchronize { @running }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def dispatch(readable_ios)
|
|
164
|
+
readable_ios.each do |io|
|
|
165
|
+
line = io.gets rescue nil
|
|
166
|
+
next unless line
|
|
167
|
+
|
|
168
|
+
parsed = JSON.parse(line, symbolize_names: true) rescue nil
|
|
169
|
+
next unless parsed
|
|
170
|
+
|
|
171
|
+
# Skip notifications (method present, no id)
|
|
172
|
+
next if parsed[:method] && !parsed.key?(:id)
|
|
173
|
+
|
|
174
|
+
entry = @mutex.synchronize { @clients[io] }
|
|
175
|
+
entry[:queue]&.push(parsed)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def stdio_client?(client)
|
|
180
|
+
client.respond_to?(:transport) &&
|
|
181
|
+
client.transport.is_a?(Transports::Stdio) &&
|
|
182
|
+
client.transport.respond_to?(:stdout) &&
|
|
183
|
+
!client.transport.stdout.nil?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/robot_lab/mcp/server.rb
CHANGED
|
@@ -33,17 +33,21 @@ module RobotLab
|
|
|
33
33
|
# @return [Hash] the transport configuration
|
|
34
34
|
# @!attribute [r] timeout
|
|
35
35
|
# @return [Numeric] request timeout in seconds
|
|
36
|
-
|
|
36
|
+
# @!attribute [r] description
|
|
37
|
+
# @return [String] human-readable description used by ServerDiscovery
|
|
38
|
+
attr_reader :name, :transport, :timeout, :description
|
|
37
39
|
|
|
38
40
|
# Creates a new Server configuration.
|
|
39
41
|
#
|
|
40
42
|
# @param name [String] the server name
|
|
41
43
|
# @param transport [Hash] the transport configuration
|
|
42
44
|
# @param timeout [Numeric, nil] request timeout in seconds (default: 15)
|
|
45
|
+
# @param description [String, nil] human-readable description for server discovery
|
|
43
46
|
# @param _extra [Hash] additional keys are silently ignored for forward compatibility
|
|
44
47
|
# @raise [ArgumentError] if transport type is invalid or required fields are missing
|
|
45
|
-
def initialize(name:, transport:, timeout: nil, **_extra)
|
|
48
|
+
def initialize(name:, transport:, timeout: nil, description: nil, **_extra)
|
|
46
49
|
@name = name.to_s
|
|
50
|
+
@description = description.to_s
|
|
47
51
|
@transport = normalize_transport(transport)
|
|
48
52
|
@timeout = normalize_timeout(timeout)
|
|
49
53
|
validate!
|
|
@@ -62,6 +66,7 @@ module RobotLab
|
|
|
62
66
|
def to_h
|
|
63
67
|
{
|
|
64
68
|
name: name,
|
|
69
|
+
description: description,
|
|
65
70
|
transport: transport,
|
|
66
71
|
timeout: timeout
|
|
67
72
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module MCP
|
|
5
|
+
# Selects relevant MCP servers for a given user query using TF cosine
|
|
6
|
+
# similarity between the query and each server's topic text
|
|
7
|
+
# (name + description).
|
|
8
|
+
#
|
|
9
|
+
# This is used as a fallback mechanism when a robot has many MCP servers
|
|
10
|
+
# configured but only some are relevant to a particular user message.
|
|
11
|
+
# Instead of connecting to all servers upfront, the robot can enable
|
|
12
|
+
# discovery so only the semantically matching servers are connected.
|
|
13
|
+
#
|
|
14
|
+
# == Usage
|
|
15
|
+
#
|
|
16
|
+
# robot = RobotLab.build(
|
|
17
|
+
# name: "assistant",
|
|
18
|
+
# mcp_discovery: true,
|
|
19
|
+
# mcp: [
|
|
20
|
+
# {
|
|
21
|
+
# name: "filesystem",
|
|
22
|
+
# description: "Read, write, and search local files and directories",
|
|
23
|
+
# transport: { type: "stdio", command: "mcp-server-fs" }
|
|
24
|
+
# },
|
|
25
|
+
# {
|
|
26
|
+
# name: "github",
|
|
27
|
+
# description: "GitHub repos, issues, pull requests, code search",
|
|
28
|
+
# transport: { type: "stdio", command: "mcp-server-github" }
|
|
29
|
+
# },
|
|
30
|
+
# {
|
|
31
|
+
# name: "brew",
|
|
32
|
+
# description: "Install, update, and manage macOS packages via Homebrew",
|
|
33
|
+
# transport: { type: "stdio", command: "mcp-server-brew" }
|
|
34
|
+
# }
|
|
35
|
+
# ]
|
|
36
|
+
# )
|
|
37
|
+
#
|
|
38
|
+
# # Discovery connects only the :brew server for this query:
|
|
39
|
+
# robot.run("install imagemagick")
|
|
40
|
+
#
|
|
41
|
+
# == Fallback Behaviour
|
|
42
|
+
#
|
|
43
|
+
# The full server list is returned unchanged when:
|
|
44
|
+
# - No server has a +:description+ field
|
|
45
|
+
# - The 'classifier' gem is unavailable
|
|
46
|
+
# - The query is blank
|
|
47
|
+
# - No server scores at or above +threshold+ (minimum relevance)
|
|
48
|
+
#
|
|
49
|
+
# @api private
|
|
50
|
+
module ServerDiscovery
|
|
51
|
+
# Minimum cosine similarity score for a server to be considered relevant.
|
|
52
|
+
# Low by design — server descriptions are short, so scores are naturally
|
|
53
|
+
# low even for on-topic queries.
|
|
54
|
+
DEFAULT_THRESHOLD = 0.05
|
|
55
|
+
|
|
56
|
+
# Select MCP servers relevant to the given query.
|
|
57
|
+
#
|
|
58
|
+
# @param query [String] user message or intent
|
|
59
|
+
# @param from [Array<Hash, MCP::Server>] candidate server configs
|
|
60
|
+
# @param threshold [Float] minimum cosine score (default 0.05)
|
|
61
|
+
# @return [Array<Hash, MCP::Server>] matching servers, or +from+ as
|
|
62
|
+
# fallback when no match is found
|
|
63
|
+
def self.select(query, from:, threshold: DEFAULT_THRESHOLD)
|
|
64
|
+
return from if from.empty?
|
|
65
|
+
return from if query.to_s.strip.empty?
|
|
66
|
+
return from unless any_descriptions?(from)
|
|
67
|
+
|
|
68
|
+
TextAnalysis.require_classifier!
|
|
69
|
+
|
|
70
|
+
scored = from.map { |server| [server, score(query, server)] }
|
|
71
|
+
matches = scored.select { |_, s| s >= threshold }.map(&:first)
|
|
72
|
+
|
|
73
|
+
matches.empty? ? from : matches
|
|
74
|
+
rescue DependencyError
|
|
75
|
+
# Classifier gem not available — connect to all servers
|
|
76
|
+
from
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# @param servers [Array<Hash, MCP::Server>]
|
|
82
|
+
def self.any_descriptions?(servers)
|
|
83
|
+
servers.any? { |s| !description_for(s).empty? }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Build the topic string used for similarity scoring: name + description.
|
|
87
|
+
#
|
|
88
|
+
# @param server [Hash, MCP::Server]
|
|
89
|
+
# @return [String]
|
|
90
|
+
def self.topic_text(server)
|
|
91
|
+
name = server.is_a?(Hash) ? server[:name].to_s : server.name.to_s
|
|
92
|
+
"#{name} #{description_for(server)}".strip
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param server [Hash, MCP::Server]
|
|
96
|
+
# @return [String] description or empty string
|
|
97
|
+
def self.description_for(server)
|
|
98
|
+
desc = server.is_a?(Hash) ? server[:description] : server.respond_to?(:description) && server.description
|
|
99
|
+
desc.to_s.strip
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param query [String]
|
|
103
|
+
# @param server [Hash, MCP::Server]
|
|
104
|
+
# @return [Float] cosine similarity in [0.0, 1.0]
|
|
105
|
+
def self.score(query, server)
|
|
106
|
+
TextAnalysis.tf_cosine_similarity(query, topic_text(server))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -120,6 +120,12 @@ module RobotLab
|
|
|
120
120
|
@connected && @wait_thread&.alive?
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
# Expose the underlying IO objects so ConnectionPoller can
|
|
124
|
+
# register them in its IO.select loop.
|
|
125
|
+
#
|
|
126
|
+
# @api private
|
|
127
|
+
attr_reader :stdin, :stdout
|
|
128
|
+
|
|
123
129
|
private
|
|
124
130
|
|
|
125
131
|
def send_initialize
|
data/lib/robot_lab/memory.rb
CHANGED
|
@@ -112,6 +112,12 @@ module RobotLab
|
|
|
112
112
|
@waiters = Hash.new { |h, k| h[k] = [] }
|
|
113
113
|
@subscription_mutex = Mutex.new
|
|
114
114
|
@waiter_mutex = Mutex.new
|
|
115
|
+
|
|
116
|
+
# Notification coalescing — batches multiple key changes into a single
|
|
117
|
+
# drainer fiber rather than spawning one Async task per callback per change.
|
|
118
|
+
@notification_queue = []
|
|
119
|
+
@notification_queue_mutex = Mutex.new
|
|
120
|
+
@drainer_scheduled = false
|
|
115
121
|
end
|
|
116
122
|
|
|
117
123
|
# Get value by key
|
|
@@ -410,6 +416,61 @@ module RobotLab
|
|
|
410
416
|
end
|
|
411
417
|
end
|
|
412
418
|
|
|
419
|
+
# =========================================================================
|
|
420
|
+
# Document Store — embedding-based semantic search
|
|
421
|
+
# =========================================================================
|
|
422
|
+
|
|
423
|
+
# Embed +text+ and store it under +key+ for later semantic search.
|
|
424
|
+
#
|
|
425
|
+
# The embedding model (BAAI/bge-small-en-v1.5 via fastembed) is initialised
|
|
426
|
+
# lazily on the first call. The model file is downloaded once and cached.
|
|
427
|
+
#
|
|
428
|
+
# @param key [Symbol, String] identifier for the document
|
|
429
|
+
# @param text [String] text to embed and store
|
|
430
|
+
# @return [self]
|
|
431
|
+
#
|
|
432
|
+
# @example
|
|
433
|
+
# memory.store_document(:readme, File.read("README.md"))
|
|
434
|
+
# memory.store_document(:changelog, File.read("CHANGELOG.md"))
|
|
435
|
+
#
|
|
436
|
+
def store_document(key, text)
|
|
437
|
+
document_store.store(key, text)
|
|
438
|
+
self
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Search stored documents for the ones most semantically similar to +query+.
|
|
442
|
+
#
|
|
443
|
+
# @param query [String] natural-language query
|
|
444
|
+
# @param limit [Integer] maximum number of results to return (default 5)
|
|
445
|
+
# @return [Array<Hash>] results sorted by score descending;
|
|
446
|
+
# each hash has +:key+, +:text+, and +:score+ (Float 0.0..1.0)
|
|
447
|
+
#
|
|
448
|
+
# @example
|
|
449
|
+
# hits = memory.search_documents("how to configure redis", limit: 3)
|
|
450
|
+
# hits.each { |h| puts "#{h[:key]} (#{h[:score].round(3)}): #{h[:text][0..80]}" }
|
|
451
|
+
#
|
|
452
|
+
def search_documents(query, limit: 5)
|
|
453
|
+
return [] unless @document_store
|
|
454
|
+
|
|
455
|
+
@document_store.search(query, limit: limit)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Keys of all documents stored in the embedded document store.
|
|
459
|
+
#
|
|
460
|
+
# @return [Array<Symbol>]
|
|
461
|
+
def document_keys
|
|
462
|
+
@document_store&.keys || []
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Remove a document from the store.
|
|
466
|
+
#
|
|
467
|
+
# @param key [Symbol, String]
|
|
468
|
+
# @return [self]
|
|
469
|
+
def delete_document(key)
|
|
470
|
+
@document_store&.delete(key)
|
|
471
|
+
self
|
|
472
|
+
end
|
|
473
|
+
|
|
413
474
|
# Append a robot result to history
|
|
414
475
|
#
|
|
415
476
|
# @param result [RobotResult]
|
|
@@ -625,6 +686,10 @@ module RobotLab
|
|
|
625
686
|
|
|
626
687
|
private
|
|
627
688
|
|
|
689
|
+
def document_store
|
|
690
|
+
@document_store ||= DocumentStore.new
|
|
691
|
+
end
|
|
692
|
+
|
|
628
693
|
def create_semantic_cache
|
|
629
694
|
RubyLLM::SemanticCache
|
|
630
695
|
end
|
|
@@ -738,6 +803,7 @@ module RobotLab
|
|
|
738
803
|
end
|
|
739
804
|
|
|
740
805
|
result = waiter.wait(timeout: timeout)
|
|
806
|
+
waiter.close
|
|
741
807
|
|
|
742
808
|
if result == :timeout
|
|
743
809
|
# Clean up the waiter
|
|
@@ -758,10 +824,8 @@ module RobotLab
|
|
|
758
824
|
callbacks = []
|
|
759
825
|
|
|
760
826
|
@subscription_mutex.synchronize do
|
|
761
|
-
# Exact key matches
|
|
762
827
|
callbacks.concat(@subscriptions[key].map { |s| s[:callback] })
|
|
763
828
|
|
|
764
|
-
# Pattern matches
|
|
765
829
|
key_str = key.to_s
|
|
766
830
|
@pattern_subscriptions.each do |sub|
|
|
767
831
|
callbacks << sub[:callback] if sub[:pattern].match?(key_str)
|
|
@@ -770,7 +834,6 @@ module RobotLab
|
|
|
770
834
|
|
|
771
835
|
return if callbacks.empty?
|
|
772
836
|
|
|
773
|
-
# Build the change object
|
|
774
837
|
change = MemoryChange.new(
|
|
775
838
|
key: key,
|
|
776
839
|
value: value,
|
|
@@ -780,10 +843,44 @@ module RobotLab
|
|
|
780
843
|
timestamp: Time.now
|
|
781
844
|
)
|
|
782
845
|
|
|
783
|
-
#
|
|
784
|
-
|
|
785
|
-
|
|
846
|
+
# Coalesce: push onto the batch queue and spawn at most one drainer fiber.
|
|
847
|
+
# Under rapid writes (e.g. many network robots writing simultaneously) this
|
|
848
|
+
# reduces Async fiber churn from O(subscribers × key_changes) to O(1).
|
|
849
|
+
schedule_drain = false
|
|
850
|
+
@notification_queue_mutex.synchronize do
|
|
851
|
+
@notification_queue << { change: change, callbacks: callbacks }
|
|
852
|
+
unless @drainer_scheduled
|
|
853
|
+
@drainer_scheduled = true
|
|
854
|
+
schedule_drain = true
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
dispatch_async { drain_notification_queue } if schedule_drain
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
# Drain all pending notification batches in a single fiber.
|
|
862
|
+
# Loops until the queue is empty, then resets the drainer flag.
|
|
863
|
+
# If new items arrive just before the flag resets, reschedules itself.
|
|
864
|
+
def drain_notification_queue
|
|
865
|
+
loop do
|
|
866
|
+
batch = @notification_queue_mutex.synchronize do
|
|
867
|
+
items = @notification_queue.dup
|
|
868
|
+
@notification_queue.clear
|
|
869
|
+
items
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
break if batch.empty?
|
|
873
|
+
|
|
874
|
+
batch.each do |item|
|
|
875
|
+
item[:callbacks].each { |cb| cb.call(item[:change]) }
|
|
876
|
+
end
|
|
877
|
+
end
|
|
878
|
+
ensure
|
|
879
|
+
reschedule = @notification_queue_mutex.synchronize do
|
|
880
|
+
@drainer_scheduled = false
|
|
881
|
+
!@notification_queue.empty?
|
|
786
882
|
end
|
|
883
|
+
dispatch_async { drain_notification_queue } if reschedule
|
|
787
884
|
end
|
|
788
885
|
|
|
789
886
|
def generate_subscription_id
|
data/lib/robot_lab/network.rb
CHANGED
|
@@ -71,7 +71,7 @@ module RobotLab
|
|
|
71
71
|
# @return [Hash<String, Robot>] robots in this network, keyed by name
|
|
72
72
|
# @!attribute [r] memory
|
|
73
73
|
# @return [Memory] shared memory for all robots in the network
|
|
74
|
-
attr_reader :name, :pipeline, :robots, :memory, :config
|
|
74
|
+
attr_reader :name, :pipeline, :robots, :memory, :config, :parallel_mode
|
|
75
75
|
|
|
76
76
|
# Creates a new Network instance.
|
|
77
77
|
#
|
|
@@ -86,14 +86,16 @@ module RobotLab
|
|
|
86
86
|
# task :billing, billing_robot, context: { dept: "billing" }, depends_on: :optional
|
|
87
87
|
# end
|
|
88
88
|
#
|
|
89
|
-
def initialize(name:, concurrency: :auto, memory: nil, config: nil, &block)
|
|
89
|
+
def initialize(name:, concurrency: :auto, memory: nil, config: nil, parallel_mode: :async, &block)
|
|
90
90
|
@name = name.to_s
|
|
91
91
|
@robots = {}
|
|
92
92
|
@tasks = {}
|
|
93
93
|
@pipeline = SimpleFlow::Pipeline.new(concurrency: concurrency)
|
|
94
94
|
@memory = memory || Memory.new(network_name: @name)
|
|
95
95
|
@config = config || RunConfig.new
|
|
96
|
+
@parallel_mode = parallel_mode
|
|
96
97
|
@broadcast_handlers = []
|
|
98
|
+
@bus_poller = BusPoller.new.start
|
|
97
99
|
|
|
98
100
|
instance_eval(&block) if block_given?
|
|
99
101
|
end
|
|
@@ -121,7 +123,7 @@ module RobotLab
|
|
|
121
123
|
# @example Task with dependencies
|
|
122
124
|
# task :writer, writer_robot, depends_on: [:analyst]
|
|
123
125
|
#
|
|
124
|
-
def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none)
|
|
126
|
+
def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none, poller_group: :default)
|
|
125
127
|
task_wrapper = Task.new(
|
|
126
128
|
name: name,
|
|
127
129
|
robot: robot,
|
|
@@ -132,6 +134,10 @@ module RobotLab
|
|
|
132
134
|
config: config
|
|
133
135
|
)
|
|
134
136
|
|
|
137
|
+
# Register the group and assign the shared poller to the robot
|
|
138
|
+
@bus_poller.add_group(poller_group)
|
|
139
|
+
robot.assign_bus_poller(@bus_poller, group: poller_group) if robot.respond_to?(:assign_bus_poller, true)
|
|
140
|
+
|
|
135
141
|
@robots[name.to_s] = robot
|
|
136
142
|
@tasks[name.to_s] = task_wrapper
|
|
137
143
|
@pipeline.step(name, task_wrapper, depends_on: depends_on)
|
|
@@ -177,12 +183,15 @@ module RobotLab
|
|
|
177
183
|
# Pass network's config so robots can inherit it
|
|
178
184
|
run_context[:network_config] = @config unless @config.empty?
|
|
179
185
|
|
|
180
|
-
|
|
181
|
-
run_context
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
if @parallel_mode == :ractor
|
|
187
|
+
run_with_ractor_scheduler(run_context)
|
|
188
|
+
else
|
|
189
|
+
initial_result = SimpleFlow::Result.new(
|
|
190
|
+
run_context,
|
|
191
|
+
context: { run_params: run_context }
|
|
192
|
+
)
|
|
193
|
+
@pipeline.call_parallel(initial_result)
|
|
194
|
+
end
|
|
186
195
|
end
|
|
187
196
|
|
|
188
197
|
# Broadcast a message to all robots in the network.
|
|
@@ -339,5 +348,31 @@ module RobotLab
|
|
|
339
348
|
}.compact
|
|
340
349
|
end
|
|
341
350
|
|
|
351
|
+
private
|
|
352
|
+
|
|
353
|
+
def run_with_ractor_scheduler(run_context)
|
|
354
|
+
message = run_context[:message].to_s
|
|
355
|
+
dep_graph = @pipeline.step_dependencies # { task_sym => [dep_sym, ...] }
|
|
356
|
+
|
|
357
|
+
specs_with_deps = @tasks.map do |task_name, task_wrapper|
|
|
358
|
+
deps = dep_graph[task_name.to_sym] || []
|
|
359
|
+
deps = deps.empty? ? :none : deps.map(&:to_s)
|
|
360
|
+
|
|
361
|
+
spec = RobotSpec.new(
|
|
362
|
+
name: task_wrapper.robot.name.freeze,
|
|
363
|
+
template: task_wrapper.robot.template&.to_s&.freeze,
|
|
364
|
+
system_prompt: task_wrapper.robot.system_prompt&.freeze,
|
|
365
|
+
config_hash: RactorBoundary.freeze_deep(task_wrapper.robot.config.to_json_hash)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
{ spec: spec, depends_on: deps }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
scheduler = RactorNetworkScheduler.new(memory: @memory)
|
|
372
|
+
results = scheduler.run_pipeline(specs_with_deps, message: message)
|
|
373
|
+
scheduler.shutdown
|
|
374
|
+
results
|
|
375
|
+
end
|
|
376
|
+
|
|
342
377
|
end
|
|
343
378
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Utility for making values safe to pass across Ractor boundaries.
|
|
5
|
+
#
|
|
6
|
+
# Recursively freezes Hash and Array structures. Raises RactorBoundaryError
|
|
7
|
+
# if a value cannot be made Ractor-shareable (e.g. a live IO or Proc).
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# safe = RactorBoundary.freeze_deep({ model: "sonnet", args: { x: 1 } })
|
|
11
|
+
# Ractor.shareable?(safe) #=> true
|
|
12
|
+
#
|
|
13
|
+
module RactorBoundary
|
|
14
|
+
# Recursively freeze an object for safe Ractor boundary crossing.
|
|
15
|
+
#
|
|
16
|
+
# @param obj [Object] the value to freeze
|
|
17
|
+
# @return [Object] a frozen, Ractor-shareable copy
|
|
18
|
+
# @raise [RactorBoundaryError] if the value cannot be made shareable
|
|
19
|
+
def self.freeze_deep(obj)
|
|
20
|
+
return obj if Ractor.shareable?(obj)
|
|
21
|
+
|
|
22
|
+
result = case obj
|
|
23
|
+
when Hash
|
|
24
|
+
obj.transform_keys { |k| freeze_deep(k) }
|
|
25
|
+
.transform_values { |v| freeze_deep(v) }
|
|
26
|
+
when Array
|
|
27
|
+
obj.map { |v| freeze_deep(v) }
|
|
28
|
+
else
|
|
29
|
+
begin
|
|
30
|
+
obj.dup
|
|
31
|
+
rescue TypeError
|
|
32
|
+
raise RactorBoundaryError,
|
|
33
|
+
"Cannot make #{obj.class} Ractor-shareable: dup not supported"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Ractor.make_shareable(result)
|
|
38
|
+
rescue Ractor::IsolationError, Ractor::Error => e
|
|
39
|
+
raise RactorBoundaryError, "Cannot make value Ractor-shareable: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Carrier for work crossing a Ractor boundary.
|
|
5
|
+
#
|
|
6
|
+
# All fields must be Ractor-shareable (frozen Data, frozen String,
|
|
7
|
+
# frozen Hash, or a RactorQueue). Build with RactorBoundary.freeze_deep
|
|
8
|
+
# on the payload before constructing.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# job = RactorJob.new(
|
|
12
|
+
# id: SecureRandom.uuid.freeze,
|
|
13
|
+
# type: :tool,
|
|
14
|
+
# payload: RactorBoundary.freeze_deep({ tool_class: "MyTool", args: { x: 1 } }),
|
|
15
|
+
# reply_queue: RactorQueue.new(capacity: 1)
|
|
16
|
+
# )
|
|
17
|
+
RactorJob = Data.define(:id, :type, :payload, :reply_queue)
|
|
18
|
+
|
|
19
|
+
# Frozen error representation for exceptions raised inside a Ractor worker.
|
|
20
|
+
# Serialized at the Ractor boundary and re-raised on the thread side.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# err = RactorJobError.new(message: e.message.freeze, backtrace: e.backtrace.freeze)
|
|
24
|
+
RactorJobError = Data.define(:message, :backtrace)
|
|
25
|
+
|
|
26
|
+
# Carries everything needed to reconstruct a Robot inside a Ractor.
|
|
27
|
+
# All fields must be frozen strings, symbols, or hashes.
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# spec = RobotSpec.new(
|
|
31
|
+
# name: "analyst",
|
|
32
|
+
# template: :analyst,
|
|
33
|
+
# system_prompt: nil,
|
|
34
|
+
# config_hash: { model: "claude-sonnet-4" }.freeze
|
|
35
|
+
# )
|
|
36
|
+
RobotSpec = Data.define(:name, :template, :system_prompt, :config_hash)
|
|
37
|
+
end
|