robot_lab-ractor 0.1.0
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 +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +94 -0
- data/Rakefile +8 -0
- data/docs/guides/ractor-parallelism.md +364 -0
- data/docs/index.md +69 -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/29_ractor_tools.rb +242 -0
- data/examples/30_ractor_network.rb +255 -0
- data/lib/robot_lab/ractor/version.rb +9 -0
- data/lib/robot_lab/ractor.rb +37 -0
- 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 +68 -0
- data/lib/robot_lab/ractor_network_scheduler.rb +139 -0
- data/lib/robot_lab/ractor_worker_pool.rb +102 -0
- data/mkdocs.yml +118 -0
- metadata +110 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 30: Ractor Network Scheduler
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates Track 2 of RobotLab's Ractor parallelism: running a
|
|
7
|
+
# multi-robot pipeline under RactorNetworkScheduler, which dispatches
|
|
8
|
+
# independent tasks in parallel waves and respects depends_on ordering.
|
|
9
|
+
#
|
|
10
|
+
# Concepts covered:
|
|
11
|
+
# - Network.new(parallel_mode: :ractor) — opt the network into Ractor dispatch
|
|
12
|
+
# - Dependency waves — independent tasks run concurrently,
|
|
13
|
+
# dependent tasks wait for their wave
|
|
14
|
+
# - RobotSpec — frozen, Ractor-shareable robot descriptor
|
|
15
|
+
# - RactorNetworkScheduler — direct use for testing / custom wiring
|
|
16
|
+
# - Result Hash — run_pipeline returns { name => result }
|
|
17
|
+
#
|
|
18
|
+
# Part 1 — simulated scheduler, no API key needed.
|
|
19
|
+
# Overrides execute_spec with a sleep-based stub so the wave ordering and
|
|
20
|
+
# timing characteristics are visible without real LLM calls.
|
|
21
|
+
#
|
|
22
|
+
# Part 2 — Network.new(parallel_mode: :ractor) API walkthrough.
|
|
23
|
+
# Shows how to configure the network and inspects the dependency graph.
|
|
24
|
+
# No run() call is made, so no API key is required.
|
|
25
|
+
#
|
|
26
|
+
# Part 3 — live LLM run (optional).
|
|
27
|
+
# Executes automatically when ANTHROPIC_API_KEY is set.
|
|
28
|
+
#
|
|
29
|
+
# Usage:
|
|
30
|
+
# bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
|
|
31
|
+
# ANTHROPIC_API_KEY=key ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
|
|
32
|
+
|
|
33
|
+
require "robot_lab"
|
|
34
|
+
require "robot_lab/ractor"
|
|
35
|
+
|
|
36
|
+
puts "=" * 62
|
|
37
|
+
puts "Example 30: Ractor Network Scheduler"
|
|
38
|
+
puts "=" * 62
|
|
39
|
+
puts
|
|
40
|
+
|
|
41
|
+
DIVIDER = ("─" * 54).freeze
|
|
42
|
+
|
|
43
|
+
# =============================================================================
|
|
44
|
+
# Part 1: Simulated scheduler — dependency waves and timing
|
|
45
|
+
# =============================================================================
|
|
46
|
+
#
|
|
47
|
+
# Pipeline topology:
|
|
48
|
+
#
|
|
49
|
+
# headline_finder ─────────────────────────────────┐
|
|
50
|
+
# ├──► report_writer
|
|
51
|
+
# background_brief ─────────────────────────────────┤
|
|
52
|
+
# │
|
|
53
|
+
# fact_checker ─────────────────────────────────┘
|
|
54
|
+
#
|
|
55
|
+
# Wave 1 — headline_finder, background_brief, fact_checker: parallel (none depend on each other)
|
|
56
|
+
# Wave 2 — report_writer: sequential (depends on all three)
|
|
57
|
+
#
|
|
58
|
+
# Simulated latencies (seconds):
|
|
59
|
+
LATENCIES = {
|
|
60
|
+
"headline_finder" => 0.60,
|
|
61
|
+
"background_brief" => 0.50,
|
|
62
|
+
"fact_checker" => 0.40,
|
|
63
|
+
"report_writer" => 0.70
|
|
64
|
+
}.freeze
|
|
65
|
+
#
|
|
66
|
+
# Expected times:
|
|
67
|
+
# Parallel — wave1 = max(0.60, 0.50, 0.40) = 0.60s
|
|
68
|
+
# wave2 = 0.70s
|
|
69
|
+
# total ≈ 1.30s
|
|
70
|
+
#
|
|
71
|
+
# Sequential — 0.60 + 0.50 + 0.40 + 0.70 = 2.20s
|
|
72
|
+
#
|
|
73
|
+
# Speedup ≈ 1.7×
|
|
74
|
+
|
|
75
|
+
puts "── Part 1: Simulated parallel run (no API key) ───────────"
|
|
76
|
+
puts
|
|
77
|
+
|
|
78
|
+
# SimulatedScheduler overrides execute_spec so that instead of
|
|
79
|
+
# constructing a real Robot and calling the LLM, it just sleeps for
|
|
80
|
+
# the robot's configured latency and returns a synthetic result string.
|
|
81
|
+
# All dependency-ordering and wave-dispatch logic is inherited unchanged.
|
|
82
|
+
class SimulatedScheduler < RobotLab::RactorNetworkScheduler
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def execute_spec(spec, _message)
|
|
86
|
+
delay = LATENCIES[spec.name] || 0.30
|
|
87
|
+
sleep delay
|
|
88
|
+
"#{spec.name}: completed after #{delay}s".freeze
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Build RobotSpec objects directly.
|
|
93
|
+
# (Normally Network#run_with_ractor_scheduler builds these from Task wrappers.)
|
|
94
|
+
def make_spec(name, deps = :none)
|
|
95
|
+
spec = RobotLab::RobotSpec.new(
|
|
96
|
+
name: name.freeze,
|
|
97
|
+
template: nil,
|
|
98
|
+
system_prompt: "You are the #{name} robot.".freeze,
|
|
99
|
+
config_hash: {}.freeze
|
|
100
|
+
)
|
|
101
|
+
{ spec: spec, depends_on: deps }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
specs_with_deps = [
|
|
105
|
+
make_spec("headline_finder"),
|
|
106
|
+
make_spec("background_brief"),
|
|
107
|
+
make_spec("fact_checker"),
|
|
108
|
+
make_spec("report_writer", ["headline_finder", "background_brief", "fact_checker"])
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
memory = RobotLab::Memory.new
|
|
112
|
+
scheduler = SimulatedScheduler.new(memory: memory)
|
|
113
|
+
|
|
114
|
+
topic = "artificial intelligence regulation in 2025"
|
|
115
|
+
|
|
116
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
+
results = scheduler.run_pipeline(specs_with_deps, message: topic)
|
|
118
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
119
|
+
|
|
120
|
+
scheduler.shutdown
|
|
121
|
+
|
|
122
|
+
sequential_total = LATENCIES.values.sum
|
|
123
|
+
|
|
124
|
+
puts " Pipeline topology:"
|
|
125
|
+
puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
|
|
126
|
+
puts " Wave 2 (sequential): report_writer ← waits for all three"
|
|
127
|
+
puts
|
|
128
|
+
|
|
129
|
+
puts " Results (Hash { name => result_string }):"
|
|
130
|
+
results.each { |name, val| puts " \"#{name}\" => #{val.inspect}" }
|
|
131
|
+
puts
|
|
132
|
+
|
|
133
|
+
puts " Wall time: #{"%.3f" % elapsed}s"
|
|
134
|
+
puts " Sequential equiv: #{"%.3f" % sequential_total}s (#{LATENCIES.map { |n, d| "#{n}=#{d}" }.join(' + ')})"
|
|
135
|
+
puts " Speedup: #{(sequential_total / elapsed).round(1)}×"
|
|
136
|
+
puts
|
|
137
|
+
|
|
138
|
+
# =============================================================================
|
|
139
|
+
# Part 2: Network.new(parallel_mode: :ractor) API
|
|
140
|
+
# =============================================================================
|
|
141
|
+
|
|
142
|
+
puts "── Part 2: Network.new(parallel_mode: :ractor) ───────────"
|
|
143
|
+
puts
|
|
144
|
+
|
|
145
|
+
# When parallel_mode: :ractor is set on a Network, network.run(message:)
|
|
146
|
+
# routes through RactorNetworkScheduler instead of SimpleFlow::Pipeline.
|
|
147
|
+
# The default mode is :async (unchanged SimpleFlow behavior).
|
|
148
|
+
|
|
149
|
+
model = "claude-haiku-4-5-20251001"
|
|
150
|
+
|
|
151
|
+
network = RobotLab::Network.new(name: "research_pipeline", parallel_mode: :ractor) do
|
|
152
|
+
task :headline_finder, RobotLab.build(name: "headline_finder",
|
|
153
|
+
system_prompt: "Find the 3 most relevant news headlines. Be concise.",
|
|
154
|
+
model: model),
|
|
155
|
+
depends_on: :none
|
|
156
|
+
|
|
157
|
+
task :background_brief, RobotLab.build(name: "background_brief",
|
|
158
|
+
system_prompt: "Provide a 2-sentence background on the topic.",
|
|
159
|
+
model: model),
|
|
160
|
+
depends_on: :none
|
|
161
|
+
|
|
162
|
+
task :fact_checker, RobotLab.build(name: "fact_checker",
|
|
163
|
+
system_prompt: "List 3 verifiable facts about the topic.",
|
|
164
|
+
model: model),
|
|
165
|
+
depends_on: :none
|
|
166
|
+
|
|
167
|
+
task :report_writer, RobotLab.build(name: "report_writer",
|
|
168
|
+
system_prompt: "Synthesize the provided context into a 3-sentence report.",
|
|
169
|
+
model: model),
|
|
170
|
+
depends_on: ["headline_finder", "background_brief", "fact_checker"]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
puts " network.name => #{network.name.inspect}"
|
|
174
|
+
puts " network.parallel_mode => #{network.parallel_mode.inspect}"
|
|
175
|
+
puts
|
|
176
|
+
puts " Dependency graph (from network.pipeline.step_dependencies):"
|
|
177
|
+
|
|
178
|
+
network.pipeline.step_dependencies.each do |step, deps|
|
|
179
|
+
dep_str = deps.empty? ? "(entry point — Wave 1)" : "depends on: #{deps.join(', ')}"
|
|
180
|
+
puts " :#{step.to_s.ljust(18)} #{dep_str}"
|
|
181
|
+
end
|
|
182
|
+
puts
|
|
183
|
+
puts " When network.run(message: ...) is called:"
|
|
184
|
+
puts " • RactorNetworkScheduler is created with the network's memory"
|
|
185
|
+
puts " • Each task is converted to a frozen RobotSpec"
|
|
186
|
+
puts " • Wave 1 tasks are dispatched concurrently (Thread per task)"
|
|
187
|
+
puts " • Each Thread submits a RactorJob to the worker queue"
|
|
188
|
+
puts " • A Ractor worker pops the job, constructs a fresh Robot from the"
|
|
189
|
+
puts " spec, calls robot.run(message), and returns the frozen result"
|
|
190
|
+
puts " • Wave 2 begins once all Wave 1 results are available"
|
|
191
|
+
puts " • Return value is a Hash { robot_name => result_string }"
|
|
192
|
+
puts
|
|
193
|
+
|
|
194
|
+
# =============================================================================
|
|
195
|
+
# Part 3: Live LLM run (optional — requires ANTHROPIC_API_KEY)
|
|
196
|
+
# =============================================================================
|
|
197
|
+
|
|
198
|
+
unless ENV["ANTHROPIC_API_KEY"]
|
|
199
|
+
puts "── Part 3: Live LLM run ──────────────────────────────────"
|
|
200
|
+
puts " Set ANTHROPIC_API_KEY to run the real pipeline."
|
|
201
|
+
puts " Expected behavior: headline_finder, background_brief, and"
|
|
202
|
+
puts " fact_checker run in parallel; report_writer follows."
|
|
203
|
+
puts
|
|
204
|
+
puts "=" * 62
|
|
205
|
+
puts "Example 30 complete."
|
|
206
|
+
puts "=" * 62
|
|
207
|
+
exit 0
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
puts "── Part 3: Live LLM run (ANTHROPIC_API_KEY detected) ─────"
|
|
211
|
+
puts
|
|
212
|
+
|
|
213
|
+
puts " Running 4-robot research pipeline on:"
|
|
214
|
+
puts " \"#{topic}\""
|
|
215
|
+
puts
|
|
216
|
+
puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
|
|
217
|
+
puts " Wave 2: report_writer"
|
|
218
|
+
puts
|
|
219
|
+
|
|
220
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
221
|
+
|
|
222
|
+
begin
|
|
223
|
+
result = network.run(message: topic)
|
|
224
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
225
|
+
|
|
226
|
+
puts " Completed in #{"%.2f" % elapsed}s"
|
|
227
|
+
puts
|
|
228
|
+
puts " Results:"
|
|
229
|
+
result.each do |name, text|
|
|
230
|
+
snippet = text.to_s.strip.lines.first.to_s.strip
|
|
231
|
+
puts " [#{name}] #{snippet[0..90]}#{snippet.length > 90 ? '…' : ''}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
rescue Ractor::IsolationError, Ractor::Error => e
|
|
235
|
+
puts " Ractor error: #{e.class}: #{e.message[0..100]}"
|
|
236
|
+
puts
|
|
237
|
+
puts " ruby_llm is not yet Ractor-safe: it uses Procs in class"
|
|
238
|
+
puts " definitions that cannot cross Ractor boundaries."
|
|
239
|
+
puts " Track this at: https://github.com/crmne/ruby_llm"
|
|
240
|
+
|
|
241
|
+
rescue RobotLab::Error => e
|
|
242
|
+
if e.message.include?("un-shareable Proc")
|
|
243
|
+
puts " Live LLM runs require ruby_llm to be Ractor-safe."
|
|
244
|
+
puts " ruby_llm stores Procs in class-level hooks that cannot cross"
|
|
245
|
+
puts " Ractor boundaries. Part 1 (SimulatedScheduler) demonstrates wave"
|
|
246
|
+
puts " ordering today; Part 3 will work once ruby_llm gains Ractor support."
|
|
247
|
+
else
|
|
248
|
+
puts " RobotLab::Error: #{e.message}"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
puts
|
|
253
|
+
puts "=" * 62
|
|
254
|
+
puts "Example 30 complete."
|
|
255
|
+
puts "=" * 62
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# NOTE: This file intentionally does NOT open module RobotLab::Ractor.
|
|
4
|
+
# Defining that module would shadow Ruby's built-in Ractor class for all
|
|
5
|
+
# code inside the RobotLab namespace, breaking Ractor.new / Ractor.make_shareable
|
|
6
|
+
# calls in RactorWorkerPool, RactorNetworkScheduler, etc.
|
|
7
|
+
|
|
8
|
+
require "etc"
|
|
9
|
+
require "ractor_queue"
|
|
10
|
+
require "ractor/wrapper"
|
|
11
|
+
|
|
12
|
+
require_relative "ractor/version"
|
|
13
|
+
require_relative "ractor_job"
|
|
14
|
+
require_relative "ractor_boundary"
|
|
15
|
+
require_relative "ractor_worker_pool"
|
|
16
|
+
require_relative "ractor_memory_proxy"
|
|
17
|
+
require_relative "ractor_network_scheduler"
|
|
18
|
+
|
|
19
|
+
# Extend the RobotLab module with Ractor pool lifecycle methods.
|
|
20
|
+
# Once robot_lab removes its own copies and adds robot_lab-ractor as a
|
|
21
|
+
# dependency, this becomes the single source of truth for ractor_pool.
|
|
22
|
+
module RobotLab
|
|
23
|
+
class << self
|
|
24
|
+
def ractor_pool
|
|
25
|
+
@ractor_pool ||= begin
|
|
26
|
+
size = respond_to?(:config) && config.respond_to?(:ractor_pool_size) \
|
|
27
|
+
? (config.ractor_pool_size || :auto) : :auto
|
|
28
|
+
RactorWorkerPool.new(size: size)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def shutdown_ractor_pool
|
|
33
|
+
@ractor_pool&.shutdown
|
|
34
|
+
@ractor_pool = nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Wraps a Memory instance via Ractor::Wrapper so Ractor workers can safely
|
|
5
|
+
# read and write shared state.
|
|
6
|
+
#
|
|
7
|
+
# Only get, set, and keys are proxied across the Ractor boundary.
|
|
8
|
+
# Subscriptions and callbacks are NOT proxied — closures are not
|
|
9
|
+
# Ractor-safe. Use the thread-side Memory directly for reactive subscriptions.
|
|
10
|
+
#
|
|
11
|
+
# Values passed to set() must be Ractor-shareable; RactorBoundary.freeze_deep
|
|
12
|
+
# is applied automatically.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# memory = Memory.new
|
|
16
|
+
# proxy = RactorMemoryProxy.new(memory)
|
|
17
|
+
#
|
|
18
|
+
# # From any Ractor via the stub:
|
|
19
|
+
# proxy.set(:result, "done")
|
|
20
|
+
# proxy.get(:result) #=> "done"
|
|
21
|
+
#
|
|
22
|
+
# proxy.shutdown # call when done
|
|
23
|
+
#
|
|
24
|
+
class RactorMemoryProxy
|
|
25
|
+
# @param memory [Memory] the memory instance to wrap
|
|
26
|
+
def initialize(memory)
|
|
27
|
+
@memory = memory
|
|
28
|
+
@wrapper = ::Ractor::Wrapper.new(memory, use_current_ractor: true)
|
|
29
|
+
@stub = @wrapper.stub
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the Ractor-shareable stub for use inside Ractors.
|
|
33
|
+
# @return [Ractor::Wrapper stub]
|
|
34
|
+
def stub
|
|
35
|
+
@stub
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Read a value from the proxied Memory.
|
|
39
|
+
# @param key [Symbol]
|
|
40
|
+
# @return [Object, nil]
|
|
41
|
+
def get(key)
|
|
42
|
+
@stub.get(key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Write a frozen value to the proxied Memory.
|
|
46
|
+
# @param key [Symbol]
|
|
47
|
+
# @param value [Object] must be Ractor-shareable after freeze_deep
|
|
48
|
+
# @raise [RactorBoundaryError] if value cannot be made shareable
|
|
49
|
+
def set(key, value)
|
|
50
|
+
frozen_value = RactorBoundary.freeze_deep(value)
|
|
51
|
+
@stub.set(key, frozen_value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# List all keys currently set in the proxied Memory.
|
|
55
|
+
# @return [Array<Symbol>]
|
|
56
|
+
def keys
|
|
57
|
+
@stub.keys
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Shut down the ractor-wrapper.
|
|
61
|
+
def shutdown
|
|
62
|
+
@wrapper.async_stop
|
|
63
|
+
@wrapper.join
|
|
64
|
+
rescue => e
|
|
65
|
+
warn "RactorMemoryProxy shutdown error: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Schedules frozen robot task descriptions across Ractor workers.
|
|
5
|
+
#
|
|
6
|
+
# Robots stay in threads for LLM calls (ruby_llm is not Ractor-safe).
|
|
7
|
+
# The scheduler distributes frozen RobotSpec payloads; each worker
|
|
8
|
+
# constructs a fresh Robot, runs the task, and returns a frozen result.
|
|
9
|
+
#
|
|
10
|
+
# Task ordering respects depends_on: tasks are only dispatched once all
|
|
11
|
+
# named dependencies have resolved (same topological semantics as
|
|
12
|
+
# SimpleFlow::Pipeline).
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# scheduler = RactorNetworkScheduler.new(memory: shared_memory)
|
|
16
|
+
# scheduler.run_pipeline([
|
|
17
|
+
# { spec: analyst_spec, depends_on: :none },
|
|
18
|
+
# { spec: writer_spec, depends_on: ["analyst"] }
|
|
19
|
+
# ], message: "Process this")
|
|
20
|
+
# scheduler.shutdown
|
|
21
|
+
#
|
|
22
|
+
class RactorNetworkScheduler
|
|
23
|
+
QUEUE_CAPACITY = 256
|
|
24
|
+
|
|
25
|
+
# @param memory [Memory] shared network memory for all robot tasks
|
|
26
|
+
# @param pool_size [Integer, :auto] number of Ractor workers
|
|
27
|
+
def initialize(memory:, pool_size: :auto)
|
|
28
|
+
@memory = memory
|
|
29
|
+
@work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
|
|
30
|
+
@size = pool_size == :auto ? Etc.nprocessors : pool_size.to_i
|
|
31
|
+
@workers = @size.times.map { spawn_worker(@work_q) }
|
|
32
|
+
@closed = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Run a single spec and return the result string.
|
|
36
|
+
# @param spec [RobotSpec]
|
|
37
|
+
# @param message [String]
|
|
38
|
+
# @return [String] the robot's last_text_content
|
|
39
|
+
def run_spec(spec, message:)
|
|
40
|
+
execute_spec(spec, message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Run a pipeline of specs in dependency order.
|
|
44
|
+
#
|
|
45
|
+
# @param specs_with_deps [Array<Hash>] each entry has :spec and :depends_on
|
|
46
|
+
# @param message [String] initial message passed to entry-point robots
|
|
47
|
+
# @return [Hash<String, String>] name => result for each completed robot
|
|
48
|
+
def run_pipeline(specs_with_deps, message:)
|
|
49
|
+
completed = {}
|
|
50
|
+
remaining = specs_with_deps.dup
|
|
51
|
+
|
|
52
|
+
until remaining.empty?
|
|
53
|
+
ready, remaining = remaining.partition do |entry|
|
|
54
|
+
deps = entry[:depends_on]
|
|
55
|
+
deps == :none || deps == :optional ||
|
|
56
|
+
Array(deps).all? { |d| completed.key?(d) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
raise RobotLab::Error, "Circular dependency or unresolvable deps in RactorNetworkScheduler" if ready.empty?
|
|
60
|
+
|
|
61
|
+
threads = ready.map do |entry|
|
|
62
|
+
spec = entry[:spec]
|
|
63
|
+
msg = completed.values.last || message
|
|
64
|
+
Thread.new { [spec.name, execute_spec(spec, msg)] }.tap { |t| t.report_on_exception = false }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
threads.each do |t|
|
|
68
|
+
name, result = t.value
|
|
69
|
+
completed[name] = result
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
completed
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Gracefully shut down worker Ractors.
|
|
77
|
+
def shutdown
|
|
78
|
+
return if @closed
|
|
79
|
+
|
|
80
|
+
@closed = true
|
|
81
|
+
@size.times { @work_q.push(nil) }
|
|
82
|
+
@workers.each { |w| w.join rescue nil }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def execute_spec(spec, message)
|
|
88
|
+
frozen_spec = ::Ractor.make_shareable(spec)
|
|
89
|
+
frozen_message = message.to_s.freeze
|
|
90
|
+
reply_q = RactorQueue.new(capacity: 1)
|
|
91
|
+
|
|
92
|
+
job = RactorJob.new(
|
|
93
|
+
id: SecureRandom.uuid.freeze,
|
|
94
|
+
type: :robot,
|
|
95
|
+
payload: RactorBoundary.freeze_deep({ spec: frozen_spec, message: frozen_message }),
|
|
96
|
+
reply_queue: reply_q
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@work_q.push(job)
|
|
100
|
+
result = reply_q.pop
|
|
101
|
+
|
|
102
|
+
if result.is_a?(RactorJobError)
|
|
103
|
+
raise RobotLab::Error, "Robot '#{spec.name}' failed in Ractor: #{result.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def spawn_worker(work_q)
|
|
110
|
+
::Ractor.new(work_q) do |q|
|
|
111
|
+
loop do
|
|
112
|
+
job = q.pop
|
|
113
|
+
break if job.nil?
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
spec = job.payload[:spec]
|
|
117
|
+
message = job.payload[:message]
|
|
118
|
+
|
|
119
|
+
robot = RobotLab::Robot.new(
|
|
120
|
+
name: spec.name,
|
|
121
|
+
template: spec.template ? spec.template.to_sym : nil,
|
|
122
|
+
system_prompt: spec.system_prompt,
|
|
123
|
+
config: spec.config_hash.empty? ? nil : RobotLab::RunConfig.new(**spec.config_hash.transform_keys(&:to_sym))
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
robot_result = robot.run(message)
|
|
127
|
+
job.reply_queue.push(robot_result.last_text_content.to_s.freeze)
|
|
128
|
+
rescue => e
|
|
129
|
+
err = RobotLab::RactorJobError.new(
|
|
130
|
+
message: e.message.freeze,
|
|
131
|
+
backtrace: (e.backtrace || []).map(&:freeze).freeze
|
|
132
|
+
)
|
|
133
|
+
job.reply_queue.push(err)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# A pool of Ractor workers that execute CPU-bound, Ractor-safe tools.
|
|
5
|
+
#
|
|
6
|
+
# Work is distributed via a shared RactorQueue. Each worker runs a
|
|
7
|
+
# blocking loop, pops RactorJob instances, dispatches to the named
|
|
8
|
+
# tool class, and pushes the frozen result (or a RactorJobError) to
|
|
9
|
+
# the job's per-job reply_queue.
|
|
10
|
+
#
|
|
11
|
+
# Shutdown uses a poison-pill pattern: one nil sentinel per worker is
|
|
12
|
+
# pushed to the work queue; each worker exits when it pops nil.
|
|
13
|
+
#
|
|
14
|
+
# Only tools that declare +ractor_safe true+ should be submitted.
|
|
15
|
+
# Tool classes are instantiated fresh inside the Ractor for each call.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# pool = RactorWorkerPool.new(size: 4)
|
|
19
|
+
# result = pool.submit("MyTool", { "arg" => "value" })
|
|
20
|
+
# pool.shutdown
|
|
21
|
+
#
|
|
22
|
+
class RactorWorkerPool
|
|
23
|
+
QUEUE_CAPACITY = 1024
|
|
24
|
+
|
|
25
|
+
attr_reader :size
|
|
26
|
+
|
|
27
|
+
# @param size [Integer, :auto] number of workers (:auto = Etc.nprocessors)
|
|
28
|
+
def initialize(size: :auto)
|
|
29
|
+
@size = size == :auto ? Etc.nprocessors : size.to_i
|
|
30
|
+
@closed = false
|
|
31
|
+
@work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
|
|
32
|
+
@workers = @size.times.map { spawn_worker(@work_q) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Submit a tool job and block until the result is available.
|
|
36
|
+
#
|
|
37
|
+
# @param tool_class_name [String] fully-qualified Ruby constant name of the tool class
|
|
38
|
+
# @param args [Hash] tool arguments (deep-frozen before crossing Ractor boundary)
|
|
39
|
+
# @return [Object] the tool's return value
|
|
40
|
+
# @raise [ToolError] if the tool raises inside the Ractor
|
|
41
|
+
def submit(tool_class_name, args)
|
|
42
|
+
raise ToolError, "Pool is shut down" if @closed
|
|
43
|
+
|
|
44
|
+
reply_q = RactorQueue.new(capacity: 1)
|
|
45
|
+
payload = RactorBoundary.freeze_deep({
|
|
46
|
+
tool_class: tool_class_name.to_s,
|
|
47
|
+
args: args
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
job = RactorJob.new(
|
|
51
|
+
id: SecureRandom.uuid.freeze,
|
|
52
|
+
type: :tool,
|
|
53
|
+
payload: payload,
|
|
54
|
+
reply_queue: reply_q
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@work_q.push(job)
|
|
58
|
+
result = reply_q.pop
|
|
59
|
+
|
|
60
|
+
if result.is_a?(RactorJobError)
|
|
61
|
+
raise ToolError, "Tool '#{tool_class_name}' failed in Ractor: #{result.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Gracefully shut down the pool via poison-pill pattern.
|
|
68
|
+
# @return [void]
|
|
69
|
+
def shutdown
|
|
70
|
+
return if @closed
|
|
71
|
+
|
|
72
|
+
@closed = true
|
|
73
|
+
@size.times { @work_q.push(nil) }
|
|
74
|
+
@workers.each { |w| w.join rescue nil }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def spawn_worker(work_q)
|
|
80
|
+
::Ractor.new(work_q) do |q|
|
|
81
|
+
loop do
|
|
82
|
+
job = q.pop
|
|
83
|
+
break if job.nil?
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
tool_class = Object.const_get(job.payload[:tool_class])
|
|
87
|
+
tool = tool_class.new
|
|
88
|
+
result = tool.execute(**job.payload[:args].transform_keys(&:to_sym))
|
|
89
|
+
frozen_result = ::Ractor.make_shareable(result.frozen? ? result : result.dup.freeze)
|
|
90
|
+
job.reply_queue.push(frozen_result)
|
|
91
|
+
rescue => e
|
|
92
|
+
err = RobotLab::RactorJobError.new(
|
|
93
|
+
message: e.message.freeze,
|
|
94
|
+
backtrace: (e.backtrace || []).map(&:freeze).freeze
|
|
95
|
+
)
|
|
96
|
+
job.reply_queue.push(err)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|