robot_lab-ractor 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25de3bcf1d2904408d989619f14f2204b556d5cc7412683793d1e108c986192f
4
- data.tar.gz: 412b11c1532be8674b9ec1767471d42909dd20e98cf2ca6661f143dbeccbe68f
3
+ metadata.gz: 7df9c3ab4660c44f064946a5a90f071df1ad03baa1b4cac8c3259156a35be70b
4
+ data.tar.gz: 0fb20202b60255f45be1b423d5fa5870730cfd4fbf3dbaee1ec601eb72a87f59
5
5
  SHA512:
6
- metadata.gz: ce3e69d19db119d2f28e0c4b557826271f09169d8f5adaf811787f2ffba9bd637ace4aad2f5e8f525781d5c59c6e995bd851f26bac222a5e695d3ac7a6e08c1d
7
- data.tar.gz: b3a6f73c02fd8e006dc71c1cea906adb1d47051c5c584d30b45ba374f9c43a036f0782a3f60a72fde5463cd78503cc7b0292a77ded5858b52d5ef9edbc693530
6
+ metadata.gz: '0418c60678b46d1c7b9e3a625f8e2c3b1ebf789a2f526daea6f05d5c8a9046a11e54217bea1dd8fd15a50bae6115ea634907e002adc96d7d06f736c626f05d20'
7
+ data.tar.gz: c8800eee270d7987d17658a97c7dce82074ce8db09b67d7a598d92c1e29b15138d07e242f4a5de86fe0a812cadbb10c0ce02736da6d80f60cea36f24b22b4779
data/.envrc CHANGED
@@ -1 +1,3 @@
1
+ source_up
1
2
  export RR=`pwd`
3
+ export BUNDLE_GEMFILE=Gemfile
data/.loki ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # robot_lab-ractor — CPU parallelism via Ractors.
3
+
4
+ import_up "repo_dev.loki"
5
+
6
+ class Tasks
7
+ @@gem_name ||= "robot_lab-ractor".freeze
8
+
9
+ header "robot_lab-ractor v#{gem_version} — CPU-parallel robot execution"
10
+ end
@@ -0,0 +1,3 @@
1
+ 1 lib/robot_lab/ractor_boundary.rb
2
+ 3 lib/robot_lab/ractor_network_scheduler.rb
3
+ 1 lib/robot_lab/ractor_worker_pool.rb
data/.rubocop.yml CHANGED
@@ -1,173 +1 @@
1
- AllCops:
2
- NewCops: enable
3
- SuggestExtensions: false
4
- TargetRubyVersion: 4.0
5
- Exclude:
6
- - 'examples/**/*'
7
- - 'vendor/**/*'
8
- - 'dead_code/**/*'
9
-
10
- # ── Style: disabled cops ───────────────────────────────────────────────────
11
- Style/StringLiterals:
12
- Enabled: false
13
-
14
- Style/StringLiteralsInInterpolation:
15
- Enabled: false
16
-
17
- Style/Documentation:
18
- Enabled: false
19
-
20
- # Ruby 4.0 freezes string literals by default
21
- Style/FrozenStringLiteralComment:
22
- Enabled: false
23
-
24
- Style/IfUnlessModifier:
25
- Enabled: false
26
-
27
- Style/RescueModifier:
28
- Enabled: false
29
-
30
- Style/TrivialAccessors:
31
- Enabled: false
32
-
33
- Style/MultilineTernaryOperator:
34
- Enabled: false
35
-
36
- Style/SafeNavigation:
37
- Enabled: false
38
-
39
- Style/EmptyClassDefinition:
40
- Enabled: false
41
-
42
- Style/ClassAndModuleChildren:
43
- Enabled: false
44
-
45
- Style/RescueStandardError:
46
- Enabled: false
47
-
48
- Style/OneClassPerFile:
49
- Enabled: false
50
-
51
- # Both % and format/sprintf are acceptable
52
- Style/FormatString:
53
- Enabled: false
54
-
55
- # String concatenation and interpolation are both acceptable
56
- Style/StringConcatenation:
57
- Enabled: false
58
-
59
- # ── Layout ─────────────────────────────────────────────────────────────────
60
- Layout/LineLength:
61
- Max: 140
62
-
63
- Layout/ExtraSpacing:
64
- Enabled: false
65
-
66
- Layout/HashAlignment:
67
- Enabled: false
68
-
69
- Layout/FirstHashElementIndentation:
70
- Enabled: false
71
-
72
- Layout/EmptyLineAfterGuardClause:
73
- Enabled: false
74
-
75
- # ── Naming ─────────────────────────────────────────────────────────────────
76
- # Single-char params (c, e, n) are acceptable throughout
77
- Naming/MethodParameterName:
78
- Enabled: false
79
-
80
- Naming/VariableNumber:
81
- Exclude:
82
- - 'test/**/*'
83
-
84
- Naming/RescuedExceptionsVariableName:
85
- Enabled: false
86
-
87
- # set_results and similar explicit setters are clear and conventional
88
- Naming/AccessorMethodName:
89
- Enabled: false
90
-
91
-
92
- # has_tool_calls? and similar are clear and conventional
93
- Naming/PredicatePrefix:
94
- Enabled: false
95
-
96
- # Test helper methods don't need to follow predicate naming rules
97
- Naming/PredicateMethod:
98
- Exclude:
99
- - 'test/**/*'
100
-
101
- # ── Lint: relax noisy cops on intentional patterns ─────────────────────────
102
- # Library and framework methods commonly accept args for API/documentation purposes
103
- Lint/UnusedMethodArgument:
104
- Enabled: false
105
-
106
-
107
- Lint/EmptyBlock:
108
- Exclude:
109
- - 'test/**/*'
110
-
111
- Lint/ConstantDefinitionInBlock:
112
- Exclude:
113
- - 'Rakefile'
114
- - 'test/**/*'
115
-
116
- # ── Gemspec ────────────────────────────────────────────────────────────────
117
- Gemspec/DevelopmentDependencies:
118
- EnforcedStyle: Gemfile
119
-
120
- Gemspec/RequiredRubyVersion:
121
- Enabled: false
122
-
123
- Gemspec/OrderedDependencies:
124
- Enabled: false
125
-
126
- # ── Metrics ────────────────────────────────────────────────────────────────
127
- # Framework-level code (routers, parsers, orchestrators) is inherently complex.
128
- # Flog is the primary complexity gate — these RuboCop thresholds catch only
129
- # egregious outliers without false-positiving every dispatch method.
130
-
131
- Metrics/MethodLength:
132
- Max: 35
133
- CountAsOne:
134
- - heredoc
135
- - array
136
- - hash
137
- Exclude:
138
- - 'test/**/*'
139
-
140
- Metrics/AbcSize:
141
- Max: 40
142
- Exclude:
143
- - 'test/**/*'
144
-
145
- Metrics/ClassLength:
146
- Max: 600
147
- Exclude:
148
- - 'test/**/*'
149
-
150
- Metrics/ModuleLength:
151
- Max: 200
152
- Exclude:
153
- - 'test/**/*'
154
-
155
- Metrics/CyclomaticComplexity:
156
- Max: 20
157
- Exclude:
158
- - 'test/**/*'
159
-
160
- Metrics/PerceivedComplexity:
161
- Max: 20
162
- Exclude:
163
- - 'test/**/*'
164
-
165
- # Long method signatures with keyword args are a Ruby framework idiom
166
- Metrics/ParameterLists:
167
- Enabled: false
168
-
169
- Metrics/BlockLength:
170
- Exclude:
171
- - 'Rakefile'
172
- - '*.gemspec'
173
- - 'test/**/*'
1
+ inherit_from: ../.rubocop-base.yml
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+ - `.loki` Asgard task file: `test`, `rubocop`, `rubocop_fix`, `flog`, `flay`, `quality`, `build`, `install`, `release`, and `console` tasks via the Asgard task runner
5
+ - `flay_check` Rake task: structural code duplication gate (mass threshold 50); integrated into the `quality` Rake task
6
+ - `flay` and `minitest-reporters` gems added to development dependencies
7
+ - `test_output.txt`, `flay_output.txt`, `flog_output.txt`, and `rubocop_output.txt` added to `.gitignore`
8
+
9
+ ### Changed
10
+ - `test/test_helper.rb`: test output redirected to `test_output.txt` via `$stdout` reassignment; `TerminalSummaryReporter` prints a single PASS/FAIL summary line to the terminal
11
+ - `Rakefile`: `rubocop` and `rubocop_fix` tasks removed (now owned by Asgard); `flay_check` integrated into the `quality` gate
12
+
3
13
  ## [0.2.1] - 2026-05-19
4
14
 
5
15
  ### Added
data/CLAUDE.md ADDED
@@ -0,0 +1,71 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Gem Does
6
+
7
+ `robot_lab-ractor` enables CPU-parallel execution in RobotLab via Ruby Ractors. It provides a worker pool for CPU-bound tools and a dependency-aware network scheduler for running robot pipelines across Ractor workers.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ bundle exec rake test # Run full test suite
13
+ ruby -Ilib:test test/<file> # Run a single test file
14
+ ```
15
+
16
+ ## CRITICAL: Never define `module RobotLab::Ractor`
17
+
18
+ `lib/robot_lab/ractor.rb` intentionally does **not** open `module RobotLab::Ractor`. Defining that module would shadow Ruby's built-in `Ractor` class for all code inside the `RobotLab` namespace, breaking `Ractor.new`, `Ractor.make_shareable`, etc. throughout the codebase. The version constant lives in `RobotLab::RactorPool::VERSION` to avoid this clash.
19
+
20
+ ## Architecture
21
+
22
+ **`RactorWorkerPool`** (`ractor_worker_pool.rb`) — Pool of Ractor workers for CPU-bound, Ractor-safe tools. Submit a job from any thread; the result is returned synchronously via a per-job `RactorQueue`.
23
+
24
+ ```ruby
25
+ pool = RactorWorkerPool.new(size: 4) # :auto = Etc.nprocessors
26
+ result = pool.submit("MyTool", { arg: "value" })
27
+ pool.shutdown
28
+ ```
29
+
30
+ Accessed globally via `RobotLab.ractor_pool` / `RobotLab.shutdown_ractor_pool`.
31
+
32
+ **`RactorNetworkScheduler`** (`ractor_network_scheduler.rb`) — Schedules frozen `RobotSpec` payloads across Ractor workers in dependency order. Entry-point robots (`:none` deps) dispatch immediately; dependent robots dispatch once all named deps complete.
33
+
34
+ ```ruby
35
+ scheduler = RactorNetworkScheduler.new(memory: shared_memory)
36
+ results = scheduler.run_pipeline([
37
+ { spec: analyst_spec, depends_on: :none },
38
+ { spec: writer_spec, depends_on: ["analyst"] }
39
+ ], message: "Analyse this")
40
+ scheduler.shutdown
41
+ ```
42
+
43
+ **`RactorBoundary`** (`ractor_boundary.rb`) — Utility for crossing Ractor boundaries. `freeze_deep(obj)` recursively freezes Hash/Array structures and calls `Ractor.make_shareable`. Raises `RactorBoundaryError` if the value cannot be made shareable (e.g. a live IO or Proc).
44
+
45
+ **`RactorMemoryProxy`** (`ractor_memory_proxy.rb`) — Wraps a `Memory` instance via `Ractor::Wrapper` so Ractor workers can read and write shared state. Only `get`, `set`, and `keys` are proxied; closures and subscriptions are not Ractor-safe and must use the thread-side Memory directly.
46
+
47
+ **Data Carriers** (`ractor_job.rb`) — Three frozen `Data.define` structs:
48
+ - `RactorJob` — work item: `id`, `type`, `payload`, `reply_queue`
49
+ - `RactorJobError` — serialised error: `message`, `backtrace`
50
+ - `RobotSpec` — frozen robot descriptor: `name`, `template`, `system_prompt`, `config_hash`, `hook_classes`
51
+
52
+ ## Tool Requirements for Ractor Safety
53
+
54
+ Tools submitted to `RactorWorkerPool` must:
55
+ 1. Be instantiated fresh per call (no shared mutable state)
56
+ 2. Accept only Ractor-shareable arguments (frozen strings, numbers, frozen hashes)
57
+ 3. Return a value that can be `Ractor.make_shareable` (frozen or deep-freezable)
58
+
59
+ LLM calls via `ruby_llm` are NOT Ractor-safe — do not submit tools that call the LLM to the pool.
60
+
61
+ ## Key Constraints
62
+
63
+ - Shutdown is via poison-pill (one `nil` per worker) — always call `shutdown` to avoid orphaned Ractors.
64
+ - `RactorNetworkScheduler.process_job` and `RactorWorkerPool.process_job` must be class methods — Ractors cannot call instance methods on objects defined outside their scope.
65
+ - `RobotSpec#hook_classes` must be an array of frozen class references, not instances.
66
+
67
+ ## Testing
68
+
69
+ - Minitest with SimpleCov (branch coverage tracked, no minimum threshold enforced yet)
70
+ - Tests use RobotLab stubs (the full `robot_lab` gem is NOT loaded in tests — see `test_helper.rb`)
71
+ - Coverage baseline: ~55% line / ~32% branch
data/Rakefile CHANGED
@@ -24,16 +24,6 @@ task :test_file, [:file] do |_t, args|
24
24
  ruby "test/#{args[:file]}"
25
25
  end
26
26
 
27
- desc 'Check code style with RuboCop'
28
- task :rubocop do
29
- sh 'bundle exec rubocop'
30
- end
31
-
32
- desc 'Auto-correct RuboCop offenses'
33
- task :rubocop_fix do
34
- sh 'bundle exec rubocop -a'
35
- end
36
-
37
27
  desc 'Check code complexity with Flog (warn >=20, fail >=50)'
38
28
  task :flog_check do
39
29
  require 'flog'
@@ -71,36 +61,62 @@ task :flog_check do
71
61
  end
72
62
  end
73
63
 
74
- desc 'Run all quality checks: tests (with coverage), RuboCop, and Flog'
75
- task :quality do
76
- results = {}
64
+ desc 'Check for structural code duplication with Flay (mass >= 50)'
65
+ task :flay_check do
66
+ require 'flay'
77
67
 
78
- puts "\n#{'=' * 60}"
79
- puts 'Quality Gate: Tests + Coverage'
80
- puts '=' * 60
81
- results[:tests] = system('bundle exec rake test') ? :pass : :fail
68
+ mass_threshold = 50
82
69
 
83
- puts "\n#{'=' * 60}"
84
- puts 'Quality Gate: RuboCop'
85
- puts '=' * 60
86
- results[:rubocop] = system('bundle exec rubocop') ? :pass : :fail
70
+ flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
71
+ flay.process(*Dir.glob('lib/**/*.rb'))
72
+ flay.analyze
87
73
 
88
- puts "\n#{'=' * 60}"
89
- puts 'Quality Gate: Flog Complexity'
90
- puts '=' * 60
91
- results[:flog] = system('bundle exec rake flog_check') ? :pass : :fail
74
+ if flay.hashes.empty?
75
+ puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
76
+ else
77
+ puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
78
+ flay.report
79
+ abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
80
+ end
81
+ end
82
+
83
+ desc 'Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay'
84
+ task :quality do
85
+ gates = [
86
+ ['Tests + Coverage', 'bundle exec rake test'],
87
+ ['RuboCop', 'bundle exec rubocop'],
88
+ ['Flog Complexity', 'bundle exec rake flog_check'],
89
+ ['Flay Duplication', 'bundle exec rake flay_check']
90
+ ]
91
+
92
+ results = gates.map do |label, command|
93
+ puts "\n#{'=' * 60}"
94
+ puts "Quality Gate: #{label}"
95
+ puts '=' * 60
96
+ [label, system(command) ? :pass : :fail]
97
+ end
98
+
99
+ green = ->(s) { "\e[32m#{s}\e[0m" }
100
+ red = ->(s) { "\e[31m#{s}\e[0m" }
101
+ width = results.map { |label, _| label.length }.max
92
102
 
93
103
  puts "\n#{'=' * 60}"
94
- puts 'Quality Summary'
104
+ puts 'Quality Gate Summary'
95
105
  puts '=' * 60
96
- results.each do |gate, status|
97
- icon = status == :pass ? 'PASS' : 'FAIL'
98
- puts " [#{icon}] #{gate}"
106
+ results.each do |label, status|
107
+ badge = status == :pass ? green.call('PASS') : red.call('FAIL')
108
+ puts " [#{badge}] #{label.ljust(width)}"
99
109
  end
110
+ puts '-' * 60
111
+
112
+ passed = results.count { |_, s| s == :pass }
113
+ failed = results.count { |_, s| s == :fail }
114
+ tally = "#{passed} passed, #{failed} failed"
115
+ puts " #{failed.zero? ? green.call(tally) : red.call(tally)}"
100
116
  puts '=' * 60
101
117
 
102
- abort "\nQuality gate failed" if results.values.any?(:fail)
103
- puts "\nAll quality gates passed."
118
+ abort "\n#{red.call('Quality gate failed.')}" unless failed.zero?
119
+ puts "\n#{green.call('All quality gates passed.')}"
104
120
  end
105
121
 
106
122
  namespace :docs do
@@ -229,6 +229,71 @@ Values written via `set` are automatically deep-frozen before crossing the bound
229
229
 
230
230
  ---
231
231
 
232
+ ## Hooks in Ractor Workers
233
+
234
+ `RactorNetworkScheduler` carries a robot's registered `RobotLab::Hook` handler
235
+ classes across the Ractor boundary so the full hook pipeline still fires
236
+ inside each worker.
237
+
238
+ When the scheduler builds a `RobotSpec`, it collects handler classes from all
239
+ three registration levels and stores them in the frozen `hook_classes` field:
240
+
241
+ ```ruby
242
+ def ractor_hook_classes_for(robot)
243
+ [RobotLab.hooks, @hooks, robot.hooks]
244
+ .flat_map { |r| r.registrations.map(&:handler_class) }
245
+ .uniq.freeze
246
+ end
247
+ ```
248
+
249
+ | Registration level | Collected by |
250
+ |---|---|
251
+ | `RobotLab.on(Hook)` | `RobotLab.hooks` (global) |
252
+ | `network.on(Hook)` | `@hooks` (network) |
253
+ | `robot.on(Hook)` | `robot.hooks` (robot) |
254
+
255
+ Inside each worker, the scheduler re-registers the handlers on the freshly
256
+ built robot before running it:
257
+
258
+ ```ruby
259
+ spec.hook_classes.each { |handler| robot.on(handler) }
260
+ robot.run(message) # full hook pipeline fires here
261
+ ```
262
+
263
+ This works because a Hook handler is a Ruby **class**, and classes are
264
+ Ractor-shareable constants — they cross the boundary without any
265
+ serialization.
266
+
267
+ ### Writing a Ractor-Safe Hook
268
+
269
+ A hook handler that may run inside a Ractor worker must follow the same
270
+ statelessness rules as a Ractor-safe tool:
271
+
272
+ - **No class-level state.** `@ivars` set on the class object live in the main
273
+ Ractor; non-main Ractors cannot read or write them, even when the values
274
+ themselves would be shareable.
275
+ - **Accumulate events on the context, not the class.** Push results into
276
+ `ctx.local` (a plain object local to that Ractor's run) and return them as
277
+ part of the worker's result tuple so the main thread can aggregate them
278
+ after `Ractor#value`.
279
+ - **Avoid `$stdout.puts` for anything you need interleaved with main-thread
280
+ output.** Output from non-main Ractors is buffered per-Ractor and only
281
+ flushes at Ractor exit. `Kernel#warn` is silently dropped. Reading the
282
+ `STDOUT` constant raises `IsolationError`. `$stderr.puts` and a
283
+ `File.open(frozen_path, "a")` opened fresh inside the worker are both safe.
284
+ - **`on_error` fires inside the failing Ractor**, before the error propagates
285
+ back to the main thread. Surface whatever you need via the return tuple —
286
+ the main thread only learns of the failure when it calls `worker.value`.
287
+
288
+ `examples/03_ractor_hooks.rb` walks through all of this with two example
289
+ handlers (`TraceHook`, `PerfHook`) plus `RobotLab::Xyzzy` — a fully
290
+ Ractor-safe tracer covering all five hook families (`run`, `llm_generation`,
291
+ `tool_call`, `network_run`, `task`) — run directly against the hook mechanic
292
+ without touching `ruby_llm` (which has class-level Proc callbacks that
293
+ prevent construction inside Ractors today).
294
+
295
+ ---
296
+
232
297
  ## The Frozen-Data Contract
233
298
 
234
299
  Everything that crosses a Ractor boundary must be Ractor-shareable: frozen strings, frozen hashes, frozen arrays, `Data.define` structs, and integers/symbols/nil.
@@ -356,6 +421,16 @@ at_exit { RobotLab.shutdown_ractor_pool }
356
421
 
357
422
  ---
358
423
 
424
+ ## Runnable Examples
425
+
426
+ - `examples/01_ractor_tools.rb` — CPU-bound tools routed through `RactorWorkerPool`
427
+ - `examples/02_ractor_network.rb` — a robot network run via `RactorNetworkScheduler`
428
+ - `examples/03_ractor_hooks.rb` — the hook system firing inside Ractor workers (no LLM API key required)
429
+
430
+ ```bash
431
+ bundle exec ruby examples/03_ractor_hooks.rb
432
+ ```
433
+
359
434
  ## Next Steps
360
435
 
361
436
  - [Using Tools](using-tools.md) — Tool definitions and configuration
data/docs/index.md CHANGED
@@ -60,6 +60,12 @@ pool = RobotLab.ractor_pool
60
60
  RobotLab.shutdown_ractor_pool
61
61
  ```
62
62
 
63
+ ## Examples
64
+
65
+ - `examples/01_ractor_tools.rb` — CPU-bound tools through `RactorWorkerPool`
66
+ - `examples/02_ractor_network.rb` — a robot network run via `RactorNetworkScheduler`
67
+ - `examples/03_ractor_hooks.rb` — the hook system firing inside Ractor workers (no LLM API key required)
68
+
63
69
  ## Links
64
70
 
65
71
  - [Ractor Parallelism Guide](guides/ractor-parallelism.md)
@@ -26,8 +26,7 @@
26
26
  # SHA-256 rounds (~320 ms on modern hardware) so the 4-6× speedup is
27
27
  # clearly visible on a 6-core machine.
28
28
 
29
- require "robot_lab"
30
- require "robot_lab/ractor"
29
+ require_relative "common"
31
30
  require "digest"
32
31
 
33
32
  # Always shut down the pool when the process exits.
@@ -114,12 +113,7 @@ end
114
113
  # Demo
115
114
  # =============================================================================
116
115
 
117
- puts "=" * 62
118
- puts "Example 29: Ractor-Safe CPU Tools"
119
- puts "=" * 62
120
- puts
121
-
122
- DIVIDER = ("─" * 54).freeze
116
+ banner("Ractor-Safe CPU Tools")
123
117
 
124
118
  SAMPLE_TEXTS = [
125
119
  "Ruby makes programmer happiness a first-class concern in language design.",
@@ -130,10 +124,7 @@ SAMPLE_TEXTS = [
130
124
  "Simplicity is the ultimate sophistication in software architecture."
131
125
  ].freeze
132
126
 
133
- # ── 1. ractor_safe? flags ─────────────────────────────────────
134
-
135
- puts "1. ractor_safe? flags"
136
- puts " #{DIVIDER}"
127
+ section("1. ractor_safe? flags")
137
128
 
138
129
  {
139
130
  "WordStatsTool" => WordStatsTool,
@@ -146,10 +137,7 @@ puts " #{DIVIDER}"
146
137
  end
147
138
  puts
148
139
 
149
- # ── 2. RactorBoundary.freeze_deep ─────────────────────────────
150
-
151
- puts "2. RactorBoundary.freeze_deep"
152
- puts " #{DIVIDER}"
140
+ section("2. RactorBoundary.freeze_deep")
153
141
 
154
142
  nested = { tags: ["ruby", "ractor"], meta: { version: 2 } }
155
143
  frozen = RobotLab::RactorBoundary.freeze_deep(nested)
@@ -170,11 +158,8 @@ rescue RobotLab::RactorBoundaryError => e
170
158
  end
171
159
  puts
172
160
 
173
- # ── 3. Single pool submissions ─────────────────────────────────
174
-
175
161
  pool = RobotLab.ractor_pool
176
- puts "3. Worker pool (#{pool.size} Ractors — one per CPU core)"
177
- puts " #{DIVIDER}"
162
+ section("3. Worker pool (#{pool.size} Ractors — one per CPU core)")
178
163
 
179
164
  sample = SAMPLE_TEXTS.first
180
165
 
@@ -185,10 +170,7 @@ r = pool.submit("ReadabilityTool", { text: sample })
185
170
  puts " ReadabilityTool: words_per_sentence=#{r[:words_per_sentence]} long_word_pct=#{r[:long_word_pct]}%"
186
171
  puts
187
172
 
188
- # ── 4. ToolError propagation ───────────────────────────────────
189
-
190
- puts "4. ToolError propagation"
191
- puts " #{DIVIDER}"
173
+ section("4. ToolError propagation")
192
174
  puts " Submitting nil as :text (WordStatsTool will call nil.scan — NoMethodError)"
193
175
  puts
194
176
 
@@ -200,10 +182,7 @@ rescue RobotLab::ToolError => e
200
182
  end
201
183
  puts
202
184
 
203
- # ── 5. Parallel batch vs sequential ───────────────────────────
204
-
205
- puts "5. Parallel batch — #{SAMPLE_TEXTS.length} jobs, each doing #{HeavyDigestTool::ROUNDS.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1_').reverse} SHA-256 rounds"
206
- puts " #{DIVIDER}"
185
+ section("5. Parallel batch — #{SAMPLE_TEXTS.length} jobs, #{HeavyDigestTool::ROUNDS.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1_').reverse} SHA-256 rounds each")
207
186
 
208
187
  # Parallel: one Thread per job, all submitted simultaneously.
209
188
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -230,13 +209,8 @@ puts
230
209
  puts " First result (truncated digest): #{parallel_results.first}"
231
210
  puts
232
211
 
233
- # ── 6. Shutdown ────────────────────────────────────────────────
234
-
235
- puts "6. Shutdown"
236
- puts " #{DIVIDER}"
212
+ section("6. Shutdown")
237
213
  RobotLab.shutdown_ractor_pool
238
214
  puts " Pool shut down cleanly (poison-pill × #{pool.size} workers)."
239
215
  puts
240
- puts "=" * 62
241
- puts "Example 29 complete."
242
- puts "=" * 62
216
+ hr
@@ -30,15 +30,9 @@
30
30
  # bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
31
31
  # ANTHROPIC_API_KEY=key ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
32
32
 
33
- require "robot_lab"
34
- require "robot_lab/ractor"
33
+ require_relative "common"
35
34
 
36
- puts "=" * 62
37
- puts "Example 30: Ractor Network Scheduler"
38
- puts "=" * 62
39
- puts
40
-
41
- DIVIDER = ("─" * 54).freeze
35
+ banner("Ractor Network Scheduler")
42
36
 
43
37
  # =============================================================================
44
38
  # Part 1: Simulated scheduler — dependency waves and timing
@@ -72,8 +66,7 @@ LATENCIES = {
72
66
  #
73
67
  # Speedup ≈ 1.7×
74
68
 
75
- puts "── Part 1: Simulated parallel run (no API key) ───────────"
76
- puts
69
+ section("Part 1: Simulated parallel run (no API key)")
77
70
 
78
71
  # SimulatedScheduler overrides execute_spec so that instead of
79
72
  # constructing a real Robot and calling the LLM, it just sleeps for
@@ -139,8 +132,7 @@ puts
139
132
  # Part 2: Network.new(parallel_mode: :ractor) API
140
133
  # =============================================================================
141
134
 
142
- puts "── Part 2: Network.new(parallel_mode: :ractor) ───────────"
143
- puts
135
+ section("Part 2: Network.new(parallel_mode: :ractor)")
144
136
 
145
137
  # When parallel_mode: :ractor is set on a Network, network.run(message:)
146
138
  # routes through RactorNetworkScheduler instead of SimpleFlow::Pipeline.
@@ -196,19 +188,16 @@ puts
196
188
  # =============================================================================
197
189
 
198
190
  unless ENV["ANTHROPIC_API_KEY"]
199
- puts "── Part 3: Live LLM run ──────────────────────────────────"
191
+ section("Part 3: Live LLM run")
200
192
  puts " Set ANTHROPIC_API_KEY to run the real pipeline."
201
193
  puts " Expected behavior: headline_finder, background_brief, and"
202
194
  puts " fact_checker run in parallel; report_writer follows."
203
195
  puts
204
- puts "=" * 62
205
- puts "Example 30 complete."
206
- puts "=" * 62
196
+ hr
207
197
  exit 0
208
198
  end
209
199
 
210
- puts "── Part 3: Live LLM run (ANTHROPIC_API_KEY detected) ─────"
211
- puts
200
+ section("Part 3: Live LLM run (ANTHROPIC_API_KEY detected)")
212
201
 
213
202
  puts " Running 4-robot research pipeline on:"
214
203
  puts " \"#{topic}\""
@@ -250,6 +239,4 @@ rescue RobotLab::Error => e
250
239
  end
251
240
 
252
241
  puts
253
- puts "=" * 62
254
- puts "Example 30 complete."
255
- puts "=" * 62
242
+ hr
@@ -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.2.1'
7
+ VERSION = '0.2.6'
8
8
  end
9
9
  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
@@ -92,6 +92,7 @@ module RobotLab
92
92
  system_prompt: spec.system_prompt,
93
93
  config: config
94
94
  )
95
+ spec.hook_classes.each { |handler| robot.on(handler) }
95
96
  robot.run(message).last_text_content.to_s.freeze
96
97
  end
97
98
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_lab-ractor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -64,8 +64,11 @@ extra_rdoc_files: []
64
64
  files:
65
65
  - ".envrc"
66
66
  - ".github/workflows/deploy-github-pages.yml"
67
+ - ".loki"
68
+ - ".quality/reek_baseline.txt"
67
69
  - ".rubocop.yml"
68
70
  - CHANGELOG.md
71
+ - CLAUDE.md
69
72
  - LICENSE.txt
70
73
  - README.md
71
74
  - Rakefile
@@ -73,8 +76,11 @@ files:
73
76
  - docs/index.md
74
77
  - docs/superpowers/plans/2026-04-14-ractor-integration.md
75
78
  - docs/superpowers/specs/2026-04-14-ractor-integration-design.md
76
- - examples/29_ractor_tools.rb
77
- - examples/30_ractor_network.rb
79
+ - examples/01_ractor_tools.rb
80
+ - examples/02_ractor_network.rb
81
+ - examples/03_ractor_hooks.rb
82
+ - examples/common.rb
83
+ - examples/xyzzy.rb
78
84
  - lib/robot_lab/ractor.rb
79
85
  - lib/robot_lab/ractor/version.rb
80
86
  - lib/robot_lab/ractor_boundary.rb
@@ -105,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
111
  - !ruby/object:Gem::Version
106
112
  version: '0'
107
113
  requirements: []
108
- rubygems_version: 4.0.11
114
+ rubygems_version: 4.0.17
109
115
  specification_version: 4
110
116
  summary: Ractor-based parallel tool execution for RobotLab agents
111
117
  test_files: []