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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +210 -1
  4. data/Rakefile +2 -1
  5. data/docs/api/core/result.md +123 -0
  6. data/docs/api/core/robot.md +182 -0
  7. data/docs/api/errors.md +185 -0
  8. data/docs/guides/building-robots.md +125 -0
  9. data/docs/guides/creating-networks.md +21 -0
  10. data/docs/guides/index.md +10 -0
  11. data/docs/guides/knowledge.md +182 -0
  12. data/docs/guides/mcp-integration.md +106 -0
  13. data/docs/guides/memory.md +2 -0
  14. data/docs/guides/observability.md +486 -0
  15. data/docs/guides/ractor-parallelism.md +364 -0
  16. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  17. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  18. data/examples/19_token_tracking.rb +128 -0
  19. data/examples/20_circuit_breaker.rb +153 -0
  20. data/examples/21_learning_loop.rb +164 -0
  21. data/examples/22_context_compression.rb +179 -0
  22. data/examples/23_convergence.rb +137 -0
  23. data/examples/24_structured_delegation.rb +150 -0
  24. data/examples/25_history_search/conversation.jsonl +30 -0
  25. data/examples/25_history_search.rb +136 -0
  26. data/examples/26_document_store/api_versioning_adr.md +52 -0
  27. data/examples/26_document_store/incident_postmortem.md +46 -0
  28. data/examples/26_document_store/postgres_runbook.md +49 -0
  29. data/examples/26_document_store/redis_caching_guide.md +48 -0
  30. data/examples/26_document_store/sidekiq_guide.md +51 -0
  31. data/examples/26_document_store.rb +147 -0
  32. data/examples/27_incident_response/incident_response.rb +244 -0
  33. data/examples/28_mcp_discovery.rb +112 -0
  34. data/examples/29_ractor_tools.rb +243 -0
  35. data/examples/30_ractor_network.rb +256 -0
  36. data/examples/README.md +136 -0
  37. data/examples/prompts/skill_with_mcp_test.md +9 -0
  38. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  39. data/examples/prompts/skill_with_tools_test.md +6 -0
  40. data/lib/robot_lab/bus_poller.rb +149 -0
  41. data/lib/robot_lab/convergence.rb +69 -0
  42. data/lib/robot_lab/delegation_future.rb +93 -0
  43. data/lib/robot_lab/document_store.rb +155 -0
  44. data/lib/robot_lab/error.rb +25 -0
  45. data/lib/robot_lab/history_compressor.rb +205 -0
  46. data/lib/robot_lab/mcp/client.rb +17 -5
  47. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  48. data/lib/robot_lab/mcp/server.rb +7 -2
  49. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  50. data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
  51. data/lib/robot_lab/memory.rb +103 -6
  52. data/lib/robot_lab/network.rb +44 -9
  53. data/lib/robot_lab/ractor_boundary.rb +42 -0
  54. data/lib/robot_lab/ractor_job.rb +37 -0
  55. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  56. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  57. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  58. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  59. data/lib/robot_lab/robot/history_search.rb +69 -0
  60. data/lib/robot_lab/robot.rb +228 -11
  61. data/lib/robot_lab/robot_result.rb +24 -5
  62. data/lib/robot_lab/run_config.rb +1 -1
  63. data/lib/robot_lab/text_analysis.rb +103 -0
  64. data/lib/robot_lab/tool.rb +42 -3
  65. data/lib/robot_lab/tool_config.rb +1 -1
  66. data/lib/robot_lab/version.rb +1 -1
  67. data/lib/robot_lab/waiter.rb +49 -29
  68. data/lib/robot_lab.rb +25 -0
  69. data/mkdocs.yml +1 -0
  70. 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
@@ -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
- attr_reader :name, :transport, :timeout
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
@@ -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
- # Dispatch callbacks asynchronously
784
- callbacks.each do |callback|
785
- dispatch_async { callback.call(change) }
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
@@ -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
- initial_result = SimpleFlow::Result.new(
181
- run_context,
182
- context: { run_params: run_context }
183
- )
184
-
185
- @pipeline.call_parallel(initial_result)
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