robot_lab 0.0.9 → 0.0.11
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/CHANGELOG.md +32 -0
- data/README.md +80 -1
- data/Rakefile +2 -1
- data/docs/api/core/robot.md +182 -0
- data/docs/guides/creating-networks.md +21 -0
- data/docs/guides/index.md +10 -0
- data/docs/guides/knowledge.md +182 -0
- data/docs/guides/mcp-integration.md +106 -0
- data/docs/guides/memory.md +2 -0
- data/docs/guides/observability.md +486 -0
- data/docs/guides/ractor-parallelism.md +364 -0
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
- data/examples/19_token_tracking.rb +128 -0
- data/examples/20_circuit_breaker.rb +153 -0
- data/examples/21_learning_loop.rb +164 -0
- data/examples/22_context_compression.rb +179 -0
- data/examples/23_convergence.rb +137 -0
- data/examples/24_structured_delegation.rb +150 -0
- data/examples/25_history_search/conversation.jsonl +30 -0
- data/examples/25_history_search.rb +136 -0
- data/examples/26_document_store/api_versioning_adr.md +52 -0
- data/examples/26_document_store/incident_postmortem.md +46 -0
- data/examples/26_document_store/postgres_runbook.md +49 -0
- data/examples/26_document_store/redis_caching_guide.md +48 -0
- data/examples/26_document_store/sidekiq_guide.md +51 -0
- data/examples/26_document_store.rb +147 -0
- data/examples/27_incident_response/incident_response.rb +244 -0
- data/examples/28_mcp_discovery.rb +112 -0
- data/examples/29_ractor_tools.rb +243 -0
- data/examples/30_ractor_network.rb +256 -0
- data/examples/README.md +136 -0
- data/examples/prompts/skill_with_mcp_test.md +9 -0
- data/examples/prompts/skill_with_robot_name_test.md +5 -0
- data/examples/prompts/skill_with_tools_test.md +6 -0
- data/lib/robot_lab/bus_poller.rb +149 -0
- data/lib/robot_lab/convergence.rb +69 -0
- data/lib/robot_lab/delegation_future.rb +93 -0
- data/lib/robot_lab/document_store.rb +155 -0
- data/lib/robot_lab/error.rb +25 -0
- data/lib/robot_lab/history_compressor.rb +205 -0
- data/lib/robot_lab/mcp/client.rb +17 -5
- data/lib/robot_lab/mcp/connection_poller.rb +187 -0
- data/lib/robot_lab/mcp/server.rb +7 -2
- data/lib/robot_lab/mcp/server_discovery.rb +110 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
- data/lib/robot_lab/memory.rb +103 -6
- data/lib/robot_lab/network.rb +44 -9
- data/lib/robot_lab/ractor_boundary.rb +42 -0
- data/lib/robot_lab/ractor_job.rb +37 -0
- data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
- data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
- data/lib/robot_lab/ractor_worker_pool.rb +117 -0
- data/lib/robot_lab/robot/bus_messaging.rb +43 -65
- data/lib/robot_lab/robot/history_search.rb +69 -0
- data/lib/robot_lab/robot.rb +228 -11
- data/lib/robot_lab/robot_result.rb +24 -5
- data/lib/robot_lab/run_config.rb +1 -1
- data/lib/robot_lab/text_analysis.rb +103 -0
- data/lib/robot_lab/tool.rb +42 -3
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +49 -29
- data/lib/robot_lab.rb +25 -0
- data/mkdocs.yml +1 -0
- metadata +70 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 27: Production Incident War Room
|
|
5
|
+
#
|
|
6
|
+
# A payment-service outage is in progress. Three SRE scouts investigate
|
|
7
|
+
# different layers in parallel — database, network, and application. Each
|
|
8
|
+
# scout writes findings to shared reactive memory and broadcasts a status
|
|
9
|
+
# update to the war-room coordinator via TypedBus.
|
|
10
|
+
#
|
|
11
|
+
# Phase 5 infrastructure features demonstrated:
|
|
12
|
+
#
|
|
13
|
+
# REACTIVE MEMORY (IO.pipe Waiter — #13)
|
|
14
|
+
# ──────────────────────────────────────────────────────────────
|
|
15
|
+
# db_scout ─┐ memory[:db_finding] = "..." ─┐
|
|
16
|
+
# net_scout ─┼ memory[:net_finding] = "..." ─┼→ IO.pipe signal
|
|
17
|
+
# app_scout ─┘ memory[:app_finding] = "..." ─┘
|
|
18
|
+
# ↓
|
|
19
|
+
# commander.get(:db_finding, :net_finding, :app_finding, wait: 60)
|
|
20
|
+
# wakes via IO.select on the pipe — no busy-wait, Async-safe
|
|
21
|
+
#
|
|
22
|
+
# BUS POLLER (serialized delivery — #14)
|
|
23
|
+
# ──────────────────────────────────────────────────────────────
|
|
24
|
+
# db_scout, net_scout, app_scout each send a bus message to :war_room.
|
|
25
|
+
# If two scouts finish simultaneously, their deliveries queue in BusPoller
|
|
26
|
+
# so war_room processes them one at a time — no re-entrancy, no dropped
|
|
27
|
+
# messages, arrival order preserved.
|
|
28
|
+
#
|
|
29
|
+
# POLLER GROUPS (#15)
|
|
30
|
+
# ──────────────────────────────────────────────────────────────
|
|
31
|
+
# task :db_scout, poller_group: :investigation (fast, no LLM in final stage)
|
|
32
|
+
# task :net_scout, poller_group: :investigation
|
|
33
|
+
# task :app_scout, poller_group: :investigation
|
|
34
|
+
# task :commander, poller_group: :command (expensive synthesis call)
|
|
35
|
+
#
|
|
36
|
+
# Usage:
|
|
37
|
+
# bundle exec ruby examples/27_incident_response/incident_response.rb
|
|
38
|
+
|
|
39
|
+
ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "../prompts")
|
|
40
|
+
|
|
41
|
+
require_relative "../../lib/robot_lab"
|
|
42
|
+
require "fileutils"
|
|
43
|
+
|
|
44
|
+
RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
|
|
45
|
+
|
|
46
|
+
OUTPUT_DIR = File.join(__dir__, "output")
|
|
47
|
+
FileUtils.mkdir_p(OUTPUT_DIR)
|
|
48
|
+
|
|
49
|
+
# ── Scout ──────────────────────────────────────────────────────────────────
|
|
50
|
+
#
|
|
51
|
+
# Investigates one subsystem, stores a terse finding in shared memory,
|
|
52
|
+
# and broadcasts a status line to the war-room via bus.
|
|
53
|
+
#
|
|
54
|
+
# NOTE: we bypass extract_run_context's delete() pattern and hold a direct
|
|
55
|
+
# reference to shared memory — the same technique used in example 15 —
|
|
56
|
+
# because parallel pipeline steps would otherwise lose the memory reference
|
|
57
|
+
# after the first step runs.
|
|
58
|
+
|
|
59
|
+
class SREScout < RobotLab::Robot
|
|
60
|
+
attr_writer :shared_memory
|
|
61
|
+
|
|
62
|
+
def initialize(name:, subsystem:, memory_key:, bus: nil)
|
|
63
|
+
super(
|
|
64
|
+
name: name,
|
|
65
|
+
system_prompt: "You are a senior SRE responding to a production outage. " \
|
|
66
|
+
"Diagnose one infrastructure layer in 2 sentences: " \
|
|
67
|
+
"first sentence is root cause, second is customer impact.",
|
|
68
|
+
bus: bus
|
|
69
|
+
)
|
|
70
|
+
@subsystem = subsystem
|
|
71
|
+
@memory_key = memory_key
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def call(result)
|
|
75
|
+
incident = extract_message(result)
|
|
76
|
+
|
|
77
|
+
finding = run("Investigate the #{@subsystem} layer. #{incident}").reply.strip
|
|
78
|
+
|
|
79
|
+
# Write to reactive memory — wakes the IO.pipe waiter in the commander
|
|
80
|
+
if @shared_memory
|
|
81
|
+
@shared_memory.current_writer = name
|
|
82
|
+
@shared_memory.set(@memory_key, finding)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts " [#{name}] #{finding[0..100]}#{"..." if finding.length > 100}"
|
|
86
|
+
|
|
87
|
+
# Notify war room — BusPoller queues this if war_room is already processing
|
|
88
|
+
send_message(to: :war_room, content: "[#{@subsystem}] #{finding}") if bus
|
|
89
|
+
|
|
90
|
+
result.with_context(name.to_sym, finding).continue(finding)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def extract_message(result)
|
|
96
|
+
case result.value
|
|
97
|
+
when Hash then result.value[:message].to_s
|
|
98
|
+
when RobotLab::RobotResult then result.value.reply.to_s
|
|
99
|
+
else result.value.to_s
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ── War Room ───────────────────────────────────────────────────────────────
|
|
105
|
+
#
|
|
106
|
+
# Receives scout status updates via TypedBus.
|
|
107
|
+
# BusPoller serializes delivery: even if all three scouts finish at the
|
|
108
|
+
# same instant their messages are processed one at a time and none is
|
|
109
|
+
# dropped. Delivery order is preserved by the queue.
|
|
110
|
+
|
|
111
|
+
class WarRoom < RobotLab::Robot
|
|
112
|
+
attr_reader :updates
|
|
113
|
+
|
|
114
|
+
def initialize(bus:)
|
|
115
|
+
super(name: "war_room", system_prompt: "SRE war-room coordinator.", bus: bus)
|
|
116
|
+
@updates = []
|
|
117
|
+
@delivery_mutex = Mutex.new # only for reading @updates outside Async
|
|
118
|
+
|
|
119
|
+
on_message do |msg|
|
|
120
|
+
# BusPoller ensures this block is never re-entered for the same robot.
|
|
121
|
+
# Simulate brief processing work so a second delivery would have to queue.
|
|
122
|
+
@updates << msg.content
|
|
123
|
+
puts " [war_room] Update ##{@updates.size} processed: #{msg.content[0..70]}..."
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Incident Commander ─────────────────────────────────────────────────────
|
|
129
|
+
#
|
|
130
|
+
# Waits until all three scout findings land in shared memory, then calls
|
|
131
|
+
# the LLM once to synthesize an action plan.
|
|
132
|
+
#
|
|
133
|
+
# memory.get(:db_finding, :net_finding, :app_finding, wait: 60)
|
|
134
|
+
# → internally each key uses an IO.pipe-backed Waiter
|
|
135
|
+
# → IO.select wakes the call as soon as all three keys are written
|
|
136
|
+
# → no busy-wait, no mutex spin, works cleanly with Async
|
|
137
|
+
|
|
138
|
+
class IncidentCommander < RobotLab::Robot
|
|
139
|
+
attr_writer :shared_memory
|
|
140
|
+
|
|
141
|
+
def call(result)
|
|
142
|
+
puts " [commander] Blocking on reactive memory — waiting for all scout findings..."
|
|
143
|
+
|
|
144
|
+
findings = @shared_memory.get(:db_finding, :net_finding, :app_finding, wait: 60)
|
|
145
|
+
|
|
146
|
+
if findings.values.include?(:timeout)
|
|
147
|
+
timed_out = findings.select { |_k, v| v == :timeout }.keys
|
|
148
|
+
puts " [commander] WARNING: scouts timed out: #{timed_out.join(", ")}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
prompt = <<~PROMPT
|
|
152
|
+
Three SRE scouts have reported their findings for a payment-service outage.
|
|
153
|
+
Issue a concise incident action plan (3–5 bullet points) covering immediate
|
|
154
|
+
mitigation, next investigation steps, and stakeholder communication.
|
|
155
|
+
|
|
156
|
+
Database layer: #{findings[:db_finding] || "no data"}
|
|
157
|
+
Network layer: #{findings[:net_finding] || "no data"}
|
|
158
|
+
Application: #{findings[:app_finding] || "no data"}
|
|
159
|
+
PROMPT
|
|
160
|
+
|
|
161
|
+
report = run(prompt).reply.strip
|
|
162
|
+
|
|
163
|
+
@shared_memory[:incident_report] = report
|
|
164
|
+
|
|
165
|
+
path = File.join(OUTPUT_DIR, "incident_report.md")
|
|
166
|
+
File.write(path, "# Incident Action Plan\n\n#{report}\n")
|
|
167
|
+
puts " [commander] Action plan written to #{path}"
|
|
168
|
+
|
|
169
|
+
result.with_context(:commander, report).continue(report)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ── Wire up ────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
bus = TypedBus::MessageBus.new
|
|
176
|
+
|
|
177
|
+
db_scout = SREScout.new(name: "db_scout", subsystem: "database", memory_key: :db_finding, bus: bus)
|
|
178
|
+
net_scout = SREScout.new(name: "net_scout", subsystem: "network", memory_key: :net_finding, bus: bus)
|
|
179
|
+
app_scout = SREScout.new(name: "app_scout", subsystem: "application", memory_key: :app_finding, bus: bus)
|
|
180
|
+
war_room = WarRoom.new(bus: bus)
|
|
181
|
+
commander = IncidentCommander.new(name: "commander", system_prompt: "SRE incident commander.")
|
|
182
|
+
|
|
183
|
+
# Build the investigation network
|
|
184
|
+
# poller_group: labels are registered on the network's shared BusPoller.
|
|
185
|
+
network = RobotLab.create_network(name: "incident_response") do
|
|
186
|
+
task :db_scout, db_scout, depends_on: :none, poller_group: :investigation
|
|
187
|
+
task :net_scout, net_scout, depends_on: :none, poller_group: :investigation
|
|
188
|
+
task :app_scout, app_scout, depends_on: :none, poller_group: :investigation
|
|
189
|
+
task :commander, commander, depends_on: [:db_scout, :net_scout, :app_scout], poller_group: :command
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Assign direct shared-memory references (avoids the extract_run_context
|
|
193
|
+
# mutation problem with parallel pipeline steps — see example 15).
|
|
194
|
+
shared_memory = network.memory
|
|
195
|
+
[db_scout, net_scout, app_scout, commander].each do |r|
|
|
196
|
+
r.shared_memory = shared_memory
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Subscribe to memory changes — fires as each scout writes its finding.
|
|
200
|
+
# The callback runs asynchronously; the IO.pipe waiter in commander wakes
|
|
201
|
+
# independently when the key is set.
|
|
202
|
+
network.memory.subscribe(:db_finding, :net_finding, :app_finding) do |change|
|
|
203
|
+
puts " [memory] :#{change.key} written by #{change.writer}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ── Run ────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
puts "=" * 65
|
|
209
|
+
puts "Example 27: Production Incident War Room"
|
|
210
|
+
puts " Phase 5 — BusPoller · Reactive Memory · Poller Groups"
|
|
211
|
+
puts "=" * 65
|
|
212
|
+
puts
|
|
213
|
+
puts network.visualize
|
|
214
|
+
puts
|
|
215
|
+
|
|
216
|
+
puts "Poller groups registered on network BusPoller:"
|
|
217
|
+
puts " #{network.instance_variable_get(:@bus_poller).groups.inspect}"
|
|
218
|
+
puts
|
|
219
|
+
|
|
220
|
+
puts "INCIDENT: Payment service degraded — elevated error rates and timeouts"
|
|
221
|
+
puts "-" * 65
|
|
222
|
+
puts
|
|
223
|
+
|
|
224
|
+
result = network.run(message: "Payment service is degraded: elevated HTTP 500 " \
|
|
225
|
+
"error rates (~12%) and p99 latency spiked to 8s. " \
|
|
226
|
+
"Investigate your assigned infrastructure layer.")
|
|
227
|
+
|
|
228
|
+
puts
|
|
229
|
+
puts "-" * 65
|
|
230
|
+
puts "War-room updates received (BusPoller order):"
|
|
231
|
+
war_room.updates.each.with_index(1) { |u, i| puts " #{i}. #{u.gsub(/\s+/, " ")[0..90]}" }
|
|
232
|
+
puts
|
|
233
|
+
puts "Reactive memory keys written by scouts:"
|
|
234
|
+
%i[db_finding net_finding app_finding].each do |key|
|
|
235
|
+
val = network.memory[key]
|
|
236
|
+
puts " :#{key.to_s.ljust(14)} #{val&.gsub(/\s+/, " ")&.slice(0, 80)}..."
|
|
237
|
+
end
|
|
238
|
+
puts
|
|
239
|
+
puts "Incident action plan (from #{OUTPUT_DIR}/incident_report.md):"
|
|
240
|
+
puts "-" * 65
|
|
241
|
+
report = network.memory[:incident_report].to_s
|
|
242
|
+
puts report
|
|
243
|
+
puts
|
|
244
|
+
puts "=" * 65
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 28: MCP Server Discovery
|
|
5
|
+
#
|
|
6
|
+
# When a robot has many MCP servers configured, connecting to all of them
|
|
7
|
+
# upfront is wasteful — some servers may be irrelevant to a particular query.
|
|
8
|
+
#
|
|
9
|
+
# MCP Server Discovery uses TF cosine similarity to select only the servers
|
|
10
|
+
# semantically relevant to the user's query, then connects only those.
|
|
11
|
+
#
|
|
12
|
+
# == Key config
|
|
13
|
+
#
|
|
14
|
+
# robot = RobotLab.build(
|
|
15
|
+
# mcp_discovery: true, # ← enables semantic filtering
|
|
16
|
+
# mcp: [ ... ] # ← candidate servers, each with :description
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# == Fallback behaviour
|
|
20
|
+
#
|
|
21
|
+
# All servers are connected unchanged when:
|
|
22
|
+
# - No server has a :description field
|
|
23
|
+
# - The classifier gem is unavailable
|
|
24
|
+
# - The query is blank or nil
|
|
25
|
+
# - No server scores at or above the threshold (0.05 by default)
|
|
26
|
+
#
|
|
27
|
+
# This demo exercises MCP::ServerDiscovery directly — no LLM calls needed.
|
|
28
|
+
#
|
|
29
|
+
# Usage:
|
|
30
|
+
# bundle exec ruby examples/28_mcp_discovery.rb
|
|
31
|
+
|
|
32
|
+
require_relative "../lib/robot_lab"
|
|
33
|
+
|
|
34
|
+
# Three representative MCP server configurations
|
|
35
|
+
SERVERS = [
|
|
36
|
+
{
|
|
37
|
+
name: "filesystem",
|
|
38
|
+
description: "Read, write, and search local files and directories",
|
|
39
|
+
transport: { type: "stdio", command: "mcp-server-filesystem" }
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "github",
|
|
43
|
+
description: "GitHub repos, issues, pull requests, code search",
|
|
44
|
+
transport: { type: "stdio", command: "mcp-server-github" }
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "brew",
|
|
48
|
+
description: "Install, update, and manage macOS packages via Homebrew",
|
|
49
|
+
transport: { type: "stdio", command: "mcp-server-brew" }
|
|
50
|
+
}
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
def show_query(label, query)
|
|
54
|
+
selected = RobotLab::MCP::ServerDiscovery.select(query, from: SERVERS)
|
|
55
|
+
names = selected.map { |s| s[:name] }
|
|
56
|
+
|
|
57
|
+
puts " Query : #{query.inspect}"
|
|
58
|
+
puts " Match : #{names.inspect}"
|
|
59
|
+
puts
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
puts "=" * 60
|
|
63
|
+
puts "Example 28: MCP Server Discovery"
|
|
64
|
+
puts " Semantic server selection via TF cosine similarity"
|
|
65
|
+
puts "=" * 60
|
|
66
|
+
puts
|
|
67
|
+
puts "Candidate servers:"
|
|
68
|
+
SERVERS.each do |s|
|
|
69
|
+
puts " #{s[:name].ljust(12)} #{s[:description]}"
|
|
70
|
+
end
|
|
71
|
+
puts
|
|
72
|
+
|
|
73
|
+
puts "Discovery queries:"
|
|
74
|
+
puts "-" * 60
|
|
75
|
+
show_query("File ops", "read my config file")
|
|
76
|
+
show_query("Package mgmt", "install imagemagick via homebrew")
|
|
77
|
+
show_query("Code review", "list open pull requests on my repo")
|
|
78
|
+
|
|
79
|
+
puts "Fallback cases:"
|
|
80
|
+
puts "-" * 60
|
|
81
|
+
|
|
82
|
+
# No description → all servers returned
|
|
83
|
+
no_desc_servers = SERVERS.map { |s| s.except(:description) }
|
|
84
|
+
result = RobotLab::MCP::ServerDiscovery.select("install imagemagick", from: no_desc_servers)
|
|
85
|
+
puts " No descriptions : returns all (#{result.size} servers)"
|
|
86
|
+
|
|
87
|
+
# Blank query → all servers returned
|
|
88
|
+
result = RobotLab::MCP::ServerDiscovery.select("", from: SERVERS)
|
|
89
|
+
puts " Blank query : returns all (#{result.size} servers)"
|
|
90
|
+
|
|
91
|
+
# Very high threshold → no match → fallback to all
|
|
92
|
+
result = RobotLab::MCP::ServerDiscovery.select("install imagemagick", from: SERVERS, threshold: 1.0)
|
|
93
|
+
puts " High threshold : returns all (#{result.size} servers) — no match above 1.0"
|
|
94
|
+
|
|
95
|
+
puts
|
|
96
|
+
puts "mcp_discovery: true on a Robot"
|
|
97
|
+
puts "-" * 60
|
|
98
|
+
puts <<~NOTE
|
|
99
|
+
RobotLab.build(
|
|
100
|
+
name: "assistant",
|
|
101
|
+
mcp_discovery: true,
|
|
102
|
+
mcp: [
|
|
103
|
+
{ name: "filesystem", description: "Read, write...", transport: { ... } },
|
|
104
|
+
{ name: "github", description: "GitHub repos...", transport: { ... } },
|
|
105
|
+
{ name: "brew", description: "Install packages...", transport: { ... } }
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Only the :brew server is connected for this message:
|
|
110
|
+
robot.run("install imagemagick")
|
|
111
|
+
NOTE
|
|
112
|
+
puts "=" * 60
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 29: Ractor-Safe CPU Tools
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates Track 1 of RobotLab's Ractor parallelism: CPU-bound tools
|
|
7
|
+
# that bypass Ruby's GVL by running inside a pool of Ractor workers.
|
|
8
|
+
#
|
|
9
|
+
# Concepts covered:
|
|
10
|
+
# - ractor_safe true — opt a tool class into Ractor execution
|
|
11
|
+
# - Inheritance — subclasses inherit ractor_safe automatically
|
|
12
|
+
# - RactorBoundary.freeze_deep — deep-freeze nested data before
|
|
13
|
+
# crossing the Ractor boundary
|
|
14
|
+
# - RactorBoundaryError — raised for non-shareable values (Procs, IOs…)
|
|
15
|
+
# - RactorWorkerPool — global pool, submit jobs, read frozen results
|
|
16
|
+
# - ToolError — propagated when a tool raises inside a Ractor
|
|
17
|
+
# - Parallel batch — many threads submitting concurrently vs sequentially
|
|
18
|
+
#
|
|
19
|
+
# Usage (no LLM API key required):
|
|
20
|
+
# bundle exec ruby examples/29_ractor_tools.rb
|
|
21
|
+
#
|
|
22
|
+
# Good to know:
|
|
23
|
+
# Ractor dispatch has a fixed overhead of ~20 ms per job (reply-queue
|
|
24
|
+
# Ractor creation + Ractor.make_shareable). Computation must dominate
|
|
25
|
+
# that overhead for parallelism to win. HeavyDigestTool uses 500 000
|
|
26
|
+
# SHA-256 rounds (~320 ms on modern hardware) so the 4-6× speedup is
|
|
27
|
+
# clearly visible on a 6-core machine.
|
|
28
|
+
|
|
29
|
+
ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
|
|
30
|
+
|
|
31
|
+
require_relative "../lib/robot_lab"
|
|
32
|
+
require "digest"
|
|
33
|
+
|
|
34
|
+
# Always shut down the pool when the process exits.
|
|
35
|
+
at_exit { RobotLab.shutdown_ractor_pool }
|
|
36
|
+
|
|
37
|
+
# =============================================================================
|
|
38
|
+
# Tool definitions
|
|
39
|
+
# =============================================================================
|
|
40
|
+
|
|
41
|
+
# Base class — declare ractor_safe once; all subclasses inherit it.
|
|
42
|
+
class TextTool < RobotLab::Tool
|
|
43
|
+
ractor_safe true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Word and sentence statistics — pure computation, no shared state.
|
|
47
|
+
class WordStatsTool < TextTool
|
|
48
|
+
description "Count words, sentences, and average word length"
|
|
49
|
+
|
|
50
|
+
param :text, type: :string, desc: "Text to analyze"
|
|
51
|
+
|
|
52
|
+
def execute(text:)
|
|
53
|
+
words = text.scan(/\b\w+\b/)
|
|
54
|
+
sentences = [text.scan(/[.!?]+/).length, 1].max
|
|
55
|
+
avg_len = words.empty? ? 0.0 : (words.sum(&:length).to_f / words.length).round(2)
|
|
56
|
+
|
|
57
|
+
{ words: words.length, sentences: sentences, avg_word_len: avg_len }.freeze
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Words-per-sentence and long-word density (a simple readability proxy).
|
|
62
|
+
class ReadabilityTool < TextTool
|
|
63
|
+
description "Estimate words-per-sentence and long-word density"
|
|
64
|
+
|
|
65
|
+
param :text, type: :string, desc: "Text to analyze"
|
|
66
|
+
|
|
67
|
+
def execute(text:)
|
|
68
|
+
words = text.scan(/\b\w+\b/)
|
|
69
|
+
sentences = [text.scan(/[.!?]+/).length, 1].max
|
|
70
|
+
long_words = words.count { |w| w.length > 6 }
|
|
71
|
+
long_pct = words.empty? ? 0 : (long_words * 100 / words.length)
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
words: words.length,
|
|
75
|
+
sentences: sentences,
|
|
76
|
+
words_per_sentence: (words.length.to_f / sentences).round(1),
|
|
77
|
+
long_word_pct: long_pct
|
|
78
|
+
}.freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# CPU-intensive: 500 000 SHA-256 rounds (~320 ms per job on modern hardware).
|
|
83
|
+
# The overhead of reply-queue Ractor creation + make_shareable is ~20 ms per job
|
|
84
|
+
# (constant), so computation must dwarf it to show a real speedup.
|
|
85
|
+
class HeavyDigestTool < TextTool
|
|
86
|
+
description "SHA-256 chain (500 000 rounds) — CPU-intensive, Ractor-safe"
|
|
87
|
+
|
|
88
|
+
ROUNDS = 500_000
|
|
89
|
+
|
|
90
|
+
param :text, type: :string, desc: "Seed text"
|
|
91
|
+
|
|
92
|
+
def execute(text:)
|
|
93
|
+
digest = text
|
|
94
|
+
ROUNDS.times { digest = Digest::SHA256.hexdigest(digest) }
|
|
95
|
+
digest[0..15].freeze
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# NOT Ractor-safe: @@hits is mutable class-level state.
|
|
100
|
+
# Shown here purely as a contrast — do not submit this to the pool.
|
|
101
|
+
class RequestCounterTool < RobotLab::Tool
|
|
102
|
+
description "Word count plus a mutable global call counter (not Ractor-safe)"
|
|
103
|
+
|
|
104
|
+
@@hits = 0 # mutable class variable — Ractor workers cannot access this
|
|
105
|
+
|
|
106
|
+
param :text, type: :string, desc: "Text to count"
|
|
107
|
+
|
|
108
|
+
def execute(text:)
|
|
109
|
+
@@hits += 1
|
|
110
|
+
"#{text.scan(/\b\w+\b/).length} words (total calls: #{@@hits})"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# =============================================================================
|
|
115
|
+
# Demo
|
|
116
|
+
# =============================================================================
|
|
117
|
+
|
|
118
|
+
puts "=" * 62
|
|
119
|
+
puts "Example 29: Ractor-Safe CPU Tools"
|
|
120
|
+
puts "=" * 62
|
|
121
|
+
puts
|
|
122
|
+
|
|
123
|
+
DIVIDER = ("─" * 54).freeze
|
|
124
|
+
|
|
125
|
+
SAMPLE_TEXTS = [
|
|
126
|
+
"Ruby makes programmer happiness a first-class concern in language design.",
|
|
127
|
+
"Ractors enable true CPU parallelism by isolating mutable state between actors.",
|
|
128
|
+
"Every distributed system eventually becomes a consistency problem.",
|
|
129
|
+
"The quick brown fox jumps over the lazy dog — a pangram.",
|
|
130
|
+
"Machine learning transforms raw observations into actionable insight.",
|
|
131
|
+
"Simplicity is the ultimate sophistication in software architecture."
|
|
132
|
+
].freeze
|
|
133
|
+
|
|
134
|
+
# ── 1. ractor_safe? flags ─────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
puts "1. ractor_safe? flags"
|
|
137
|
+
puts " #{DIVIDER}"
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
"WordStatsTool" => WordStatsTool,
|
|
141
|
+
"ReadabilityTool" => ReadabilityTool,
|
|
142
|
+
"HeavyDigestTool" => HeavyDigestTool,
|
|
143
|
+
"RequestCounterTool" => RequestCounterTool
|
|
144
|
+
}.each do |name, klass|
|
|
145
|
+
mark = klass.ractor_safe ? "✓ ractor_safe (inherits from TextTool)" : "✗ NOT ractor_safe"
|
|
146
|
+
puts " #{name.ljust(22)} #{mark}"
|
|
147
|
+
end
|
|
148
|
+
puts
|
|
149
|
+
|
|
150
|
+
# ── 2. RactorBoundary.freeze_deep ─────────────────────────────
|
|
151
|
+
|
|
152
|
+
puts "2. RactorBoundary.freeze_deep"
|
|
153
|
+
puts " #{DIVIDER}"
|
|
154
|
+
|
|
155
|
+
nested = { tags: ["ruby", "ractor"], meta: { version: 2 } }
|
|
156
|
+
frozen = RobotLab::RactorBoundary.freeze_deep(nested)
|
|
157
|
+
|
|
158
|
+
puts " Input frozen? #{nested.frozen?}"
|
|
159
|
+
puts " Output frozen? #{frozen.frozen?}"
|
|
160
|
+
puts " Inner :tags array frozen? #{frozen[:tags].frozen?}"
|
|
161
|
+
puts " Inner :meta hash frozen? #{frozen[:meta].frozen?}"
|
|
162
|
+
puts
|
|
163
|
+
|
|
164
|
+
# A Proc cannot cross a Ractor boundary — freeze_deep raises immediately.
|
|
165
|
+
require "stringio"
|
|
166
|
+
begin
|
|
167
|
+
RobotLab::RactorBoundary.freeze_deep(StringIO.new("I cannot be frozen"))
|
|
168
|
+
rescue RobotLab::RactorBoundaryError => e
|
|
169
|
+
puts " RactorBoundaryError on StringIO:"
|
|
170
|
+
puts " #{e.message.split('.').first}."
|
|
171
|
+
end
|
|
172
|
+
puts
|
|
173
|
+
|
|
174
|
+
# ── 3. Single pool submissions ─────────────────────────────────
|
|
175
|
+
|
|
176
|
+
pool = RobotLab.ractor_pool
|
|
177
|
+
puts "3. Worker pool (#{pool.size} Ractors — one per CPU core)"
|
|
178
|
+
puts " #{DIVIDER}"
|
|
179
|
+
|
|
180
|
+
sample = SAMPLE_TEXTS.first
|
|
181
|
+
|
|
182
|
+
r = pool.submit("WordStatsTool", { text: sample })
|
|
183
|
+
puts " WordStatsTool: #{r.inspect}"
|
|
184
|
+
|
|
185
|
+
r = pool.submit("ReadabilityTool", { text: sample })
|
|
186
|
+
puts " ReadabilityTool: words_per_sentence=#{r[:words_per_sentence]} long_word_pct=#{r[:long_word_pct]}%"
|
|
187
|
+
puts
|
|
188
|
+
|
|
189
|
+
# ── 4. ToolError propagation ───────────────────────────────────
|
|
190
|
+
|
|
191
|
+
puts "4. ToolError propagation"
|
|
192
|
+
puts " #{DIVIDER}"
|
|
193
|
+
puts " Submitting nil as :text (WordStatsTool will call nil.scan — NoMethodError)"
|
|
194
|
+
puts
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
pool.submit("WordStatsTool", { text: nil })
|
|
198
|
+
rescue RobotLab::ToolError => e
|
|
199
|
+
puts " RobotLab::ToolError caught:"
|
|
200
|
+
puts " #{e.message}"
|
|
201
|
+
end
|
|
202
|
+
puts
|
|
203
|
+
|
|
204
|
+
# ── 5. Parallel batch vs sequential ───────────────────────────
|
|
205
|
+
|
|
206
|
+
puts "5. Parallel batch — #{SAMPLE_TEXTS.length} jobs, each doing #{HeavyDigestTool::ROUNDS.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1_').reverse} SHA-256 rounds"
|
|
207
|
+
puts " #{DIVIDER}"
|
|
208
|
+
|
|
209
|
+
# Parallel: one Thread per job, all submitted simultaneously.
|
|
210
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
211
|
+
threads = SAMPLE_TEXTS.map { |text| Thread.new { pool.submit("HeavyDigestTool", { text: text }) } }
|
|
212
|
+
parallel_results = threads.map(&:value)
|
|
213
|
+
parallel_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
214
|
+
|
|
215
|
+
# Sequential: jobs submitted one-at-a-time from the main thread.
|
|
216
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
217
|
+
SAMPLE_TEXTS.each { |text| pool.submit("HeavyDigestTool", { text: text }) }
|
|
218
|
+
seq_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
219
|
+
|
|
220
|
+
puts " Parallel (#{SAMPLE_TEXTS.length} threads → #{pool.size} Ractors): #{"%.3f" % parallel_time}s"
|
|
221
|
+
puts " Sequential (1 thread → #{pool.size} Ractors): #{"%.3f" % seq_time}s"
|
|
222
|
+
|
|
223
|
+
if seq_time > parallel_time * 1.2
|
|
224
|
+
puts " Speedup: #{(seq_time / parallel_time).round(1)}×"
|
|
225
|
+
else
|
|
226
|
+
puts " Note: overhead (~20 ms/job for reply-queue Ractor + make_shareable)"
|
|
227
|
+
puts " dominated this run. Increase ROUNDS further to widen the gap."
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
puts
|
|
231
|
+
puts " First result (truncated digest): #{parallel_results.first}"
|
|
232
|
+
puts
|
|
233
|
+
|
|
234
|
+
# ── 6. Shutdown ────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
puts "6. Shutdown"
|
|
237
|
+
puts " #{DIVIDER}"
|
|
238
|
+
RobotLab.shutdown_ractor_pool
|
|
239
|
+
puts " Pool shut down cleanly (poison-pill × #{pool.size} workers)."
|
|
240
|
+
puts
|
|
241
|
+
puts "=" * 62
|
|
242
|
+
puts "Example 29 complete."
|
|
243
|
+
puts "=" * 62
|