robot_lab 0.0.9 → 0.0.12
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 +53 -0
- data/README.md +210 -1
- data/Rakefile +2 -1
- data/docs/api/core/result.md +123 -0
- data/docs/api/core/robot.md +182 -0
- data/docs/api/errors.md +185 -0
- data/docs/guides/building-robots.md +125 -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 +72 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 30: Ractor Network Scheduler
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates Track 2 of RobotLab's Ractor parallelism: running a
|
|
7
|
+
# multi-robot pipeline under RactorNetworkScheduler, which dispatches
|
|
8
|
+
# independent tasks in parallel waves and respects depends_on ordering.
|
|
9
|
+
#
|
|
10
|
+
# Concepts covered:
|
|
11
|
+
# - Network.new(parallel_mode: :ractor) — opt the network into Ractor dispatch
|
|
12
|
+
# - Dependency waves — independent tasks run concurrently,
|
|
13
|
+
# dependent tasks wait for their wave
|
|
14
|
+
# - RobotSpec — frozen, Ractor-shareable robot descriptor
|
|
15
|
+
# - RactorNetworkScheduler — direct use for testing / custom wiring
|
|
16
|
+
# - Result Hash — run_pipeline returns { name => result }
|
|
17
|
+
#
|
|
18
|
+
# Part 1 — simulated scheduler, no API key needed.
|
|
19
|
+
# Overrides execute_spec with a sleep-based stub so the wave ordering and
|
|
20
|
+
# timing characteristics are visible without real LLM calls.
|
|
21
|
+
#
|
|
22
|
+
# Part 2 — Network.new(parallel_mode: :ractor) API walkthrough.
|
|
23
|
+
# Shows how to configure the network and inspects the dependency graph.
|
|
24
|
+
# No run() call is made, so no API key is required.
|
|
25
|
+
#
|
|
26
|
+
# Part 3 — live LLM run (optional).
|
|
27
|
+
# Executes automatically when ANTHROPIC_API_KEY is set.
|
|
28
|
+
#
|
|
29
|
+
# Usage:
|
|
30
|
+
# bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
|
|
31
|
+
# ANTHROPIC_API_KEY=key ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
|
|
32
|
+
|
|
33
|
+
ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
|
|
34
|
+
|
|
35
|
+
require_relative "../lib/robot_lab"
|
|
36
|
+
|
|
37
|
+
puts "=" * 62
|
|
38
|
+
puts "Example 30: Ractor Network Scheduler"
|
|
39
|
+
puts "=" * 62
|
|
40
|
+
puts
|
|
41
|
+
|
|
42
|
+
DIVIDER = ("─" * 54).freeze
|
|
43
|
+
|
|
44
|
+
# =============================================================================
|
|
45
|
+
# Part 1: Simulated scheduler — dependency waves and timing
|
|
46
|
+
# =============================================================================
|
|
47
|
+
#
|
|
48
|
+
# Pipeline topology:
|
|
49
|
+
#
|
|
50
|
+
# headline_finder ─────────────────────────────────┐
|
|
51
|
+
# ├──► report_writer
|
|
52
|
+
# background_brief ─────────────────────────────────┤
|
|
53
|
+
# │
|
|
54
|
+
# fact_checker ─────────────────────────────────┘
|
|
55
|
+
#
|
|
56
|
+
# Wave 1 — headline_finder, background_brief, fact_checker: parallel (none depend on each other)
|
|
57
|
+
# Wave 2 — report_writer: sequential (depends on all three)
|
|
58
|
+
#
|
|
59
|
+
# Simulated latencies (seconds):
|
|
60
|
+
LATENCIES = {
|
|
61
|
+
"headline_finder" => 0.60,
|
|
62
|
+
"background_brief" => 0.50,
|
|
63
|
+
"fact_checker" => 0.40,
|
|
64
|
+
"report_writer" => 0.70
|
|
65
|
+
}.freeze
|
|
66
|
+
#
|
|
67
|
+
# Expected times:
|
|
68
|
+
# Parallel — wave1 = max(0.60, 0.50, 0.40) = 0.60s
|
|
69
|
+
# wave2 = 0.70s
|
|
70
|
+
# total ≈ 1.30s
|
|
71
|
+
#
|
|
72
|
+
# Sequential — 0.60 + 0.50 + 0.40 + 0.70 = 2.20s
|
|
73
|
+
#
|
|
74
|
+
# Speedup ≈ 1.7×
|
|
75
|
+
|
|
76
|
+
puts "── Part 1: Simulated parallel run (no API key) ───────────"
|
|
77
|
+
puts
|
|
78
|
+
|
|
79
|
+
# SimulatedScheduler overrides execute_spec so that instead of
|
|
80
|
+
# constructing a real Robot and calling the LLM, it just sleeps for
|
|
81
|
+
# the robot's configured latency and returns a synthetic result string.
|
|
82
|
+
# All dependency-ordering and wave-dispatch logic is inherited unchanged.
|
|
83
|
+
class SimulatedScheduler < RobotLab::RactorNetworkScheduler
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def execute_spec(spec, _message)
|
|
87
|
+
delay = LATENCIES[spec.name] || 0.30
|
|
88
|
+
sleep delay
|
|
89
|
+
"#{spec.name}: completed after #{delay}s".freeze
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build RobotSpec objects directly.
|
|
94
|
+
# (Normally Network#run_with_ractor_scheduler builds these from Task wrappers.)
|
|
95
|
+
def make_spec(name, deps = :none)
|
|
96
|
+
spec = RobotLab::RobotSpec.new(
|
|
97
|
+
name: name.freeze,
|
|
98
|
+
template: nil,
|
|
99
|
+
system_prompt: "You are the #{name} robot.".freeze,
|
|
100
|
+
config_hash: {}.freeze
|
|
101
|
+
)
|
|
102
|
+
{ spec: spec, depends_on: deps }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
specs_with_deps = [
|
|
106
|
+
make_spec("headline_finder"),
|
|
107
|
+
make_spec("background_brief"),
|
|
108
|
+
make_spec("fact_checker"),
|
|
109
|
+
make_spec("report_writer", ["headline_finder", "background_brief", "fact_checker"])
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
memory = RobotLab::Memory.new
|
|
113
|
+
scheduler = SimulatedScheduler.new(memory: memory)
|
|
114
|
+
|
|
115
|
+
topic = "artificial intelligence regulation in 2025"
|
|
116
|
+
|
|
117
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
118
|
+
results = scheduler.run_pipeline(specs_with_deps, message: topic)
|
|
119
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
120
|
+
|
|
121
|
+
scheduler.shutdown
|
|
122
|
+
|
|
123
|
+
sequential_total = LATENCIES.values.sum
|
|
124
|
+
|
|
125
|
+
puts " Pipeline topology:"
|
|
126
|
+
puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
|
|
127
|
+
puts " Wave 2 (sequential): report_writer ← waits for all three"
|
|
128
|
+
puts
|
|
129
|
+
|
|
130
|
+
puts " Results (Hash { name => result_string }):"
|
|
131
|
+
results.each { |name, val| puts " \"#{name}\" => #{val.inspect}" }
|
|
132
|
+
puts
|
|
133
|
+
|
|
134
|
+
puts " Wall time: #{"%.3f" % elapsed}s"
|
|
135
|
+
puts " Sequential equiv: #{"%.3f" % sequential_total}s (#{LATENCIES.map { |n, d| "#{n}=#{d}" }.join(' + ')})"
|
|
136
|
+
puts " Speedup: #{(sequential_total / elapsed).round(1)}×"
|
|
137
|
+
puts
|
|
138
|
+
|
|
139
|
+
# =============================================================================
|
|
140
|
+
# Part 2: Network.new(parallel_mode: :ractor) API
|
|
141
|
+
# =============================================================================
|
|
142
|
+
|
|
143
|
+
puts "── Part 2: Network.new(parallel_mode: :ractor) ───────────"
|
|
144
|
+
puts
|
|
145
|
+
|
|
146
|
+
# When parallel_mode: :ractor is set on a Network, network.run(message:)
|
|
147
|
+
# routes through RactorNetworkScheduler instead of SimpleFlow::Pipeline.
|
|
148
|
+
# The default mode is :async (unchanged SimpleFlow behavior).
|
|
149
|
+
|
|
150
|
+
model = "claude-haiku-4-5-20251001"
|
|
151
|
+
|
|
152
|
+
network = RobotLab::Network.new(name: "research_pipeline", parallel_mode: :ractor) do
|
|
153
|
+
task :headline_finder, RobotLab.build(name: "headline_finder",
|
|
154
|
+
system_prompt: "Find the 3 most relevant news headlines. Be concise.",
|
|
155
|
+
model: model),
|
|
156
|
+
depends_on: :none
|
|
157
|
+
|
|
158
|
+
task :background_brief, RobotLab.build(name: "background_brief",
|
|
159
|
+
system_prompt: "Provide a 2-sentence background on the topic.",
|
|
160
|
+
model: model),
|
|
161
|
+
depends_on: :none
|
|
162
|
+
|
|
163
|
+
task :fact_checker, RobotLab.build(name: "fact_checker",
|
|
164
|
+
system_prompt: "List 3 verifiable facts about the topic.",
|
|
165
|
+
model: model),
|
|
166
|
+
depends_on: :none
|
|
167
|
+
|
|
168
|
+
task :report_writer, RobotLab.build(name: "report_writer",
|
|
169
|
+
system_prompt: "Synthesize the provided context into a 3-sentence report.",
|
|
170
|
+
model: model),
|
|
171
|
+
depends_on: ["headline_finder", "background_brief", "fact_checker"]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
puts " network.name => #{network.name.inspect}"
|
|
175
|
+
puts " network.parallel_mode => #{network.parallel_mode.inspect}"
|
|
176
|
+
puts
|
|
177
|
+
puts " Dependency graph (from network.pipeline.step_dependencies):"
|
|
178
|
+
|
|
179
|
+
network.pipeline.step_dependencies.each do |step, deps|
|
|
180
|
+
dep_str = deps.empty? ? "(entry point — Wave 1)" : "depends on: #{deps.join(', ')}"
|
|
181
|
+
puts " :#{step.to_s.ljust(18)} #{dep_str}"
|
|
182
|
+
end
|
|
183
|
+
puts
|
|
184
|
+
puts " When network.run(message: ...) is called:"
|
|
185
|
+
puts " • RactorNetworkScheduler is created with the network's memory"
|
|
186
|
+
puts " • Each task is converted to a frozen RobotSpec"
|
|
187
|
+
puts " • Wave 1 tasks are dispatched concurrently (Thread per task)"
|
|
188
|
+
puts " • Each Thread submits a RactorJob to the worker queue"
|
|
189
|
+
puts " • A Ractor worker pops the job, constructs a fresh Robot from the"
|
|
190
|
+
puts " spec, calls robot.run(message), and returns the frozen result"
|
|
191
|
+
puts " • Wave 2 begins once all Wave 1 results are available"
|
|
192
|
+
puts " • Return value is a Hash { robot_name => result_string }"
|
|
193
|
+
puts
|
|
194
|
+
|
|
195
|
+
# =============================================================================
|
|
196
|
+
# Part 3: Live LLM run (optional — requires ANTHROPIC_API_KEY)
|
|
197
|
+
# =============================================================================
|
|
198
|
+
|
|
199
|
+
unless ENV["ANTHROPIC_API_KEY"]
|
|
200
|
+
puts "── Part 3: Live LLM run ──────────────────────────────────"
|
|
201
|
+
puts " Set ANTHROPIC_API_KEY to run the real pipeline."
|
|
202
|
+
puts " Expected behavior: headline_finder, background_brief, and"
|
|
203
|
+
puts " fact_checker run in parallel; report_writer follows."
|
|
204
|
+
puts
|
|
205
|
+
puts "=" * 62
|
|
206
|
+
puts "Example 30 complete."
|
|
207
|
+
puts "=" * 62
|
|
208
|
+
exit 0
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
puts "── Part 3: Live LLM run (ANTHROPIC_API_KEY detected) ─────"
|
|
212
|
+
puts
|
|
213
|
+
|
|
214
|
+
puts " Running 4-robot research pipeline on:"
|
|
215
|
+
puts " \"#{topic}\""
|
|
216
|
+
puts
|
|
217
|
+
puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
|
|
218
|
+
puts " Wave 2: report_writer"
|
|
219
|
+
puts
|
|
220
|
+
|
|
221
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
222
|
+
|
|
223
|
+
begin
|
|
224
|
+
result = network.run(message: topic)
|
|
225
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
226
|
+
|
|
227
|
+
puts " Completed in #{"%.2f" % elapsed}s"
|
|
228
|
+
puts
|
|
229
|
+
puts " Results:"
|
|
230
|
+
result.each do |name, text|
|
|
231
|
+
snippet = text.to_s.strip.lines.first.to_s.strip
|
|
232
|
+
puts " [#{name}] #{snippet[0..90]}#{snippet.length > 90 ? '…' : ''}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
rescue Ractor::IsolationError, Ractor::Error => e
|
|
236
|
+
puts " Ractor error: #{e.class}: #{e.message[0..100]}"
|
|
237
|
+
puts
|
|
238
|
+
puts " ruby_llm is not yet Ractor-safe: it uses Procs in class"
|
|
239
|
+
puts " definitions that cannot cross Ractor boundaries."
|
|
240
|
+
puts " Track this at: https://github.com/crmne/ruby_llm"
|
|
241
|
+
|
|
242
|
+
rescue RobotLab::Error => e
|
|
243
|
+
if e.message.include?("un-shareable Proc")
|
|
244
|
+
puts " Live LLM runs require ruby_llm to be Ractor-safe."
|
|
245
|
+
puts " ruby_llm stores Procs in class-level hooks that cannot cross"
|
|
246
|
+
puts " Ractor boundaries. Part 1 (SimulatedScheduler) demonstrates wave"
|
|
247
|
+
puts " ordering today; Part 3 will work once ruby_llm gains Ractor support."
|
|
248
|
+
else
|
|
249
|
+
puts " RobotLab::Error: #{e.message}"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
puts
|
|
254
|
+
puts "=" * 62
|
|
255
|
+
puts "Example 30 complete."
|
|
256
|
+
puts "=" * 62
|
data/examples/README.md
CHANGED
|
@@ -25,6 +25,8 @@ bundle exec ruby examples/01_simple_robot.rb
|
|
|
25
25
|
|
|
26
26
|
```
|
|
27
27
|
examples/
|
|
28
|
+
27_incident_response/ # Phase 5 infra — BusPoller, reactive memory, poller groups
|
|
29
|
+
incident_response.rb # Main entrypoint — wires up the war room
|
|
28
30
|
01_simple_robot.rb # Basic robot with template
|
|
29
31
|
02_tools.rb # Robot with custom tools
|
|
30
32
|
03_network.rb # Multi-robot network with routing
|
|
@@ -45,6 +47,16 @@ examples/
|
|
|
45
47
|
scout.rb # Talent scout with analyst spawning
|
|
46
48
|
display.rb # Terminal formatting (color, wrapping, file output)
|
|
47
49
|
prompts/ # Templates for comic, heckler, and scout
|
|
50
|
+
19_token_tracking.rb # Per-robot token & cost tracking
|
|
51
|
+
20_circuit_breaker.rb # Tool loop circuit breaker with max_tool_rounds
|
|
52
|
+
21_learning_loop.rb # Learning accumulation across runs with robot.learn
|
|
53
|
+
22_context_compression.rb # Context window compression with HistoryCompressor
|
|
54
|
+
23_convergence.rb # Debate convergence detection and reconciler fast-path
|
|
55
|
+
24_structured_delegation.rb # Structured delegation with duration and token tracking
|
|
56
|
+
25_history_search.rb # Semantic search over a robot's conversation history
|
|
57
|
+
26_document_store.rb # Embedding-based document store (RAG) via fastembed
|
|
58
|
+
29_ractor_tools.rb # Ractor-safe tools: worker pool, freeze_deep, parallel batch
|
|
59
|
+
30_ractor_network.rb # Ractor network scheduler: dependency waves, parallel_mode
|
|
48
60
|
18_rails/ # Minimal Rails 8 demo app (full integration)
|
|
49
61
|
app/robots/chat_robot.rb # Robot factory with system prompt + TimeTool
|
|
50
62
|
app/tools/time_tool.rb # Custom RobotLab::Tool subclass
|
|
@@ -161,6 +173,130 @@ Demonstrates: Robot subclasses, self-modification via tool side effects, dynamic
|
|
|
161
173
|
|
|
162
174
|
**Requires:** LLM API key
|
|
163
175
|
|
|
176
|
+
### 19 — Token & Cost Tracking
|
|
177
|
+
|
|
178
|
+
Track token usage across runs using `result.input_tokens` / `result.output_tokens` for per-run counts and `robot.total_input_tokens` / `robot.total_output_tokens` for running totals. Demonstrates `reset_token_totals` to start a fresh batch and includes a simple cost estimate using per-provider pricing constants.
|
|
179
|
+
|
|
180
|
+
**Requires:** LLM API key
|
|
181
|
+
|
|
182
|
+
### 20 — Tool Loop Circuit Breaker
|
|
183
|
+
|
|
184
|
+
Guards against runaway tool call loops using `max_tool_rounds:`. A step processor tool is designed to always return "more steps remain", which would loop indefinitely without a guard. The circuit breaker fires after the configured limit and raises `RobotLab::ToolLoopError`. Shows how to rescue the error gracefully and confirms the robot is fully reusable after a breaker trip.
|
|
185
|
+
|
|
186
|
+
**Requires:** LLM API key
|
|
187
|
+
|
|
188
|
+
### 21 — Learning Accumulation Loop
|
|
189
|
+
|
|
190
|
+
Builds up cross-run observations with `robot.learn(text)`. A code reviewer accumulates one key insight after each review. On subsequent runs, learnings are automatically prepended to the user message as a "LEARNINGS FROM PREVIOUS RUNS:" block. Demonstrates bidirectional substring deduplication (broader learnings replace narrower ones), the `robot.learnings` accessor, and how learnings survive a robot rebuild via the shared `Memory` object.
|
|
191
|
+
|
|
192
|
+
**Requires:** LLM API key
|
|
193
|
+
|
|
194
|
+
### 22 — Context Window Compression
|
|
195
|
+
|
|
196
|
+
Demonstrates `robot.compress_history()` for reducing token usage in long conversations. Old turns are scored against the recent context using stemmed term-frequency cosine similarity (via the `classifier` gem). High-relevance turns are kept verbatim; irrelevant turns are dropped; medium-relevance turns can optionally be summarized by a second robot. Shows both drop-mode and summarizer-lambda patterns, plus the LLM summarizer integration recipe.
|
|
197
|
+
|
|
198
|
+
**Requires:** `gem 'classifier', '~> 2.3'` in your Gemfile (no LLM calls in the demo itself)
|
|
199
|
+
|
|
200
|
+
### 24 — Structured Delegation
|
|
201
|
+
|
|
202
|
+
A manager robot delegates sub-tasks to a summarizer and an analyst. Each `delegate()` call returns a `RobotResult` annotated with `delegated_by`, `duration`, and token counts. Includes a comparison table of when to use delegation vs. bus messaging vs. pipelines.
|
|
203
|
+
|
|
204
|
+
**Requires:** LLM API key
|
|
205
|
+
|
|
206
|
+
### 23 — Debate Convergence Detection
|
|
207
|
+
|
|
208
|
+
Demonstrates `RobotLab::Convergence` for detecting when two independent agents have reached the same conclusion. Scores pairs of texts from identical → semantically similar → partially related → unrelated, showing how the similarity metric varies. Includes the router fast-path pattern: when two verifier robots agree above a threshold, the expensive reconciler LLM call is skipped entirely.
|
|
209
|
+
|
|
210
|
+
**Requires:** `gem 'classifier', '~> 2.3'` in your Gemfile (no LLM calls in the demo itself)
|
|
211
|
+
|
|
212
|
+
### 24 — Structured Delegation
|
|
213
|
+
|
|
214
|
+
Demonstrates `robot.delegate(to:, task:)` for synchronous and asynchronous inter-robot delegation. The manager robot delegates document analysis to a summarizer and an analyst. Shows synchronous (sequential, blocking) and asynchronous (parallel fan-out, `DelegationFuture`) modes with wall-time comparison.
|
|
215
|
+
|
|
216
|
+
**Requires:** LLM API key
|
|
217
|
+
|
|
218
|
+
### 25 — Chat History Search
|
|
219
|
+
|
|
220
|
+
Demonstrates `robot.search_history(query, limit:)` — semantic search over accumulated conversation turns using stemmed TF cosine similarity. No LLM calls: messages are injected directly. Shows relevance ranking, role preservation, short-message filtering, and the `DependencyError` guard.
|
|
221
|
+
|
|
222
|
+
**Requires:** `gem 'classifier', '~> 2.3'` in your Gemfile (no LLM calls)
|
|
223
|
+
|
|
224
|
+
### 26 — Embedding-Based Document Store
|
|
225
|
+
|
|
226
|
+
Demonstrates `memory.store_document(key, text)` and `memory.search_documents(query, limit:)` — a lightweight RAG store using `fastembed` (BAAI/bge-small-en-v1.5). Documents are embedded once; queries are compared by cosine similarity at search time. Includes the `RobotLab::DocumentStore` standalone API and a RAG pattern sketch showing how to pass retrieved context to a robot.
|
|
227
|
+
|
|
228
|
+
**Requires:** `fastembed` gem (already a core dependency); downloads the ~23 MB ONNX model on first run (cached in `~/.cache/fastembed/`)
|
|
229
|
+
|
|
230
|
+
### 27 — Production Incident War Room
|
|
231
|
+
|
|
232
|
+
Three SRE scout robots investigate a payment-service outage in parallel — database layer, network layer, and application layer. Each scout stores its findings in shared reactive memory and broadcasts a status update to a war-room coordinator via TypedBus.
|
|
233
|
+
|
|
234
|
+
Demonstrates all four Phase 5 infrastructure improvements together:
|
|
235
|
+
|
|
236
|
+
| Feature | How it shows up |
|
|
237
|
+
|---------|----------------|
|
|
238
|
+
| **IO.pipe Waiter** (#13) | Commander blocks on `memory.get(:db_finding, :net_finding, :app_finding, wait: 60)`; wakes the instant the last scout writes its key |
|
|
239
|
+
| **BusPoller** (#14) | All three scouts send bus messages to the war room; BusPoller serializes delivery so war_room processes them one at a time, in arrival order, with no dropped messages |
|
|
240
|
+
| **Poller Groups** (#15) | Scout tasks labeled `poller_group: :investigation`; commander task labeled `poller_group: :command`; group list printed before the run |
|
|
241
|
+
| **Reactive Memory** | `memory.subscribe` callbacks fire in real-time as each scout writes, while the blocking waiter runs independently |
|
|
242
|
+
|
|
243
|
+
**Run:**
|
|
244
|
+
```bash
|
|
245
|
+
bundle exec ruby examples/27_incident_response/incident_response.rb
|
|
246
|
+
# or
|
|
247
|
+
bundle exec rake examples:run[27]
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Requires:** LLM API key
|
|
251
|
+
|
|
252
|
+
### 28 — MCP Server Discovery
|
|
253
|
+
|
|
254
|
+
When a robot has many MCP servers configured, connecting to all of them upfront is wasteful. `mcp_discovery: true` enables semantic server selection: before the first connection, `MCP::ServerDiscovery` scores each server's `name + description` against the user query using TF cosine similarity and connects only the relevant subset.
|
|
255
|
+
|
|
256
|
+
Demonstrates: `MCP::ServerDiscovery.select(query, from:, threshold:)`, the `description:` field on MCP server configs, `mcp_discovery: true` on Robot, and all four fallback cases (no descriptions, blank query, classifier unavailable, no match above threshold).
|
|
257
|
+
|
|
258
|
+
**Requires:** None (no LLM calls — exercises the discovery module directly)
|
|
259
|
+
|
|
260
|
+
### 29 — Ractor-Safe CPU Tools
|
|
261
|
+
|
|
262
|
+
Demonstrates Track 1 of RobotLab's Ractor parallelism: CPU-bound tool classes that
|
|
263
|
+
bypass the GVL by routing through a pool of Ractor workers.
|
|
264
|
+
|
|
265
|
+
| Section | What it shows |
|
|
266
|
+
|---------|--------------|
|
|
267
|
+
| `ractor_safe?` flags | `WordStatsTool`, `ReadabilityTool`, `HeavyDigestTool` inherit `ractor_safe true` from `TextTool`; `RequestCounterTool` (mutable class variable) does not |
|
|
268
|
+
| `RactorBoundary.freeze_deep` | Deep-freezes nested hashes/arrays; raises `RactorBoundaryError` on `StringIO` |
|
|
269
|
+
| Single pool submit | Direct `RobotLab.ractor_pool.submit(class_name, args)` calls |
|
|
270
|
+
| `ToolError` propagation | `nil.scan(...)` inside a Ractor worker → `NoMethodError` → `RobotLab::ToolError` |
|
|
271
|
+
| Parallel batch | 6 threads each submitting a `HeavyDigestTool` job (5 000 SHA-256 rounds) simultaneously vs sequentially; speedup visible on multi-core hardware |
|
|
272
|
+
|
|
273
|
+
**Requires:** None (no LLM calls)
|
|
274
|
+
|
|
275
|
+
### 30 — Ractor Network Scheduler
|
|
276
|
+
|
|
277
|
+
Demonstrates Track 2 of RobotLab's Ractor parallelism: running a multi-robot
|
|
278
|
+
pipeline under `parallel_mode: :ractor`, which dispatches independent tasks in
|
|
279
|
+
true parallel waves and respects `depends_on` ordering.
|
|
280
|
+
|
|
281
|
+
Pipeline topology (4 robots):
|
|
282
|
+
```
|
|
283
|
+
headline_finder ──┐
|
|
284
|
+
background_brief ──┼──► report_writer
|
|
285
|
+
fact_checker ──┘
|
|
286
|
+
```
|
|
287
|
+
Wave 1 (headline_finder + background_brief + fact_checker) runs in parallel;
|
|
288
|
+
Wave 2 (report_writer) runs after all three complete.
|
|
289
|
+
|
|
290
|
+
**Part 1** — `SimulatedScheduler` (overrides `execute_spec` with `sleep`) shows
|
|
291
|
+
wave ordering and timing without any API calls. Expected: ~1.3 s parallel vs 2.2 s sequential.
|
|
292
|
+
|
|
293
|
+
**Part 2** — Walks through `Network.new(parallel_mode: :ractor)` configuration
|
|
294
|
+
and the `pipeline.step_dependencies` dependency graph inspection.
|
|
295
|
+
|
|
296
|
+
**Part 3** — Live LLM run (enabled automatically when `ANTHROPIC_API_KEY` is set).
|
|
297
|
+
|
|
298
|
+
**Requires:** None for Parts 1 & 2. LLM API key for Part 3.
|
|
299
|
+
|
|
164
300
|
### 18 — Rails Integration Demo
|
|
165
301
|
|
|
166
302
|
A minimal, hand-built Rails 8 app that exercises every piece of RobotLab's Rails integration end-to-end. No `rails new` — every file is hand-crafted for minimum size.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ractor_queue"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
# Centralizes bus delivery serialization for robots.
|
|
7
|
+
#
|
|
8
|
+
# Each robot's TypedBus subscription calls enqueue() instead of
|
|
9
|
+
# handling deliveries inline. BusPoller ensures per-robot sequential
|
|
10
|
+
# processing: if a robot is already processing a delivery, new ones
|
|
11
|
+
# are queued and drained after the current one completes.
|
|
12
|
+
#
|
|
13
|
+
# Unlike the old per-robot @bus_processing flag, BusPoller is mutex-
|
|
14
|
+
# protected and shared across robots, giving a single point of control
|
|
15
|
+
# for delivery ordering. Delivery still happens in the caller's
|
|
16
|
+
# execution context (Async fiber or OS thread), so synchronous test
|
|
17
|
+
# semantics are preserved.
|
|
18
|
+
#
|
|
19
|
+
# == Poller Groups
|
|
20
|
+
#
|
|
21
|
+
# Robots can be assigned to named groups via the Network DSL:
|
|
22
|
+
#
|
|
23
|
+
# task :fast_bot, fast_robot, depends_on: :none
|
|
24
|
+
# task :slow_bot, slow_robot, depends_on: :none, poller_group: :slow
|
|
25
|
+
#
|
|
26
|
+
# Groups are purely organizational — they share the same mutex-based
|
|
27
|
+
# drain mechanism. In Async contexts, slow robots naturally yield during
|
|
28
|
+
# LLM HTTP calls without blocking fast robots.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
class BusPoller
|
|
32
|
+
# Capacity of per-robot RactorQueue delivery queues.
|
|
33
|
+
QUEUE_CAPACITY = 512
|
|
34
|
+
|
|
35
|
+
# Creates a new BusPoller.
|
|
36
|
+
def initialize
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@robot_busy = {} # robot_name => Boolean
|
|
39
|
+
@robot_queues = {} # robot_name => RactorQueue<delivery>
|
|
40
|
+
@groups = [:default]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# No-op — BusPoller has no background threads to start.
|
|
44
|
+
# Kept for API symmetry with the old design.
|
|
45
|
+
#
|
|
46
|
+
# @return [self]
|
|
47
|
+
def start
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# No-op — no threads to stop.
|
|
52
|
+
#
|
|
53
|
+
# @return [self]
|
|
54
|
+
def stop(*)
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Enqueue a delivery for a robot.
|
|
59
|
+
#
|
|
60
|
+
# If the robot is not currently processing, the delivery is handled
|
|
61
|
+
# immediately in the caller's context (Async fiber or OS thread).
|
|
62
|
+
# If the robot is busy, the delivery is queued and will be drained
|
|
63
|
+
# after the current delivery completes.
|
|
64
|
+
#
|
|
65
|
+
# @param robot [Robot] the robot that will process the delivery
|
|
66
|
+
# @param delivery [Object] the TypedBus delivery object
|
|
67
|
+
# @param group [Symbol] poller group label (informational only)
|
|
68
|
+
# @return [void]
|
|
69
|
+
#
|
|
70
|
+
def enqueue(robot:, delivery:, group: :default)
|
|
71
|
+
should_process = @mutex.synchronize do
|
|
72
|
+
name = robot.name
|
|
73
|
+
@robot_queues[name] ||= RactorQueue.new(capacity: QUEUE_CAPACITY)
|
|
74
|
+
@robot_busy[name] ||= false
|
|
75
|
+
|
|
76
|
+
if @robot_busy[name]
|
|
77
|
+
@robot_queues[name].push(delivery)
|
|
78
|
+
false
|
|
79
|
+
else
|
|
80
|
+
@robot_busy[name] = true
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
process_and_drain(robot, delivery) if should_process
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Add a named poller group.
|
|
89
|
+
#
|
|
90
|
+
# Idempotent. Groups are informational labels — they do not create
|
|
91
|
+
# separate queues or threads.
|
|
92
|
+
#
|
|
93
|
+
# @param name [Symbol]
|
|
94
|
+
# @return [void]
|
|
95
|
+
def add_group(name)
|
|
96
|
+
@mutex.synchronize { @groups << name unless @groups.include?(name) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Names of all registered poller groups.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Symbol>]
|
|
102
|
+
def groups
|
|
103
|
+
@mutex.synchronize { @groups.dup }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Always true — BusPoller needs no background threads.
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def running?
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Process one delivery, then drain any queued deliveries for the robot.
|
|
116
|
+
def process_and_drain(robot, delivery)
|
|
117
|
+
robot.send(:process_delivery, delivery)
|
|
118
|
+
|
|
119
|
+
loop do
|
|
120
|
+
next_delivery = @mutex.synchronize do
|
|
121
|
+
name = robot.name
|
|
122
|
+
queue = @robot_queues[name]
|
|
123
|
+
|
|
124
|
+
if queue && !queue.empty?
|
|
125
|
+
entry = queue.try_pop
|
|
126
|
+
entry.equal?(RactorQueue::EMPTY) ? nil : entry
|
|
127
|
+
else
|
|
128
|
+
@robot_busy[name] = false
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
break unless next_delivery
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
robot.send(:process_delivery, next_delivery)
|
|
137
|
+
rescue BusError => e
|
|
138
|
+
RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
rescue BusError => e
|
|
142
|
+
@mutex.synchronize { @robot_busy[robot.name] = false }
|
|
143
|
+
RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
|
|
144
|
+
rescue => e
|
|
145
|
+
@mutex.synchronize { @robot_busy[robot.name] = false }
|
|
146
|
+
RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# TF-IDF cosine similarity utilities for detecting semantic convergence
|
|
5
|
+
# between two texts.
|
|
6
|
+
#
|
|
7
|
+
# Common use cases:
|
|
8
|
+
# - Checking whether two independent verifiers have reached the same conclusion
|
|
9
|
+
# - Skipping a reconciler LLM call when verifiers already agree (fast-path)
|
|
10
|
+
# - Detecting when a multi-robot debate has converged on a consensus
|
|
11
|
+
#
|
|
12
|
+
# Requires the optional 'classifier' gem (~> 2.3).
|
|
13
|
+
#
|
|
14
|
+
# @example Skip reconciler when verifiers agree
|
|
15
|
+
# score = RobotLab::Convergence.similarity(result_a.reply, result_b.reply)
|
|
16
|
+
#
|
|
17
|
+
# router = ->(args) do
|
|
18
|
+
# a = args.context[:verifier_a]&.reply.to_s
|
|
19
|
+
# b = args.context[:verifier_b]&.reply.to_s
|
|
20
|
+
# RobotLab::Convergence.detected?(a, b) ? nil : ["reconciler"]
|
|
21
|
+
# end
|
|
22
|
+
module Convergence
|
|
23
|
+
# Default cosine similarity threshold above which texts are convergent.
|
|
24
|
+
DEFAULT_THRESHOLD = 0.85
|
|
25
|
+
|
|
26
|
+
# Minimum text length (characters) for meaningful TF-IDF scoring.
|
|
27
|
+
# Texts shorter than this always return 0.0 similarity.
|
|
28
|
+
MIN_TEXT_LENGTH = 30
|
|
29
|
+
|
|
30
|
+
# Determine whether two texts are semantically convergent.
|
|
31
|
+
#
|
|
32
|
+
# @param text_a [String]
|
|
33
|
+
# @param text_b [String]
|
|
34
|
+
# @param threshold [Float] minimum similarity to declare convergence (default 0.85)
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
# @raise [DependencyError] if the 'classifier' gem is not installed
|
|
37
|
+
# @raise [ArgumentError] if threshold is outside [0.0, 1.0]
|
|
38
|
+
def self.detected?(text_a, text_b, threshold: DEFAULT_THRESHOLD)
|
|
39
|
+
unless (0.0..1.0).cover?(threshold)
|
|
40
|
+
raise ArgumentError, "threshold must be in [0.0, 1.0], got #{threshold}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
similarity(text_a, text_b) >= threshold
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Compute cosine similarity between two texts using stemmed term frequencies.
|
|
47
|
+
#
|
|
48
|
+
# Uses +String#word_hash+ (provided by the classifier gem) to build
|
|
49
|
+
# stemmed, stopword-filtered term-frequency vectors, then computes
|
|
50
|
+
# L2-normalized cosine similarity. Term frequencies (no IDF) are used
|
|
51
|
+
# because IDF on a 2-document corpus collapses shared terms to zero,
|
|
52
|
+
# which would incorrectly penalize texts that agree on the same topic.
|
|
53
|
+
#
|
|
54
|
+
# Returns 0.0 when either text is blank or shorter than +MIN_TEXT_LENGTH+.
|
|
55
|
+
#
|
|
56
|
+
# @param text_a [String]
|
|
57
|
+
# @param text_b [String]
|
|
58
|
+
# @return [Float] in [0.0, 1.0]
|
|
59
|
+
# @raise [DependencyError] if the 'classifier' gem is not installed
|
|
60
|
+
def self.similarity(text_a, text_b)
|
|
61
|
+
a = text_a.to_s.strip
|
|
62
|
+
b = text_b.to_s.strip
|
|
63
|
+
|
|
64
|
+
return 0.0 if a.length < MIN_TEXT_LENGTH || b.length < MIN_TEXT_LENGTH
|
|
65
|
+
|
|
66
|
+
TextAnalysis.tf_cosine_similarity(a, b)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|