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 +4 -4
- data/.envrc +2 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +3 -0
- data/.rubocop.yml +1 -173
- data/CHANGELOG.md +10 -0
- data/CLAUDE.md +71 -0
- data/Rakefile +47 -31
- 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_job.rb +5 -1
- data/lib/robot_lab/ractor_network_scheduler.rb +1 -0
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7df9c3ab4660c44f064946a5a90f071df1ad03baa1b4cac8c3259156a35be70b
|
|
4
|
+
data.tar.gz: 0fb20202b60255f45be1b423d5fa5870730cfd4fbf3dbaee1ec601eb72a87f59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0418c60678b46d1c7b9e3a625f8e2c3b1ebf789a2f526daea6f05d5c8a9046a11e54217bea1dd8fd15a50bae6115ea634907e002adc96d7d06f736c626f05d20'
|
|
7
|
+
data.tar.gz: c8800eee270d7987d17658a97c7dce82074ce8db09b67d7a598d92c1e29b15138d07e242f4a5de86fe0a812cadbb10c0ce02736da6d80f60cea36f24b22b4779
|
data/.envrc
CHANGED
data/.loki
ADDED
data/.rubocop.yml
CHANGED
|
@@ -1,173 +1 @@
|
|
|
1
|
-
|
|
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 '
|
|
75
|
-
task :
|
|
76
|
-
|
|
64
|
+
desc 'Check for structural code duplication with Flay (mass >= 50)'
|
|
65
|
+
task :flay_check do
|
|
66
|
+
require 'flay'
|
|
77
67
|
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 |
|
|
97
|
-
|
|
98
|
-
puts " [#{
|
|
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 "\
|
|
103
|
-
puts "\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
require "robot_lab/ractor"
|
|
33
|
+
require_relative "common"
|
|
35
34
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
puts "Example 30 complete."
|
|
206
|
-
puts "=" * 62
|
|
196
|
+
hr
|
|
207
197
|
exit 0
|
|
208
198
|
end
|
|
209
199
|
|
|
210
|
-
|
|
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
|
-
|
|
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
|
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_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
|
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.
|
|
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/
|
|
77
|
-
- examples/
|
|
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.
|
|
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: []
|