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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +210 -1
  4. data/Rakefile +2 -1
  5. data/docs/api/core/result.md +123 -0
  6. data/docs/api/core/robot.md +182 -0
  7. data/docs/api/errors.md +185 -0
  8. data/docs/guides/building-robots.md +125 -0
  9. data/docs/guides/creating-networks.md +21 -0
  10. data/docs/guides/index.md +10 -0
  11. data/docs/guides/knowledge.md +182 -0
  12. data/docs/guides/mcp-integration.md +106 -0
  13. data/docs/guides/memory.md +2 -0
  14. data/docs/guides/observability.md +486 -0
  15. data/docs/guides/ractor-parallelism.md +364 -0
  16. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  17. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  18. data/examples/19_token_tracking.rb +128 -0
  19. data/examples/20_circuit_breaker.rb +153 -0
  20. data/examples/21_learning_loop.rb +164 -0
  21. data/examples/22_context_compression.rb +179 -0
  22. data/examples/23_convergence.rb +137 -0
  23. data/examples/24_structured_delegation.rb +150 -0
  24. data/examples/25_history_search/conversation.jsonl +30 -0
  25. data/examples/25_history_search.rb +136 -0
  26. data/examples/26_document_store/api_versioning_adr.md +52 -0
  27. data/examples/26_document_store/incident_postmortem.md +46 -0
  28. data/examples/26_document_store/postgres_runbook.md +49 -0
  29. data/examples/26_document_store/redis_caching_guide.md +48 -0
  30. data/examples/26_document_store/sidekiq_guide.md +51 -0
  31. data/examples/26_document_store.rb +147 -0
  32. data/examples/27_incident_response/incident_response.rb +244 -0
  33. data/examples/28_mcp_discovery.rb +112 -0
  34. data/examples/29_ractor_tools.rb +243 -0
  35. data/examples/30_ractor_network.rb +256 -0
  36. data/examples/README.md +136 -0
  37. data/examples/prompts/skill_with_mcp_test.md +9 -0
  38. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  39. data/examples/prompts/skill_with_tools_test.md +6 -0
  40. data/lib/robot_lab/bus_poller.rb +149 -0
  41. data/lib/robot_lab/convergence.rb +69 -0
  42. data/lib/robot_lab/delegation_future.rb +93 -0
  43. data/lib/robot_lab/document_store.rb +155 -0
  44. data/lib/robot_lab/error.rb +25 -0
  45. data/lib/robot_lab/history_compressor.rb +205 -0
  46. data/lib/robot_lab/mcp/client.rb +17 -5
  47. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  48. data/lib/robot_lab/mcp/server.rb +7 -2
  49. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  50. data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
  51. data/lib/robot_lab/memory.rb +103 -6
  52. data/lib/robot_lab/network.rb +44 -9
  53. data/lib/robot_lab/ractor_boundary.rb +42 -0
  54. data/lib/robot_lab/ractor_job.rb +37 -0
  55. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  56. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  57. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  58. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  59. data/lib/robot_lab/robot/history_search.rb +69 -0
  60. data/lib/robot_lab/robot.rb +228 -11
  61. data/lib/robot_lab/robot_result.rb +24 -5
  62. data/lib/robot_lab/run_config.rb +1 -1
  63. data/lib/robot_lab/text_analysis.rb +103 -0
  64. data/lib/robot_lab/tool.rb +42 -3
  65. data/lib/robot_lab/tool_config.rb +1 -1
  66. data/lib/robot_lab/version.rb +1 -1
  67. data/lib/robot_lab/waiter.rb +49 -29
  68. data/lib/robot_lab.rb +25 -0
  69. data/mkdocs.yml +1 -0
  70. 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,9 @@
1
+ ---
2
+ description: Skill that sets mcp via frontmatter
3
+ mcp:
4
+ - name: skill_server
5
+ transport: stdio
6
+ command: echo
7
+ args: ["hello"]
8
+ ---
9
+ You have the MCP skill capabilities.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Skill that sets robot_name via frontmatter
3
+ robot_name: skill_named_bot
4
+ ---
5
+ You have the named skill capabilities.
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: Skill that sets tools via frontmatter
3
+ tools:
4
+ - FrontmatterTestTool
5
+ ---
6
+ You have the tools skill capabilities.
@@ -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