robot_lab-ractor 0.1.0 → 0.2.6
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/.envrc +2 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +3 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +71 -0
- data/Rakefile +127 -3
- data/docs/guides/ractor-parallelism.md +75 -0
- data/docs/index.md +6 -0
- data/examples/{29_ractor_tools.rb → 01_ractor_tools.rb} +9 -35
- data/examples/{30_ractor_network.rb → 02_ractor_network.rb} +8 -21
- data/examples/03_ractor_hooks.rb +399 -0
- data/examples/common.rb +79 -0
- data/examples/xyzzy.rb +88 -0
- data/lib/robot_lab/ractor/version.rb +1 -1
- data/lib/robot_lab/ractor.rb +18 -11
- data/lib/robot_lab/ractor_boundary.rb +1 -1
- data/lib/robot_lab/ractor_job.rb +5 -1
- data/lib/robot_lab/ractor_memory_proxy.rb +2 -4
- data/lib/robot_lab/ractor_network_scheduler.rb +56 -41
- data/lib/robot_lab/ractor_worker_pool.rb +29 -25
- metadata +18 -11
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 31: Hook System in Ractor Context
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates that RobotLab::Hook handler classes fire correctly inside
|
|
7
|
+
# Ractor workers. Handler classes are Ruby constants, so they cross Ractor
|
|
8
|
+
# boundaries natively and their class methods run in whichever Ractor calls them.
|
|
9
|
+
#
|
|
10
|
+
# The demo bypasses RobotLab::Robot and ruby_llm entirely (ruby_llm has class-
|
|
11
|
+
# level Proc callbacks that prevent construction inside Ractors today). Instead
|
|
12
|
+
# it calls TraceHook / PerfHook directly with a minimal mock context, isolating
|
|
13
|
+
# the hook mechanic from the LLM layer.
|
|
14
|
+
#
|
|
15
|
+
# Concepts covered:
|
|
16
|
+
# - Handler classes are Ractor-shareable (they are Ruby constants)
|
|
17
|
+
# - Hook class methods fire inside named Ractor workers
|
|
18
|
+
# - Hooks must be stateless in Ractor context — no class-level @ivars
|
|
19
|
+
# - Events accumulate in ctx.local (Ractor-local object); returned in result tuple
|
|
20
|
+
# - Timing aggregated on main thread from Ractor return values
|
|
21
|
+
# - on_error fires inside the failing Ractor; events surface via return tuple
|
|
22
|
+
# - RobotSpec.hook_classes: how registries feed handler classes to workers
|
|
23
|
+
# - Xyzzy covers all five hook families; fully Ractor-safe (no class-level state)
|
|
24
|
+
#
|
|
25
|
+
# I/O note (Ruby 4.x):
|
|
26
|
+
# $stdout.puts from non-main Ractors is buffered per-Ractor and flushes at
|
|
27
|
+
# Ractor exit — not interleaved with main-thread output. Kernel#warn is
|
|
28
|
+
# silently dropped. The STDOUT constant raises IsolationError.
|
|
29
|
+
# Correct pattern: accumulate events in ctx.local, return via result tuple.
|
|
30
|
+
#
|
|
31
|
+
# Usage (no LLM API key required):
|
|
32
|
+
# bundle exec ruby examples/03_ractor_hooks.rb
|
|
33
|
+
|
|
34
|
+
require_relative "common"
|
|
35
|
+
require_relative "../../robot_lab/examples/xyzzy"
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Minimal context objects — all top-level constants so Ractors can reach them
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
DemoRobot = Data.define(:name)
|
|
41
|
+
DemoReply = Data.define(:reply)
|
|
42
|
+
DemoNetwork = Data.define(:name) # used by network-run hook contexts (§5)
|
|
43
|
+
|
|
44
|
+
# Lean context for network_run hooks — carries a network, not a robot.
|
|
45
|
+
class DemoNetCtx
|
|
46
|
+
attr_reader :network
|
|
47
|
+
attr_accessor :request, :local
|
|
48
|
+
|
|
49
|
+
def initialize(name:, request: nil)
|
|
50
|
+
@network = DemoNetwork.new(name: name.freeze)
|
|
51
|
+
@request = request
|
|
52
|
+
@local = nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Lean context for task hooks — carries a task name string.
|
|
57
|
+
class DemoTaskCtx
|
|
58
|
+
attr_reader :task
|
|
59
|
+
attr_accessor :request, :local
|
|
60
|
+
|
|
61
|
+
def initialize(task:, request: nil)
|
|
62
|
+
@task = task.freeze
|
|
63
|
+
@request = request
|
|
64
|
+
@local = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Lean stand-in for RobotLab's RunHookContext.
|
|
69
|
+
# TraceHook and PerfHook only need: robot.name, response&.reply, error.message
|
|
70
|
+
class DemoCtx
|
|
71
|
+
attr_reader :robot, :request
|
|
72
|
+
attr_accessor :response, :error, :local
|
|
73
|
+
|
|
74
|
+
def initialize(name:, request: nil)
|
|
75
|
+
@robot = DemoRobot.new(name: name.freeze)
|
|
76
|
+
@request = request
|
|
77
|
+
@response = nil
|
|
78
|
+
@error = nil
|
|
79
|
+
@local = [] # accumulates hook event strings for this Ractor's run
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Hook handler classes — stateless by design
|
|
85
|
+
#
|
|
86
|
+
# Key Ractor rules:
|
|
87
|
+
# - A hook class IS a Ruby constant and IS Ractor-shareable.
|
|
88
|
+
# - But class-level instance variables (@events, @mutex, …) live in the
|
|
89
|
+
# class object in the main Ractor. Non-main Ractors cannot read or write
|
|
90
|
+
# them — IsolationError even if the values would be shareable.
|
|
91
|
+
# - $stdout.puts from non-main Ractors is buffered per-Ractor, flushing at
|
|
92
|
+
# Ractor exit. Output does not interleave with the main thread.
|
|
93
|
+
#
|
|
94
|
+
# Correct pattern: push events into ctx.local (a Ractor-local Array created
|
|
95
|
+
# inside the worker). The caller returns ctx.local as part of its result tuple
|
|
96
|
+
# so the main thread can aggregate and display the events.
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
# TraceHook records every lifecycle phase in ctx.local.
|
|
100
|
+
# Stateless: no class-level storage — safe to call from any Ractor.
|
|
101
|
+
class TraceHook < RobotLab::Hook
|
|
102
|
+
self.namespace = :trace
|
|
103
|
+
|
|
104
|
+
class << self
|
|
105
|
+
def before_run(ctx)
|
|
106
|
+
ctx.local << "[trace] before_run robot=#{ctx.robot.name.ljust(14)} ractor=#{worker_label}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def after_run(ctx)
|
|
110
|
+
reply = ctx.response&.reply.to_s[0, 44]
|
|
111
|
+
ctx.local << "[trace] after_run robot=#{ctx.robot.name.ljust(14)} ractor=#{worker_label} #{reply.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def on_error(ctx)
|
|
115
|
+
ctx.local << "[trace] on_error robot=#{ctx.robot.name.ljust(14)} ractor=#{worker_label} #{ctx.error.message[0, 48].inspect}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def worker_label
|
|
121
|
+
r = Ractor.current
|
|
122
|
+
r == Ractor.main ? "main" : (r.name || "ractor")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# PerfHook times each around_run call and stores the result in ctx.local.
|
|
128
|
+
# Returns [block_result, elapsed_ms] so the caller can include timing in
|
|
129
|
+
# the Ractor return tuple without any shared state.
|
|
130
|
+
class PerfHook < RobotLab::Hook
|
|
131
|
+
self.namespace = :perf
|
|
132
|
+
|
|
133
|
+
class << self
|
|
134
|
+
def around_run(ctx, &block)
|
|
135
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
136
|
+
result = block.call
|
|
137
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1_000).round(2)
|
|
138
|
+
ctx.local << "[perf] around_run robot=#{ctx.robot.name.ljust(14)} ractor=#{worker_label} #{ms}ms"
|
|
139
|
+
[result, ms]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def worker_label
|
|
145
|
+
r = Ractor.current
|
|
146
|
+
r == Ractor.main ? "main" : (r.name || "ractor")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ===========================================================================
|
|
152
|
+
banner("Hook System in Ractor Context")
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# 1. Shareability: handler classes are Ruby constants
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
section("1. Handler classes are Ractor-shareable")
|
|
158
|
+
|
|
159
|
+
[TraceHook, PerfHook, RobotLab::Xyzzy].each do |klass|
|
|
160
|
+
puts " Ractor.shareable?(#{klass.name}) => #{Ractor.shareable?(klass)}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
probe = RobotLab::RobotSpec.new(
|
|
164
|
+
name: "probe", template: nil, system_prompt: "probe".freeze,
|
|
165
|
+
config_hash: {}.freeze, hook_classes: [TraceHook, PerfHook, RobotLab::Xyzzy].freeze
|
|
166
|
+
)
|
|
167
|
+
puts " Ractor.shareable?(RobotSpec with hook_classes) => #{Ractor.shareable?(probe)}"
|
|
168
|
+
puts
|
|
169
|
+
puts " All three are Ruby constants — Ractor-shareable by definition."
|
|
170
|
+
puts " TraceHook / PerfHook are fully stateless: safe to call inside Ractors."
|
|
171
|
+
puts " Xyzzy is also fully Ractor-safe: LOG_PATH is a frozen constant;"
|
|
172
|
+
puts " each call opens its own File handle and uses PP.pp — no class-level state."
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# 2. Hook methods fire inside named Ractor workers — concurrent execution
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
section("2. Hooks firing inside named Ractor workers")
|
|
178
|
+
puts " Three Ractors run concurrently. Each receives [TraceHook, PerfHook, Xyzzy]"
|
|
179
|
+
puts " via its hook_classes argument."
|
|
180
|
+
puts " TraceHook / PerfHook: events accumulate in ctx.local, returned in tuple."
|
|
181
|
+
puts " Xyzzy: writes to $stdout + appends to LOG_PATH — both from inside the"
|
|
182
|
+
puts " Ractor. [xyzzy] lines flush at Ractor exit, appearing after Ractor work."
|
|
183
|
+
puts " Wall time ≈ max(latencies), not their sum — evidence of concurrency."
|
|
184
|
+
puts
|
|
185
|
+
|
|
186
|
+
DEMO_ROBOTS = [
|
|
187
|
+
{ name: "researcher", latency: 0.18 },
|
|
188
|
+
{ name: "analyst", latency: 0.12 },
|
|
189
|
+
{ name: "fact_checker", latency: 0.15 }
|
|
190
|
+
].freeze
|
|
191
|
+
|
|
192
|
+
hooks = [TraceHook, PerfHook, RobotLab::Xyzzy].freeze
|
|
193
|
+
|
|
194
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
195
|
+
|
|
196
|
+
workers = DEMO_ROBOTS.map do |entry|
|
|
197
|
+
rname = entry[:name].freeze
|
|
198
|
+
latency = entry[:latency]
|
|
199
|
+
|
|
200
|
+
Ractor.new(rname, latency, hooks, name: rname) do |name, lat, hook_classes|
|
|
201
|
+
ctx = DemoCtx.new(name: name, request: "AI governance frameworks")
|
|
202
|
+
|
|
203
|
+
# before_run — all handlers that implement it
|
|
204
|
+
hook_classes.each do |h|
|
|
205
|
+
h.before_run(ctx) if h.singleton_class.public_method_defined?(:before_run)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# around_run — PerfHook wraps and times the simulated work.
|
|
209
|
+
# Returns [result, elapsed_ms]; timing surfaces via the Ractor tuple.
|
|
210
|
+
work_result, ms = PerfHook.around_run(ctx) do
|
|
211
|
+
sleep lat
|
|
212
|
+
"#{name}: #{(lat * 1_000).round}ms of simulated processing".freeze
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
ctx.response = DemoReply.new(reply: work_result)
|
|
216
|
+
|
|
217
|
+
# after_run — all handlers that implement it
|
|
218
|
+
hook_classes.each do |h|
|
|
219
|
+
h.after_run(ctx) if h.singleton_class.public_method_defined?(:after_run)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
[name, work_result, ms, ctx.local.freeze].freeze
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
results = workers.map(&:value)
|
|
227
|
+
wall_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1_000).round(0)
|
|
228
|
+
seq_ms = DEMO_ROBOTS.sum { |e| e[:latency] * 1_000 }.round(0)
|
|
229
|
+
|
|
230
|
+
# Flatten results for display
|
|
231
|
+
hook_events = results.flat_map { |_, _, _, evts| evts }
|
|
232
|
+
timings = results.to_h { |name, _, ms, _| [name, ms] }
|
|
233
|
+
|
|
234
|
+
puts " Hook events (returned via Ractor result tuples):"
|
|
235
|
+
hook_events.each { |ev| puts " #{ev}" }
|
|
236
|
+
puts
|
|
237
|
+
puts " Wall time: #{wall_ms}ms vs sequential equivalent: #{seq_ms}ms"
|
|
238
|
+
puts " Speedup: #{(seq_ms.to_f / wall_ms).round(1)}× (#{DEMO_ROBOTS.size} Ractors running concurrently)"
|
|
239
|
+
puts
|
|
240
|
+
puts " Results:"
|
|
241
|
+
results.each { |name, val, _ms, _| puts " #{name.ljust(14)} #{val.inspect}" }
|
|
242
|
+
puts
|
|
243
|
+
puts " Timings (aggregated from return tuples on main thread):"
|
|
244
|
+
timings.each { |name, ms| puts " #{name.ljust(14)} #{ms}ms" }
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# 3. on_error fires inside the failing Ractor before the error propagates
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
section("3. on_error fires inside a Ractor worker")
|
|
250
|
+
puts " Two Ractors run simultaneously. The failing one raises, calls"
|
|
251
|
+
puts " on_error inside the Ractor, collects the event in ctx.local,"
|
|
252
|
+
puts " then returns {error: ..., events: [...]} so the main thread"
|
|
253
|
+
puts " can verify that on_error fired inside the worker."
|
|
254
|
+
puts " (In production the error re-raises as Ractor::RemoteError;"
|
|
255
|
+
puts " events are surfaced here via the return tuple for display.)"
|
|
256
|
+
puts
|
|
257
|
+
|
|
258
|
+
error_workers = [
|
|
259
|
+
Ractor.new("healthy_robot".freeze, [TraceHook, RobotLab::Xyzzy].freeze, name: "healthy_robot") do |name, hook_classes|
|
|
260
|
+
ctx = DemoCtx.new(name: name, request: "run")
|
|
261
|
+
hook_classes.each { |h| h.before_run(ctx) }
|
|
262
|
+
sleep 0.05
|
|
263
|
+
ctx.response = DemoReply.new(reply: "#{name}: ok".freeze)
|
|
264
|
+
hook_classes.each { |h| h.after_run(ctx) }
|
|
265
|
+
{ status: :ok, name: name, result: "ok", events: ctx.local.freeze }.freeze
|
|
266
|
+
end,
|
|
267
|
+
|
|
268
|
+
Ractor.new("failing_robot".freeze, [TraceHook, RobotLab::Xyzzy].freeze, name: "failing_robot") do |name, hook_classes|
|
|
269
|
+
ctx = DemoCtx.new(name: name, request: "run")
|
|
270
|
+
hook_classes.each { |h| h.before_run(ctx) }
|
|
271
|
+
begin
|
|
272
|
+
raise RuntimeError, "deliberate failure inside Ractor (#{name})"
|
|
273
|
+
rescue RuntimeError => e
|
|
274
|
+
ctx.error = e
|
|
275
|
+
hook_classes.each do |h|
|
|
276
|
+
h.on_error(ctx) if h.singleton_class.public_method_defined?(:on_error)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
# on_error ran inside this Ractor; return events so main thread can see them
|
|
280
|
+
{ status: :error, name: name, error: ctx.error.message, events: ctx.local.freeze }.freeze
|
|
281
|
+
end
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
error_workers.each do |worker|
|
|
285
|
+
r = worker.value
|
|
286
|
+
if r[:status] == :ok
|
|
287
|
+
puts " #{r[:name]}: #{r[:result]}"
|
|
288
|
+
puts " Events (healthy path):"
|
|
289
|
+
r[:events].each { |ev| puts " #{ev}" }
|
|
290
|
+
else
|
|
291
|
+
puts " #{r[:name]}: FAILED — #{r[:error]}"
|
|
292
|
+
puts " Events (on_error fired inside the Ractor before returning to main):"
|
|
293
|
+
r[:events].each { |ev| puts " #{ev}" }
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
puts
|
|
298
|
+
puts " The on_error event above was recorded inside the failing Ractor."
|
|
299
|
+
puts " The main thread only learns of the failure when it calls worker.value."
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# 4. How hook_classes flows from registries to Ractor workers
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
section("4. Hook delivery via RobotSpec.hook_classes")
|
|
305
|
+
puts " When Network#run_with_ractor_scheduler builds each RobotSpec:"
|
|
306
|
+
puts
|
|
307
|
+
puts " def ractor_hook_classes_for(robot)"
|
|
308
|
+
puts " [RobotLab.hooks, @hooks, robot.hooks]"
|
|
309
|
+
puts " .flat_map { |r| r.registrations.map(&:handler_class) }"
|
|
310
|
+
puts " .uniq.freeze"
|
|
311
|
+
puts " end"
|
|
312
|
+
puts
|
|
313
|
+
puts " The resulting frozen Array of Class objects is stored in"
|
|
314
|
+
puts " RobotSpec.hook_classes. Inside each Ractor worker:"
|
|
315
|
+
puts
|
|
316
|
+
puts " spec.hook_classes.each { |handler| robot.on(handler) }"
|
|
317
|
+
puts " robot.run(message) # full hook pipeline fires here"
|
|
318
|
+
puts
|
|
319
|
+
puts " Because Classes are Ruby constants, they survive the Ractor boundary"
|
|
320
|
+
puts " without any serialization. The hook pipeline inside the worker is"
|
|
321
|
+
puts " identical to what would fire on the main thread — as seen in §2 & §3."
|
|
322
|
+
puts
|
|
323
|
+
puts " Registration level → collected by"
|
|
324
|
+
puts " RobotLab.on(Hook) → RobotLab.hooks (global)"
|
|
325
|
+
puts " network.on(Hook) → @hooks (network)"
|
|
326
|
+
puts " robot.on(Hook) → robot.hooks (robot)"
|
|
327
|
+
puts
|
|
328
|
+
puts " Stateless hook design rules:"
|
|
329
|
+
puts " ✓ Accumulate events in ctx.local — returned in Ractor result tuple"
|
|
330
|
+
puts " ✓ Return timing / metrics via result tuple — aggregated on main thread"
|
|
331
|
+
puts " ✓ $stderr.puts from Ractors — immediate output (unbuffered)"
|
|
332
|
+
puts " ✓ File.open(frozen_path, 'a') inside Ractor — Ractor-local handle, safe"
|
|
333
|
+
puts " ✓ PP.pp / JSON.generate inside Ractors — stdlib, fully Ractor-safe"
|
|
334
|
+
puts " ✗ Class-level @ivars — inaccessible from non-main Ractors"
|
|
335
|
+
puts " ✗ Class variables (@@var) — inaccessible from non-main Ractors"
|
|
336
|
+
puts " ✗ $stdout.puts from Ractors — buffered per-Ractor, not interleaved"
|
|
337
|
+
puts " ✗ Kernel#warn from Ractors — silently dropped (Ruby 4.x)"
|
|
338
|
+
puts " ✗ STDOUT constant — IsolationError from non-main Ractors"
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# 5. Xyzzy: all hook families exercised from the main thread
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
section("5. Xyzzy: all five hook families exercised")
|
|
344
|
+
puts " Xyzzy is registered globally (RobotLab.on) and covers every hook"
|
|
345
|
+
puts " family. It is fully Ractor-safe: LOG_PATH is a frozen constant,"
|
|
346
|
+
puts " each call opens its own File handle and formats with PP.pp."
|
|
347
|
+
puts " stdout: one [xyzzy] tagline per call | log: full context snapshot"
|
|
348
|
+
puts " Log file: #{RobotLab::Xyzzy::LOG_PATH}"
|
|
349
|
+
puts
|
|
350
|
+
|
|
351
|
+
# ── run family ──
|
|
352
|
+
puts " [run family]"
|
|
353
|
+
run_ctx = DemoCtx.new(name: "xyzzy_bot", request: "demonstrate hooks")
|
|
354
|
+
RobotLab::Xyzzy.before_run(run_ctx)
|
|
355
|
+
RobotLab::Xyzzy.around_run(run_ctx) do
|
|
356
|
+
run_ctx.response = DemoReply.new(reply: "xyzzy_bot: ok".freeze)
|
|
357
|
+
end
|
|
358
|
+
RobotLab::Xyzzy.after_run(run_ctx)
|
|
359
|
+
run_ctx.error = RuntimeError.new("simulated run error")
|
|
360
|
+
RobotLab::Xyzzy.on_error(run_ctx)
|
|
361
|
+
puts
|
|
362
|
+
|
|
363
|
+
# ── llm_generation family ──
|
|
364
|
+
puts " [llm_generation family]"
|
|
365
|
+
llm_ctx = DemoCtx.new(name: "xyzzy_bot", request: "generate text")
|
|
366
|
+
RobotLab::Xyzzy.before_llm_generation(llm_ctx)
|
|
367
|
+
RobotLab::Xyzzy.around_llm_generation(llm_ctx) { nil }
|
|
368
|
+
RobotLab::Xyzzy.after_llm_generation(llm_ctx)
|
|
369
|
+
puts
|
|
370
|
+
|
|
371
|
+
# ── tool_call family ──
|
|
372
|
+
puts " [tool_call family]"
|
|
373
|
+
tool_ctx = DemoCtx.new(name: "xyzzy_bot", request: "call word_stats")
|
|
374
|
+
RobotLab::Xyzzy.before_tool_call(tool_ctx)
|
|
375
|
+
RobotLab::Xyzzy.around_tool_call(tool_ctx) { nil }
|
|
376
|
+
RobotLab::Xyzzy.after_tool_call(tool_ctx)
|
|
377
|
+
puts
|
|
378
|
+
|
|
379
|
+
# ── network_run family ──
|
|
380
|
+
puts " [network_run family]"
|
|
381
|
+
net_ctx = DemoNetCtx.new(name: "research_pipeline", request: "run pipeline")
|
|
382
|
+
RobotLab::Xyzzy.before_network_run(net_ctx)
|
|
383
|
+
RobotLab::Xyzzy.around_network_run(net_ctx) { nil }
|
|
384
|
+
RobotLab::Xyzzy.after_network_run(net_ctx)
|
|
385
|
+
puts
|
|
386
|
+
|
|
387
|
+
# ── task family ──
|
|
388
|
+
puts " [task family]"
|
|
389
|
+
task_ctx = DemoTaskCtx.new(task: "headline_finder", request: "run task")
|
|
390
|
+
RobotLab::Xyzzy.before_task(task_ctx)
|
|
391
|
+
RobotLab::Xyzzy.around_task(task_ctx) { nil }
|
|
392
|
+
RobotLab::Xyzzy.after_task(task_ctx)
|
|
393
|
+
|
|
394
|
+
puts
|
|
395
|
+
puts " Context snapshots (with robot/network/task/error details)"
|
|
396
|
+
puts " written to: #{RobotLab::Xyzzy::LOG_PATH}"
|
|
397
|
+
|
|
398
|
+
puts
|
|
399
|
+
hr
|
data/examples/common.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
$LOAD_PATH.unshift File.expand_path("../../robot_lab/lib", __dir__)
|
|
6
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
7
|
+
|
|
8
|
+
require "robot_lab"
|
|
9
|
+
require "robot_lab/ractor"
|
|
10
|
+
|
|
11
|
+
# Unbuffered stdout so Ractor-generated output interleaves correctly with
|
|
12
|
+
# main-thread headers when viewed in a terminal or redirected to a pipe.
|
|
13
|
+
$stdout.sync = true
|
|
14
|
+
|
|
15
|
+
# Fallback for when direnv has not activated examples/.envrc
|
|
16
|
+
ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
|
|
17
|
+
|
|
18
|
+
RubyLLM.configure do |c|
|
|
19
|
+
c.logger = Logger.new(File::NULL)
|
|
20
|
+
c.default_model = "claude-haiku-4-5-20251001"
|
|
21
|
+
c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
22
|
+
c.openai_api_key = ENV["OPENAI_API_KEY"]
|
|
23
|
+
c.openai_organization_id = ENV["OPENAI_ORGANIZATION_ID"]
|
|
24
|
+
c.openai_project_id = ENV["OPENAI_PROJECT_ID"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
RobotLab.configure do |c|
|
|
28
|
+
c.logger = Logger.new(File::NULL)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ── Output Helpers ─────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
module ExOut
|
|
34
|
+
WIDTH = 62
|
|
35
|
+
RESET = "\e[0m"
|
|
36
|
+
BOLD = "\e[1m"
|
|
37
|
+
DIM = "\e[2m"
|
|
38
|
+
CYAN = "\e[36m"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Prints a bold top-level banner. Extracts the example number from $0
|
|
42
|
+
# automatically so callers only supply the title.
|
|
43
|
+
def banner(title)
|
|
44
|
+
num = File.basename($0, ".rb")[/^\d+/]&.to_i
|
|
45
|
+
label = num ? "Example #{num}: #{title}" : title
|
|
46
|
+
puts "#{ExOut::BOLD}#{"=" * ExOut::WIDTH}#{ExOut::RESET}"
|
|
47
|
+
puts "#{ExOut::BOLD} #{label}#{ExOut::RESET}"
|
|
48
|
+
puts "#{ExOut::BOLD}#{"=" * ExOut::WIDTH}#{ExOut::RESET}"
|
|
49
|
+
puts
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Prints a named section divider.
|
|
53
|
+
def section(title)
|
|
54
|
+
puts
|
|
55
|
+
tail = "─" * [ExOut::WIDTH - title.length - 4, 2].max
|
|
56
|
+
puts "#{ExOut::BOLD}#{ExOut::CYAN}── #{title} #{tail}#{ExOut::RESET}"
|
|
57
|
+
puts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Prints a plain horizontal rule.
|
|
61
|
+
def hr
|
|
62
|
+
puts "#{ExOut::DIM}#{"─" * ExOut::WIDTH}#{ExOut::RESET}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Prints a Rouge-highlighted Ruby snippet with a framed border.
|
|
66
|
+
def show_code(ruby_string, label: "ruby")
|
|
67
|
+
require "rouge"
|
|
68
|
+
w = ExOut::WIDTH - 2
|
|
69
|
+
border = "#{ExOut::DIM} #{"─" * w}#{ExOut::RESET}"
|
|
70
|
+
output = Rouge::Formatters::Terminal256.new
|
|
71
|
+
.format(Rouge::Lexers::Ruby.new.lex(ruby_string))
|
|
72
|
+
output += "\n" unless output.end_with?("\n")
|
|
73
|
+
puts
|
|
74
|
+
puts "#{ExOut::DIM} #{label}#{ExOut::RESET}"
|
|
75
|
+
puts border
|
|
76
|
+
output.each_line { |l| print " #{l}" }
|
|
77
|
+
puts border
|
|
78
|
+
puts
|
|
79
|
+
end
|
data/examples/xyzzy.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# xyzzy.rb — single-file RobotLab hook extension demo
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates the Hook handler class pattern: implement class methods for
|
|
6
|
+
# every hook under a dedicated namespace and log each callback with its
|
|
7
|
+
# context snapshot. Useful as a live trace of the full hook pipeline during
|
|
8
|
+
# development.
|
|
9
|
+
#
|
|
10
|
+
# stdout : one tagline per hook call → [xyzzy] HH:MM:SS.mmm hook_name
|
|
11
|
+
# logfile: full context snapshot for each call (set via Xyzzy.logger=)
|
|
12
|
+
#
|
|
13
|
+
# Usage (from any example that requires common):
|
|
14
|
+
# require_relative "xyzzy"
|
|
15
|
+
|
|
16
|
+
require "logger"
|
|
17
|
+
|
|
18
|
+
module RobotLab
|
|
19
|
+
class Xyzzy < Hook
|
|
20
|
+
self.namespace = :xyzzy
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
attr_writer :logger
|
|
24
|
+
|
|
25
|
+
def logger
|
|
26
|
+
@logger ||= Logger.new($stdout)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def before_run(ctx) = log_hook(:before_run, ctx)
|
|
30
|
+
def after_run(ctx) = log_hook(:after_run, ctx)
|
|
31
|
+
def on_error(ctx) = log_hook(:on_error, ctx)
|
|
32
|
+
def before_llm_generation(ctx) = log_hook(:before_llm_generation, ctx)
|
|
33
|
+
def after_llm_generation(ctx) = log_hook(:after_llm_generation, ctx)
|
|
34
|
+
def before_tool_call(ctx) = log_hook(:before_tool_call, ctx)
|
|
35
|
+
def after_tool_call(ctx) = log_hook(:after_tool_call, ctx)
|
|
36
|
+
def before_network_run(ctx) = log_hook(:before_network_run, ctx)
|
|
37
|
+
def after_network_run(ctx) = log_hook(:after_network_run, ctx)
|
|
38
|
+
def before_task(ctx) = log_hook(:before_task, ctx)
|
|
39
|
+
def after_task(ctx) = log_hook(:after_task, ctx)
|
|
40
|
+
|
|
41
|
+
def around_run(ctx, &block)
|
|
42
|
+
log_hook(:around_run, ctx)
|
|
43
|
+
block.call
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def around_llm_generation(ctx, &block)
|
|
47
|
+
log_hook(:around_llm_generation, ctx)
|
|
48
|
+
block.call
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def around_tool_call(ctx, &block)
|
|
52
|
+
log_hook(:around_tool_call, ctx)
|
|
53
|
+
block.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def around_network_run(ctx, &block)
|
|
57
|
+
log_hook(:around_network_run, ctx)
|
|
58
|
+
block.call
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def around_task(ctx, &block)
|
|
62
|
+
log_hook(:around_task, ctx)
|
|
63
|
+
block.call
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def log_hook(hook_name, ctx)
|
|
69
|
+
ts = Time.now.strftime('%H:%M:%S.%3N')
|
|
70
|
+
puts " [xyzzy] #{ts} #{hook_name}"
|
|
71
|
+
logger.info("#{ts} #{hook_name} #{context_snapshot(ctx)}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def context_snapshot(ctx)
|
|
75
|
+
{
|
|
76
|
+
robot: ctx.respond_to?(:robot) ? ctx.robot&.name : nil,
|
|
77
|
+
request: ctx.respond_to?(:request) ? ctx.request : nil,
|
|
78
|
+
network: ctx.respond_to?(:network) ? ctx.network&.name : nil,
|
|
79
|
+
task: ctx.respond_to?(:task) ? ctx.task : nil,
|
|
80
|
+
error: ctx.respond_to?(:error) ? ctx.error&.message : nil
|
|
81
|
+
}.compact
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
RobotLab.register_extension(:xyzzy, RobotLab::Xyzzy) if RobotLab.respond_to?(:register_extension)
|
|
88
|
+
RobotLab.on(RobotLab::Xyzzy)
|
data/lib/robot_lab/ractor.rb
CHANGED
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
# code inside the RobotLab namespace, breaking Ractor.new / Ractor.make_shareable
|
|
6
6
|
# calls in RactorWorkerPool, RactorNetworkScheduler, etc.
|
|
7
7
|
|
|
8
|
-
require
|
|
9
|
-
require
|
|
10
|
-
require
|
|
8
|
+
require 'etc'
|
|
9
|
+
require 'ractor_queue'
|
|
10
|
+
require 'ractor/wrapper'
|
|
11
11
|
|
|
12
|
-
require_relative
|
|
13
|
-
require_relative
|
|
14
|
-
require_relative
|
|
15
|
-
require_relative
|
|
16
|
-
require_relative
|
|
17
|
-
require_relative
|
|
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
18
|
|
|
19
19
|
# Extend the RobotLab module with Ractor pool lifecycle methods.
|
|
20
20
|
# Once robot_lab removes its own copies and adds robot_lab-ractor as a
|
|
@@ -23,8 +23,11 @@ module RobotLab
|
|
|
23
23
|
class << self
|
|
24
24
|
def ractor_pool
|
|
25
25
|
@ractor_pool ||= begin
|
|
26
|
-
size = respond_to?(:config) && config.respond_to?(:ractor_pool_size)
|
|
27
|
-
|
|
26
|
+
size = if respond_to?(:config) && config.respond_to?(:ractor_pool_size)
|
|
27
|
+
config.ractor_pool_size || :auto
|
|
28
|
+
else
|
|
29
|
+
:auto
|
|
30
|
+
end
|
|
28
31
|
RactorWorkerPool.new(size: size)
|
|
29
32
|
end
|
|
30
33
|
end
|
|
@@ -35,3 +38,7 @@ module RobotLab
|
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
end
|
|
41
|
+
|
|
42
|
+
if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
|
|
43
|
+
RobotLab.register_extension(:ractor, :ractor_extension_loaded)
|
|
44
|
+
end
|
data/lib/robot_lab/ractor_job.rb
CHANGED
|
@@ -33,5 +33,9 @@ module RobotLab
|
|
|
33
33
|
# system_prompt: nil,
|
|
34
34
|
# config_hash: { model: "claude-sonnet-4" }.freeze
|
|
35
35
|
# )
|
|
36
|
-
RobotSpec = Data.define(:name, :template, :system_prompt, :config_hash)
|
|
36
|
+
RobotSpec = Data.define(:name, :template, :system_prompt, :config_hash, :hook_classes) do
|
|
37
|
+
def initialize(name:, template:, system_prompt:, config_hash:, hook_classes: [].freeze)
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
37
41
|
end
|
|
@@ -31,9 +31,7 @@ module RobotLab
|
|
|
31
31
|
|
|
32
32
|
# Returns the Ractor-shareable stub for use inside Ractors.
|
|
33
33
|
# @return [Ractor::Wrapper stub]
|
|
34
|
-
|
|
35
|
-
@stub
|
|
36
|
-
end
|
|
34
|
+
attr_reader :stub
|
|
37
35
|
|
|
38
36
|
# Read a value from the proxied Memory.
|
|
39
37
|
# @param key [Symbol]
|
|
@@ -61,7 +59,7 @@ module RobotLab
|
|
|
61
59
|
def shutdown
|
|
62
60
|
@wrapper.async_stop
|
|
63
61
|
@wrapper.join
|
|
64
|
-
rescue => e
|
|
62
|
+
rescue StandardError => e
|
|
65
63
|
warn "RactorMemoryProxy shutdown error: #{e.message}"
|
|
66
64
|
end
|
|
67
65
|
end
|