robot_lab 0.2.8 → 0.3.1

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 (262) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +16 -18
  4. data/lib/robot_lab/ask_user.rb +3 -3
  5. data/lib/robot_lab/config/defaults.yml +5 -5
  6. data/lib/robot_lab/config.rb +2 -2
  7. data/lib/robot_lab/mcp/transports/streamable_http.rb +2 -4
  8. data/lib/robot_lab/memory.rb +16 -7
  9. data/lib/robot_lab/robot/hooking.rb +26 -0
  10. data/lib/robot_lab/robot/result_building.rb +119 -0
  11. data/lib/robot_lab/robot.rb +37 -120
  12. data/lib/robot_lab/run_config.rb +52 -20
  13. data/lib/robot_lab/tool.rb +7 -12
  14. data/lib/robot_lab/version.rb +1 -1
  15. data/lib/robot_lab.rb +3 -3
  16. metadata +15 -274
  17. data/.architecture/AGENTS.md +0 -32
  18. data/.architecture/config.yml +0 -8
  19. data/.architecture/members.yml +0 -60
  20. data/.architecture/reviews/feature-free-will.md +0 -490
  21. data/.architecture/reviews/overall-codebase.md +0 -427
  22. data/.claude/settings.local.json +0 -57
  23. data/.codex/config.toml +0 -2
  24. data/.envrc +0 -6
  25. data/.github/workflows/deploy-github-pages.yml +0 -52
  26. data/.irbrc +0 -6
  27. data/.loki +0 -65
  28. data/.quality/reek_baseline.txt +0 -43
  29. data/.rubocop.yml +0 -10
  30. data/Archspec.rb +0 -44
  31. data/CLAUDE.md +0 -139
  32. data/COMMITS.md +0 -196
  33. data/Rakefile +0 -193
  34. data/_typos.toml +0 -21
  35. data/agent2agent_review.md +0 -192
  36. data/agentf_improvements.md +0 -253
  37. data/agents.md +0 -14
  38. data/docs/api/core/index.md +0 -147
  39. data/docs/api/core/memory.md +0 -578
  40. data/docs/api/core/network.md +0 -553
  41. data/docs/api/core/result.md +0 -211
  42. data/docs/api/core/robot.md +0 -1667
  43. data/docs/api/core/state.md +0 -145
  44. data/docs/api/core/tool.md +0 -520
  45. data/docs/api/errors.md +0 -323
  46. data/docs/api/hooks.md +0 -469
  47. data/docs/api/index.md +0 -137
  48. data/docs/api/mcp/client.md +0 -387
  49. data/docs/api/mcp/index.md +0 -267
  50. data/docs/api/mcp/server.md +0 -252
  51. data/docs/api/mcp/transports.md +0 -339
  52. data/docs/api/messages/index.md +0 -126
  53. data/docs/api/messages/text-message.md +0 -170
  54. data/docs/api/messages/tool-call-message.md +0 -206
  55. data/docs/api/messages/tool-result-message.md +0 -242
  56. data/docs/api/messages/user-message.md +0 -232
  57. data/docs/api/skills.md +0 -423
  58. data/docs/api/streaming/context.md +0 -280
  59. data/docs/api/streaming/events.md +0 -185
  60. data/docs/api/streaming/index.md +0 -156
  61. data/docs/api/support.md +0 -846
  62. data/docs/architecture/core-concepts.md +0 -575
  63. data/docs/architecture/index.md +0 -252
  64. data/docs/architecture/message-flow.md +0 -366
  65. data/docs/architecture/network-orchestration.md +0 -478
  66. data/docs/architecture/robot-execution.md +0 -396
  67. data/docs/architecture/state-management.md +0 -410
  68. data/docs/assets/css/custom.css +0 -56
  69. data/docs/assets/images/robot_lab.jpg +0 -0
  70. data/docs/concepts.md +0 -641
  71. data/docs/examples/basic-chat.md +0 -258
  72. data/docs/examples/index.md +0 -425
  73. data/docs/examples/mcp-server.md +0 -403
  74. data/docs/examples/multi-robot-network.md +0 -476
  75. data/docs/examples/tool-usage.md +0 -468
  76. data/docs/getting-started/configuration.md +0 -645
  77. data/docs/getting-started/index.md +0 -56
  78. data/docs/getting-started/installation.md +0 -200
  79. data/docs/getting-started/quick-start.md +0 -293
  80. data/docs/guides/building-robots.md +0 -1187
  81. data/docs/guides/creating-networks.md +0 -736
  82. data/docs/guides/hooks.md +0 -1206
  83. data/docs/guides/index.md +0 -78
  84. data/docs/guides/knowledge.md +0 -219
  85. data/docs/guides/mcp-integration.md +0 -740
  86. data/docs/guides/memory.md +0 -458
  87. data/docs/guides/observability.md +0 -733
  88. data/docs/guides/streaming.md +0 -323
  89. data/docs/guides/using-tools.md +0 -780
  90. data/docs/index.md +0 -280
  91. data/docs/superpowers/plans/2026-05-06-agentskills.md +0 -1303
  92. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +0 -247
  93. data/examples/.envrc +0 -1
  94. data/examples/01_simple_robot.rb +0 -31
  95. data/examples/02_tools.rb +0 -109
  96. data/examples/03_network.rb +0 -118
  97. data/examples/04_mcp.rb +0 -215
  98. data/examples/05_streaming.rb +0 -137
  99. data/examples/06_prompt_templates.rb +0 -325
  100. data/examples/07_network_memory.rb +0 -313
  101. data/examples/08_llm_config.rb +0 -162
  102. data/examples/09_chaining.rb +0 -251
  103. data/examples/10_memory.rb +0 -315
  104. data/examples/11_network_introspection.rb +0 -239
  105. data/examples/12_message_bus.rb +0 -67
  106. data/examples/13_spawn.rb +0 -85
  107. data/examples/14_rusty_circuit/.envrc +0 -1
  108. data/examples/14_rusty_circuit/.gitignore +0 -1
  109. data/examples/14_rusty_circuit/README.md +0 -1
  110. data/examples/14_rusty_circuit/comic.rb +0 -149
  111. data/examples/14_rusty_circuit/display.rb +0 -217
  112. data/examples/14_rusty_circuit/heckler.rb +0 -65
  113. data/examples/14_rusty_circuit/open_mic.rb +0 -132
  114. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +0 -20
  115. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +0 -23
  116. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +0 -20
  117. data/examples/14_rusty_circuit/scout.rb +0 -165
  118. data/examples/14_rusty_circuit/show.log +0 -234
  119. data/examples/15_memory_network_and_bus/.envrc +0 -1
  120. data/examples/15_memory_network_and_bus/editor_in_chief.rb +0 -24
  121. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +0 -213
  122. data/examples/15_memory_network_and_bus/linux_writer.rb +0 -80
  123. data/examples/15_memory_network_and_bus/os_editor.rb +0 -48
  124. data/examples/15_memory_network_and_bus/os_writer.rb +0 -49
  125. data/examples/15_memory_network_and_bus/output/combined_article.md +0 -13
  126. data/examples/15_memory_network_and_bus/output/final_article.md +0 -13
  127. data/examples/15_memory_network_and_bus/output/linux_draft.md +0 -7
  128. data/examples/15_memory_network_and_bus/output/mac_draft.md +0 -7
  129. data/examples/15_memory_network_and_bus/output/memory.json +0 -13
  130. data/examples/15_memory_network_and_bus/output/revision_1.md +0 -18
  131. data/examples/15_memory_network_and_bus/output/revision_2.md +0 -13
  132. data/examples/15_memory_network_and_bus/output/windows_draft.md +0 -7
  133. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +0 -13
  134. data/examples/15_memory_network_and_bus/prompts/os_chief.md +0 -13
  135. data/examples/15_memory_network_and_bus/prompts/os_editor.md +0 -13
  136. data/examples/16_writers_room/.envrc +0 -1
  137. data/examples/16_writers_room/display.rb +0 -158
  138. data/examples/16_writers_room/output/.gitignore +0 -4
  139. data/examples/16_writers_room/output/README.md +0 -69
  140. data/examples/16_writers_room/output/opus_001.md +0 -263
  141. data/examples/16_writers_room/output/opus_001_notes.log +0 -470
  142. data/examples/16_writers_room/output/opus_002.md +0 -245
  143. data/examples/16_writers_room/output/opus_002_notes.log +0 -546
  144. data/examples/16_writers_room/output/opus_002_screenplay.md +0 -7989
  145. data/examples/16_writers_room/output/opus_002_screenplay_notes.md +0 -993
  146. data/examples/16_writers_room/prompts/screenplay_writer.md +0 -66
  147. data/examples/16_writers_room/prompts/writer.md +0 -37
  148. data/examples/16_writers_room/room.rb +0 -186
  149. data/examples/16_writers_room/tools.rb +0 -173
  150. data/examples/16_writers_room/writer.rb +0 -121
  151. data/examples/16_writers_room/writers_room.rb +0 -256
  152. data/examples/17_skills.rb +0 -234
  153. data/examples/18_rails/.envrc +0 -3
  154. data/examples/18_rails/.gitignore +0 -5
  155. data/examples/18_rails/Gemfile +0 -11
  156. data/examples/18_rails/README.md +0 -67
  157. data/examples/18_rails/Rakefile +0 -4
  158. data/examples/18_rails/app/controllers/application_controller.rb +0 -4
  159. data/examples/18_rails/app/controllers/chat_controller.rb +0 -50
  160. data/examples/18_rails/app/jobs/application_job.rb +0 -4
  161. data/examples/18_rails/app/jobs/robot_run_job.rb +0 -25
  162. data/examples/18_rails/app/models/application_record.rb +0 -5
  163. data/examples/18_rails/app/models/robot_lab_result.rb +0 -36
  164. data/examples/18_rails/app/models/robot_lab_thread.rb +0 -23
  165. data/examples/18_rails/app/robots/chat_robot.rb +0 -25
  166. data/examples/18_rails/app/tools/time_tool.rb +0 -9
  167. data/examples/18_rails/app/views/chat/_user_message.html.erb +0 -1
  168. data/examples/18_rails/app/views/chat/index.html.erb +0 -67
  169. data/examples/18_rails/app/views/layouts/application.html.erb +0 -49
  170. data/examples/18_rails/bin/dev +0 -7
  171. data/examples/18_rails/bin/rails +0 -6
  172. data/examples/18_rails/bin/setup +0 -15
  173. data/examples/18_rails/config/application.rb +0 -33
  174. data/examples/18_rails/config/cable.yml +0 -2
  175. data/examples/18_rails/config/database.yml +0 -5
  176. data/examples/18_rails/config/environment.rb +0 -4
  177. data/examples/18_rails/config/initializers/robot_lab.rb +0 -11
  178. data/examples/18_rails/config/routes.rb +0 -6
  179. data/examples/18_rails/config.ru +0 -4
  180. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +0 -32
  181. data/examples/19_token_tracking.rb +0 -138
  182. data/examples/20_circuit_breaker.rb +0 -147
  183. data/examples/21_learning_loop.rb +0 -181
  184. data/examples/22_context_compression.rb +0 -172
  185. data/examples/23_convergence.rb +0 -133
  186. data/examples/24_structured_delegation.rb +0 -151
  187. data/examples/25_history_search/conversation.jsonl +0 -30
  188. data/examples/25_history_search.rb +0 -133
  189. data/examples/26_document_store/api_versioning_adr.md +0 -52
  190. data/examples/26_document_store/incident_postmortem.md +0 -46
  191. data/examples/26_document_store/postgres_runbook.md +0 -49
  192. data/examples/26_document_store/redis_caching_guide.md +0 -48
  193. data/examples/26_document_store/sidekiq_guide.md +0 -51
  194. data/examples/26_document_store.rb +0 -140
  195. data/examples/27_incident_response/incident_response.rb +0 -261
  196. data/examples/28_mcp_discovery.rb +0 -113
  197. data/examples/29_ractor_tools.rb +0 -240
  198. data/examples/30_ractor_network.rb +0 -252
  199. data/examples/31_launch_assessment.rb +0 -246
  200. data/examples/32_newsletter_reader.rb +0 -193
  201. data/examples/33_stock_generator.rb +0 -80
  202. data/examples/33_stock_predictor.rb +0 -327
  203. data/examples/34_agentskills.rb +0 -76
  204. data/examples/35_hooks.rb +0 -266
  205. data/examples/README.md +0 -533
  206. data/examples/common.rb +0 -148
  207. data/examples/prompts/assistant.md +0 -5
  208. data/examples/prompts/audit_trail.md +0 -8
  209. data/examples/prompts/billing.md +0 -10
  210. data/examples/prompts/classifier.md +0 -7
  211. data/examples/prompts/comedian.md +0 -6
  212. data/examples/prompts/comedy_critic.md +0 -10
  213. data/examples/prompts/configurable.md +0 -9
  214. data/examples/prompts/dispatcher.md +0 -12
  215. data/examples/prompts/entity_extractor.md +0 -14
  216. data/examples/prompts/escalation.md +0 -42
  217. data/examples/prompts/frontmatter_mcp_test.md +0 -9
  218. data/examples/prompts/frontmatter_named_test.md +0 -5
  219. data/examples/prompts/frontmatter_tools_test.md +0 -6
  220. data/examples/prompts/general.md +0 -7
  221. data/examples/prompts/github_assistant.md +0 -14
  222. data/examples/prompts/helper.md +0 -4
  223. data/examples/prompts/incident_responder.md +0 -18
  224. data/examples/prompts/keyword_extractor.md +0 -11
  225. data/examples/prompts/llm_config_demo.md +0 -20
  226. data/examples/prompts/order_support.md +0 -35
  227. data/examples/prompts/os_advocate.md +0 -13
  228. data/examples/prompts/os_chief.md +0 -13
  229. data/examples/prompts/os_editor.md +0 -13
  230. data/examples/prompts/parameterized_main_test.md +0 -6
  231. data/examples/prompts/pii_redactor.md +0 -7
  232. data/examples/prompts/product_support.md +0 -37
  233. data/examples/prompts/runbook_protocol.md +0 -12
  234. data/examples/prompts/sentiment_analyzer.md +0 -12
  235. data/examples/prompts/skill_a_test.md +0 -4
  236. data/examples/prompts/skill_b_test.md +0 -4
  237. data/examples/prompts/skill_config_test.md +0 -5
  238. data/examples/prompts/skill_cycle_a_test.md +0 -6
  239. data/examples/prompts/skill_cycle_b_test.md +0 -6
  240. data/examples/prompts/skill_description_test.md +0 -4
  241. data/examples/prompts/skill_leaf_test.md +0 -4
  242. data/examples/prompts/skill_nested_test.md +0 -6
  243. data/examples/prompts/skill_refs_main_test.md +0 -6
  244. data/examples/prompts/skill_self_ref_test.md +0 -6
  245. data/examples/prompts/skill_with_mcp_test.md +0 -9
  246. data/examples/prompts/skill_with_params_test.md +0 -6
  247. data/examples/prompts/skill_with_robot_name_test.md +0 -5
  248. data/examples/prompts/skill_with_tools_test.md +0 -6
  249. data/examples/prompts/sre_compliance.md +0 -10
  250. data/examples/prompts/structured_output.md +0 -7
  251. data/examples/prompts/synthesizer.md +0 -17
  252. data/examples/prompts/technical.md +0 -10
  253. data/examples/prompts/template_with_skills_test.md +0 -6
  254. data/examples/prompts/triage.md +0 -22
  255. data/examples/ruboruby.md +0 -423
  256. data/examples/xyzzy.rb +0 -97
  257. data/logfile +0 -8
  258. data/mkdocs.yml +0 -209
  259. data/robot_concurrency.md +0 -38
  260. data/simple_acp_review.md +0 -298
  261. data/temp.md +0 -6
  262. data/tool_manifest_plan.md +0 -155
@@ -1,252 +0,0 @@
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 LLM traffic.
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 LLM traffic either.
25
- #
26
- # Part 3 — live LLM run (opt-in via RUN_LIVE).
27
- # Expected to fail against ruby_llm today; the rescue clauses explain why.
28
- #
29
- # Usage:
30
- # bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
31
- # RUN_LIVE=1 bundle exec ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
32
-
33
- require_relative "common"
34
- require "robot_lab/ractor"
35
-
36
- banner "Ractor Network Scheduler"
37
-
38
- DIVIDER = ("─" * 54).freeze
39
-
40
- # =============================================================================
41
- # Part 1: Simulated scheduler — dependency waves and timing
42
- # =============================================================================
43
- #
44
- # Pipeline topology:
45
- #
46
- # headline_finder ─────────────────────────────────┐
47
- # ├──► report_writer
48
- # background_brief ─────────────────────────────────┤
49
- # │
50
- # fact_checker ─────────────────────────────────┘
51
- #
52
- # Wave 1 — headline_finder, background_brief, fact_checker: parallel (none depend on each other)
53
- # Wave 2 — report_writer: sequential (depends on all three)
54
- #
55
- # Simulated latencies (seconds):
56
- LATENCIES = {
57
- "headline_finder" => 0.60,
58
- "background_brief" => 0.50,
59
- "fact_checker" => 0.40,
60
- "report_writer" => 0.70
61
- }.freeze
62
- #
63
- # Expected times:
64
- # Parallel — wave1 = max(0.60, 0.50, 0.40) = 0.60s
65
- # wave2 = 0.70s
66
- # total ≈ 1.30s
67
- #
68
- # Sequential — 0.60 + 0.50 + 0.40 + 0.70 = 2.20s
69
- #
70
- # Speedup ≈ 1.7×
71
-
72
- section "Part 1: Simulated Parallel Run (no LLM traffic)"
73
-
74
- # SimulatedScheduler overrides execute_spec so that instead of
75
- # constructing a real Robot and calling the LLM, it just sleeps for
76
- # the robot's configured latency and returns a synthetic result string.
77
- # All dependency-ordering and wave-dispatch logic is inherited unchanged.
78
- class SimulatedScheduler < RobotLab::RactorNetworkScheduler
79
- private
80
-
81
- def execute_spec(spec, _message)
82
- delay = LATENCIES[spec.name] || 0.30
83
- sleep delay
84
- "#{spec.name}: completed after #{delay}s".freeze
85
- end
86
- end
87
-
88
- # Build RobotSpec objects directly.
89
- # (Normally Network#run_with_ractor_scheduler builds these from Task wrappers.)
90
- def make_spec(name, deps = :none)
91
- spec = RobotLab::RobotSpec.new(
92
- name: name.freeze,
93
- template: nil,
94
- system_prompt: "You are the #{name} robot.".freeze,
95
- config_hash: {}.freeze
96
- )
97
- { spec: spec, depends_on: deps }
98
- end
99
-
100
- specs_with_deps = [
101
- make_spec("headline_finder"),
102
- make_spec("background_brief"),
103
- make_spec("fact_checker"),
104
- make_spec("report_writer", ["headline_finder", "background_brief", "fact_checker"])
105
- ]
106
-
107
- memory = RobotLab::Memory.new
108
- scheduler = SimulatedScheduler.new(memory: memory)
109
-
110
- topic = "artificial intelligence regulation in 2025"
111
-
112
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
- results = scheduler.run_pipeline(specs_with_deps, message: topic)
114
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
115
-
116
- scheduler.shutdown
117
-
118
- sequential_total = LATENCIES.values.sum
119
-
120
- puts " Pipeline topology:"
121
- puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
122
- puts " Wave 2 (sequential): report_writer ← waits for all three"
123
- puts
124
-
125
- puts " Results (Hash { name => result_string }):"
126
- results.each { |name, val| puts " \"#{name}\" => #{val.inspect}" }
127
- puts
128
-
129
- puts " Wall time: #{"%.3f" % elapsed}s"
130
- puts " Sequential equiv: #{"%.3f" % sequential_total}s (#{LATENCIES.map { |n, d| "#{n}=#{d}" }.join(' + ')})"
131
- puts " Speedup: #{(sequential_total / elapsed).round(1)}×"
132
- puts
133
-
134
- # =============================================================================
135
- # Part 2: Network.new(parallel_mode: :ractor) API
136
- # =============================================================================
137
-
138
- section "Part 2: Network.new(parallel_mode: :ractor)"
139
-
140
- # When parallel_mode: :ractor is set on a Network, network.run(message:)
141
- # routes through RactorNetworkScheduler instead of SimpleFlow::Pipeline.
142
- # The default mode is :async (unchanged SimpleFlow behavior).
143
-
144
- network = RobotLab::Network.new(name: "research_pipeline", parallel_mode: :ractor) do
145
- task :headline_finder, RobotLab.build(name: "headline_finder",
146
- system_prompt: "Find the 3 most relevant news headlines. Be concise.",
147
- **llm_opts),
148
- depends_on: :none
149
-
150
- task :background_brief, RobotLab.build(name: "background_brief",
151
- system_prompt: "Provide a 2-sentence background on the topic.",
152
- **llm_opts),
153
- depends_on: :none
154
-
155
- task :fact_checker, RobotLab.build(name: "fact_checker",
156
- system_prompt: "List 3 verifiable facts about the topic.",
157
- **llm_opts),
158
- depends_on: :none
159
-
160
- task :report_writer, RobotLab.build(name: "report_writer",
161
- system_prompt: "Synthesize the provided context into a 3-sentence report.",
162
- **llm_opts),
163
- depends_on: ["headline_finder", "background_brief", "fact_checker"]
164
- end
165
-
166
- puts " network.name => #{network.name.inspect}"
167
- puts " network.parallel_mode => #{network.parallel_mode.inspect}"
168
- puts
169
- puts " Dependency graph (from network.pipeline.step_dependencies):"
170
-
171
- network.pipeline.step_dependencies.each do |step, deps|
172
- dep_str = deps.empty? ? "(entry point — Wave 1)" : "depends on: #{deps.join(', ')}"
173
- puts " :#{step.to_s.ljust(18)} #{dep_str}"
174
- end
175
- puts
176
- puts " When network.run(message: ...) is called:"
177
- puts " • RactorNetworkScheduler is created with the network's memory"
178
- puts " • Each task is converted to a frozen RobotSpec"
179
- puts " • Wave 1 tasks are dispatched concurrently (Thread per task)"
180
- puts " • Each Thread submits a RactorJob to the worker queue"
181
- puts " • A Ractor worker pops the job, constructs a fresh Robot from the"
182
- puts " spec, calls robot.run(message), and returns the frozen result"
183
- puts " • Wave 2 begins once all Wave 1 results are available"
184
- puts " • Return value is a Hash { robot_name => result_string }"
185
- puts
186
-
187
- # =============================================================================
188
- # Part 3: Live LLM run (opt-in via RUN_LIVE=1)
189
- # =============================================================================
190
-
191
- unless ENV["RUN_LIVE"]
192
- section "Part 3: Live LLM Run"
193
- puts " Set RUN_LIVE=1 (with Ollama running) to attempt the real pipeline."
194
- puts " Expected behavior: headline_finder, background_brief, and"
195
- puts " fact_checker run in parallel; report_writer follows."
196
- puts
197
- puts " Heads-up: this is expected to fail today — see the rescue clauses"
198
- puts " below. ruby_llm keeps Procs in class-level state, which cannot"
199
- puts " cross a Ractor boundary. Part 1 shows the wave ordering that Part 3"
200
- puts " will exercise once ruby_llm is Ractor-safe."
201
- puts
202
- hr
203
- puts "Example 30 complete."
204
- exit 0
205
- end
206
-
207
- require_ollama!
208
-
209
- section "Part 3: Live LLM Run (RUN_LIVE set)"
210
-
211
- puts " Running 4-robot research pipeline on:"
212
- puts " \"#{topic}\""
213
- puts
214
- puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
215
- puts " Wave 2: report_writer"
216
- puts
217
-
218
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
219
-
220
- begin
221
- result = network.run(message: topic)
222
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
223
-
224
- puts " Completed in #{"%.2f" % elapsed}s"
225
- puts
226
- puts " Results:"
227
- result.each do |name, text|
228
- snippet = text.to_s.strip.lines.first.to_s.strip
229
- puts " [#{name}] #{snippet[0..90]}#{snippet.length > 90 ? '…' : ''}"
230
- end
231
-
232
- rescue Ractor::IsolationError, Ractor::Error => e
233
- puts " Ractor error: #{e.class}: #{e.message[0..100]}"
234
- puts
235
- puts " ruby_llm is not yet Ractor-safe: it uses Procs in class"
236
- puts " definitions that cannot cross Ractor boundaries."
237
- puts " Track this at: https://github.com/crmne/ruby_llm"
238
-
239
- rescue RobotLab::Error => e
240
- if e.message.include?("un-shareable Proc")
241
- puts " Live LLM runs require ruby_llm to be Ractor-safe."
242
- puts " ruby_llm stores Procs in class-level hooks that cannot cross"
243
- puts " Ractor boundaries. Part 1 (SimulatedScheduler) demonstrates wave"
244
- puts " ordering today; Part 3 will work once ruby_llm gains Ractor support."
245
- else
246
- puts " RobotLab::Error: #{e.message}"
247
- end
248
- end
249
-
250
- puts
251
- hr
252
- puts "Example 30 complete."
@@ -1,246 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Example 31: Product Launch Assessment — 6 Parallel Analysts, Cap of 4
5
- #
6
- # Six specialist robots evaluate a product launch simultaneously.
7
- # max_concurrent_robots: 4 ensures at most 4 LLM API calls are in-flight
8
- # at once. Robots 5 and 6 queue behind the Async::Semaphore and start as
9
- # soon as any of the first 4 finishes — providing natural back-pressure
10
- # without slowing the pipeline more than necessary.
11
- #
12
- # Architecture:
13
- #
14
- # ┌──────────────────────────────────────────────────────────────────────┐
15
- # │ PARALLEL ANALYSIS PHASE (max_concurrent_robots: 4) │
16
- # │ │
17
- # │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
18
- # │ │ Market │ │ Compet. │ │ Tech │ │ Risk │ slots 1-4 │
19
- # │ │ Analyst │ │ Analyst │ │ Reviewer │ │ Assessor │ │
20
- # │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
21
- # │ │ │ │ │ │
22
- # │ start start start start │
23
- # │ │
24
- # │ ┌──────────┐ ┌──────────┐ │
25
- # │ │Financial │ │ Legal │ queued — start when a slot opens │
26
- # │ │ Reviewer │ │ Reviewer │ │
27
- # │ └────┬─────┘ └────┬─────┘ │
28
- # │ │ │ │
29
- # │ (deferred) (deferred) │
30
- # │ │
31
- # │ ┌─────────────────────────────────────────────────────────────┐ │
32
- # │ │ SHARED MEMORY │ │
33
- # │ │ :market :competitive :tech :risk :financial :legal │ │
34
- # │ └─────────────────────────────────────────────────────────────┘ │
35
- # │ │ │
36
- # │ ▼ │
37
- # │ ┌─────────────────────────────────────────────────────────────┐ │
38
- # │ │ Launch Director │ │
39
- # │ │ Blocks on reactive memory until all 6 findings arrive, │ │
40
- # │ │ then issues a GO / NO-GO recommendation. │ │
41
- # │ └─────────────────────────────────────────────────────────────┘ │
42
- # └──────────────────────────────────────────────────────────────────────┘
43
- #
44
- # Key config:
45
- # RunConfig.new(max_concurrent_robots: 4)
46
- #
47
- # Usage:
48
- # ruby examples/31_launch_assessment.rb
49
-
50
- require_relative "common"
51
-
52
- # ── AnalystRobot ────────────────────────────────────────────────────────────────
53
- #
54
- # Runs its LLM call, writes the verdict to shared memory, and logs a timing line
55
- # so you can see when the semaphore releases robots 5 and 6.
56
-
57
- class AnalystRobot < RobotLab::Robot
58
- attr_reader :memory_key
59
- attr_writer :shared_memory
60
-
61
- def initialize(name:, memory_key:, role:)
62
- super(
63
- name: name,
64
- **llm_opts,
65
- system_prompt: "You are a #{role}. " \
66
- "Review the product brief in 2-3 crisp sentences from your area of expertise. " \
67
- "Close with a one-word verdict: READY or NOT-READY."
68
- )
69
- @memory_key = memory_key
70
- end
71
-
72
- def call(result)
73
- brief = extract_brief(result)
74
- started = Time.now
75
- puts " [#{name}] started at +#{"%.1f" % (started - $run_start)}s"
76
-
77
- verdict = run(brief).reply.strip
78
-
79
- elapsed = "%.1f" % (Time.now - started)
80
- puts " [#{name}] finished in #{elapsed}s — #{verdict.split.last(2).join(" ")}"
81
-
82
- if @shared_memory
83
- @shared_memory.current_writer = name
84
- @shared_memory.set(@memory_key, verdict)
85
- end
86
-
87
- result.with_context(name.to_sym, verdict).continue(verdict)
88
- end
89
-
90
- private
91
-
92
- def extract_brief(result)
93
- case result.value
94
- when Hash then result.value[:message].to_s
95
- when RobotLab::RobotResult then result.value.reply.to_s
96
- else result.value.to_s
97
- end
98
- end
99
- end
100
-
101
- # ── LaunchDirector ──────────────────────────────────────────────────────────────
102
- #
103
- # Waits for all 6 findings via reactive memory, then issues the final call.
104
- # SimpleFlow guarantees the six analyst tasks are done before this task runs,
105
- # so the memory.get is effectively a non-blocking read by the time we arrive here.
106
-
107
- class LaunchDirector < RobotLab::Robot
108
- attr_writer :shared_memory
109
-
110
- FINDING_KEYS = %i[market competitive tech risk financial legal].freeze
111
-
112
- def call(result)
113
- puts " [#{name}] reading all findings from shared memory..."
114
- findings = collect_findings
115
-
116
- prompt = <<~PROMPT
117
- Six specialist analysts have reviewed our product launch readiness.
118
- Based on their findings, issue a final GO or NO-GO recommendation
119
- in 3-5 sentences. Be direct and specific about the key deciding factors.
120
-
121
- Market analysis: #{findings[:market] || "(not received)"}
122
- Competitive analysis: #{findings[:competitive] || "(not received)"}
123
- Technical review: #{findings[:tech] || "(not received)"}
124
- Risk assessment: #{findings[:risk] || "(not received)"}
125
- Financial review: #{findings[:financial] || "(not received)"}
126
- Legal review: #{findings[:legal] || "(not received)"}
127
-
128
- Begin your response with "GO -" or "NO-GO -".
129
- PROMPT
130
-
131
- recommendation = run(prompt).reply.strip
132
- @shared_memory.set(:recommendation, recommendation)
133
- puts " [#{name}] recommendation ready"
134
-
135
- result.with_context(:recommendation, recommendation).continue(recommendation)
136
- end
137
-
138
- private
139
-
140
- # Memory#get(wait:) RAISES RobotLab::AwaitTimeout on expiry — it never
141
- # returns a :timeout sentinel. Rescue it and re-read without wait:, which
142
- # never blocks and omits keys that were never written, so a stalled analyst
143
- # degrades the recommendation instead of killing the director.
144
- def collect_findings
145
- @shared_memory.get(*FINDING_KEYS, wait: 120)
146
- rescue RobotLab::AwaitTimeout => e
147
- puts " [#{name}] WARNING: #{e.message}"
148
- partial = @shared_memory.get(*FINDING_KEYS)
149
- missing = FINDING_KEYS - partial.keys
150
- puts " [#{name}] Proceeding without: #{missing.join(', ')}" if missing.any?
151
- partial
152
- end
153
- end
154
-
155
- # ── Product Brief ───────────────────────────────────────────────────────────────
156
-
157
- PRODUCT_BRIEF = <<~BRIEF
158
- Product: "Orion" — an AI-powered project management tool that auto-generates
159
- sprint plans from Jira backlogs, detects scope creep in real-time, and integrates
160
- with GitHub and Slack via webhooks. SaaS pricing: $25/seat/month, 14-day free trial.
161
- Target: mid-size engineering teams (20-200 developers). Launch date: 6 weeks out.
162
- Beta: 12 paying customers, 94% satisfaction, 0 critical bugs open. SOC 2 Type I
163
- certification in progress, expected within 30 days.
164
- BRIEF
165
-
166
- # ── Build the six analysts ───────────────────────────────────────────────────────
167
-
168
- ANALYSTS = [
169
- { name: "market_analyst", key: :market, role: "market opportunity analyst" },
170
- { name: "competitive_analyst", key: :competitive, role: "competitive intelligence analyst" },
171
- { name: "tech_reviewer", key: :tech, role: "technical readiness and quality reviewer" },
172
- { name: "risk_assessor", key: :risk, role: "product risk assessment specialist" },
173
- { name: "financial_reviewer", key: :financial, role: "financial viability and pricing analyst" },
174
- { name: "legal_reviewer", key: :legal, role: "legal, compliance, and IP reviewer" },
175
- ].freeze
176
-
177
- analyst_robots = ANALYSTS.map do |spec|
178
- AnalystRobot.new(name: spec[:name], memory_key: spec[:key], role: spec[:role])
179
- end
180
-
181
- director = LaunchDirector.new(
182
- name: "launch_director",
183
- **llm_opts,
184
- system_prompt: "You are the VP of Product making the final launch call."
185
- )
186
-
187
- # ── Network — note the concurrency cap ──────────────────────────────────────────
188
-
189
- config = RobotLab::RunConfig.new(max_concurrent_robots: 4)
190
-
191
- analyst_names = analyst_robots.map { |r| r.name.to_sym }
192
-
193
- network = RobotLab.create_network(name: "launch_assessment", config: config) do
194
- analyst_robots.each do |robot|
195
- task robot.name.to_sym, robot, depends_on: :none
196
- end
197
-
198
- task :launch_director, director, depends_on: analyst_names
199
- end
200
-
201
- # Assign shared memory so each robot can write to it directly
202
- shared_memory = network.memory
203
- (analyst_robots + [director]).each { |r| r.shared_memory = shared_memory }
204
-
205
- # Subscribe for a memory-level audit trail
206
- analyst_robots.each do |robot|
207
- network.memory.subscribe(robot.memory_key) do |change|
208
- puts " [memory] :#{change.key} written by #{change.writer}"
209
- end
210
- end
211
-
212
- # ── Run ─────────────────────────────────────────────────────────────────────────
213
-
214
- banner "Product Launch Assessment"
215
- puts " 6 specialist analysts in parallel, max_concurrent_robots: 4"
216
- puts
217
- puts "Pipeline:"
218
- puts network.visualize
219
- puts
220
- puts "Concurrency config: #{config.inspect}"
221
- puts
222
- puts "Product brief:"
223
- puts PRODUCT_BRIEF.strip.gsub(/^/, " ")
224
- puts
225
- section "Running — Analysts 5 and 6 Queue Until a Semaphore Slot Opens"
226
-
227
- $run_start = Time.now
228
- result = network.run(message: PRODUCT_BRIEF)
229
- elapsed = "%.1f" % (Time.now - $run_start)
230
-
231
- puts
232
- hr
233
- puts "All analysts complete. Total wall time: #{elapsed}s"
234
- hr
235
-
236
- section "Launch Director Recommendation"
237
- puts network.memory[:recommendation]
238
- puts
239
- section "Individual Analyst Verdicts"
240
- analyst_robots.each do |robot|
241
- label = robot.name.gsub("_", " ").upcase
242
- finding = network.memory.get(robot.memory_key).to_s
243
- puts "#{label}"
244
- puts finding
245
- puts
246
- end
@@ -1,193 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Example 32: Newsletter Issue Retriever
5
- #
6
- # NOTE: this is a plain utility script, not a RobotLab demo — it uses no part
7
- # of the library. It lives here as the content feeder for other experiments.
8
- # Read examples 01-31 for library capabilities.
9
- #
10
- # Fetches all unprocessed issues from multiple Ruby newsletters via RSS
11
- # and saves each as a Markdown file in a Markdown clippings folder.
12
- #
13
- # - Processes oldest unprocessed issue first (across all feeds)
14
- # - Filename: <newsletter-name>_YYYYMMDD.md
15
- # - Includes YAML frontmatter with source URL (compatible with Clippings workflow)
16
- # - Tracks processed issue URLs in ~/.robot_lab/newsletter_processed.yaml
17
- #
18
- # Usage:
19
- # ruby examples/32_newsletter_reader.rb
20
- # CLIPPINGS_DIR=~/Notes/Clippings ruby examples/32_newsletter_reader.rb
21
-
22
- require "net/http"
23
- require "open3"
24
- require "time"
25
- require "uri"
26
- require "yaml"
27
- require "fileutils"
28
- require "rexml/document"
29
- require "rexml/xpath"
30
-
31
- NEWSLETTER_RSS_URLS = [
32
- "https://rss.beehiiv.com/feeds/MTJunJRFxo.xml", # Ruby AI News
33
- "https://cprss.s3.amazonaws.com/rubyweekly.com.xml" # Ruby Weekly
34
- ].freeze
35
-
36
- CLIPPINGS_DIR = File.expand_path(ENV.fetch("CLIPPINGS_DIR", "~/Documents/Clippings"))
37
- PROCESSED_STATE_FILE = File.join(Dir.home, ".robot_lab", "newsletter_processed.yaml")
38
-
39
- # Tracks which newsletter issue URLs have been saved.
40
- class ProcessedIssues
41
- def initialize(path: PROCESSED_STATE_FILE)
42
- @path = path
43
- @urls = load_urls
44
- end
45
-
46
- def processed?(url)
47
- @urls.include?(url)
48
- end
49
-
50
- def mark_processed(url)
51
- @urls << url
52
- FileUtils.mkdir_p(File.dirname(@path))
53
- File.write(@path, YAML.dump(@urls.uniq))
54
- end
55
-
56
- def count = @urls.size
57
-
58
- private
59
-
60
- def load_urls
61
- return [] unless File.exist?(@path)
62
- Array(YAML.safe_load(File.read(@path)) || [])
63
- end
64
- end
65
-
66
- # Fetches a single RSS feed URL and returns its items (unsorted).
67
- # Each item: { title:, url:, pub_date:, published_at:, html:, channel_name: }
68
- def fetch_rss_items(url)
69
- uri = URI(url)
70
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.get(uri.request_uri) }
71
- raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
72
-
73
- doc = REXML::Document.new(response.body)
74
- ns = { "content" => "http://purl.org/rss/1.0/modules/content/" }
75
-
76
- channel_title = REXML::XPath.first(doc, "//channel/title")&.text&.strip || "newsletter"
77
-
78
- REXML::XPath.match(doc, "//channel/item").filter_map do |item|
79
- item_url = REXML::XPath.first(item, "link")&.text&.strip
80
- title = REXML::XPath.first(item, "title")&.text&.strip
81
- pub_date = REXML::XPath.first(item, "pubDate")&.text&.strip
82
- html = REXML::XPath.first(item, "content:encoded", ns)&.text ||
83
- REXML::XPath.first(item, "description")&.text || ""
84
-
85
- next unless item_url && title && pub_date
86
-
87
- {
88
- title: title,
89
- url: item_url,
90
- pub_date: pub_date,
91
- published_at: Time.parse(pub_date),
92
- html: html,
93
- channel_name: channel_title
94
- }
95
- end
96
- end
97
-
98
- # Fetches all configured RSS feeds and returns items sorted oldest-first.
99
- def fetch_all_rss_items
100
- NEWSLETTER_RSS_URLS.flat_map do |feed_url|
101
- print " #{feed_url}... "
102
- items = fetch_rss_items(feed_url)
103
- puts "#{items.size} issues"
104
- items
105
- end.sort_by { |item| item[:published_at] }
106
- end
107
-
108
- # Converts HTML to Markdown via html2markdown CLI, stripping UTM params.
109
- def html_to_markdown(html)
110
- md, = Open3.capture3(
111
- "html2markdown",
112
- stdin_data: html
113
- )
114
- md.gsub(/\]\(([^)]+)\)/) do
115
- url = $1
116
- if url.include?("?")
117
- base, query = url.split("?", 2)
118
- kept = query.split("&").reject { |p| p.start_with?("utm_") }
119
- "](#{kept.empty? ? base : "#{base}?#{kept.join("&")}"})"
120
- else
121
- "](#{url})"
122
- end
123
- end
124
- end
125
-
126
- # Builds the output filename: <newsletter-name>_YYYYMMDD.md
127
- def output_filename(channel_name, published_at)
128
- safe_name = channel_name.downcase.gsub(/[^a-z0-9]+/, "_").delete_suffix("_")
129
- date_str = published_at.strftime("%Y%m%d")
130
- "#{safe_name}_#{date_str}.md"
131
- end
132
-
133
- # Wraps markdown content in YAML frontmatter compatible with the Clippings workflow.
134
- def wrap_with_frontmatter(title:, url:, pub_date:, body:)
135
- date = Time.parse(pub_date).strftime("%Y-%m-%d")
136
- <<~MD
137
- ---
138
- source: #{url}
139
- title: "#{title.gsub('"', '\\"')}"
140
- date: #{date}
141
- ---
142
-
143
- # #{title}
144
-
145
- #{body.strip}
146
- MD
147
- end
148
-
149
- # ── Main ──────────────────────────────────────────────────────────────────────
150
-
151
- puts "=" * 60
152
- puts "Example 32: Newsletter Issue Retriever"
153
- puts "=" * 60
154
- puts
155
-
156
- FileUtils.mkdir_p(CLIPPINGS_DIR)
157
- state = ProcessedIssues.new
158
-
159
- puts "Fetching #{NEWSLETTER_RSS_URLS.size} RSS feeds..."
160
- all_items = fetch_all_rss_items
161
- pending = all_items.reject { |item| state.processed?(item[:url]) }
162
- puts "#{all_items.size} total issues found across all feeds."
163
-
164
- if pending.empty?
165
- puts "All issues already saved. Nothing to do."
166
- exit
167
- end
168
-
169
- puts "#{pending.size} unprocessed (#{state.count} already done). Saving oldest-first."
170
- puts
171
-
172
- pending.each_with_index do |item, idx|
173
- filename = output_filename(item[:channel_name], item[:published_at])
174
- filepath = File.join(CLIPPINGS_DIR, filename)
175
-
176
- print "[#{idx + 1}/#{pending.size}] #{item[:title]} (#{item[:published_at].strftime("%Y-%m-%d")})... "
177
-
178
- body = html_to_markdown(item[:html])
179
- content = wrap_with_frontmatter(
180
- title: item[:title],
181
- url: item[:url],
182
- pub_date: item[:pub_date],
183
- body: body
184
- )
185
-
186
- File.write(filepath, content)
187
- state.mark_processed(item[:url])
188
-
189
- puts "saved → #{filename}"
190
- end
191
-
192
- puts
193
- puts "Done. #{pending.size} issue(s) saved to #{CLIPPINGS_DIR}"