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.
@@ -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
@@ -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)
@@ -4,6 +4,6 @@ module RobotLab
4
4
  # RactorPool (not Ractor) avoids shadowing Ruby's built-in Ractor class
5
5
  # for all code within the RobotLab namespace.
6
6
  module RactorPool
7
- VERSION = "0.1.0"
7
+ VERSION = '0.2.6'
8
8
  end
9
9
  end
@@ -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 "etc"
9
- require "ractor_queue"
10
- require "ractor/wrapper"
8
+ require 'etc'
9
+ require 'ractor_queue'
10
+ require 'ractor/wrapper'
11
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"
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
- ? (config.ractor_pool_size || :auto) : :auto
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
@@ -35,7 +35,7 @@ module RobotLab
35
35
  end
36
36
 
37
37
  Ractor.make_shareable(result)
38
- rescue ::Ractor::IsolationError, ::Ractor::Error => e
38
+ rescue ::Ractor::Error => e
39
39
  raise RactorBoundaryError, "Cannot make value Ractor-shareable: #{e.message}"
40
40
  end
41
41
  end
@@ -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
- def stub
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