robot_lab 0.1.0 → 0.2.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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.irbrc +2 -2
  10. data/.rubocop.yml +172 -0
  11. data/CHANGELOG.md +72 -0
  12. data/CLAUDE.md +139 -0
  13. data/README.md +91 -95
  14. data/Rakefile +109 -3
  15. data/agent2agent_review.md +192 -0
  16. data/agentf_improvements.md +253 -0
  17. data/agents.md +14 -0
  18. data/docs/examples/index.md +37 -2
  19. data/docs/getting-started/configuration.md +20 -7
  20. data/docs/guides/index.md +16 -16
  21. data/docs/guides/knowledge.md +7 -1
  22. data/docs/guides/observability.md +132 -0
  23. data/docs/index.md +30 -3
  24. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  25. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  26. data/examples/.envrc +1 -0
  27. data/examples/01_simple_robot.rb +5 -9
  28. data/examples/02_tools.rb +5 -9
  29. data/examples/03_network.rb +8 -9
  30. data/examples/04_mcp.rb +21 -29
  31. data/examples/05_streaming.rb +12 -18
  32. data/examples/06_prompt_templates.rb +11 -19
  33. data/examples/07_network_memory.rb +16 -31
  34. data/examples/08_llm_config.rb +10 -22
  35. data/examples/09_chaining.rb +16 -27
  36. data/examples/10_memory.rb +12 -28
  37. data/examples/11_network_introspection.rb +15 -29
  38. data/examples/12_message_bus.rb +5 -12
  39. data/examples/13_spawn.rb +5 -10
  40. data/examples/14_rusty_circuit/.envrc +1 -0
  41. data/examples/14_rusty_circuit/comic.rb +2 -0
  42. data/examples/14_rusty_circuit/heckler.rb +1 -1
  43. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  44. data/examples/14_rusty_circuit/scout.rb +2 -0
  45. data/examples/15_memory_network_and_bus/.envrc +1 -0
  46. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  47. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  48. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  49. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  50. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  51. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  52. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  53. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  54. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  55. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  56. data/examples/16_writers_room/.envrc +1 -0
  57. data/examples/16_writers_room/writers_room.rb +2 -4
  58. data/examples/17_skills.rb +8 -17
  59. data/examples/18_rails/Gemfile +1 -0
  60. data/examples/19_token_tracking.rb +9 -15
  61. data/examples/20_circuit_breaker.rb +10 -19
  62. data/examples/21_learning_loop.rb +11 -20
  63. data/examples/22_context_compression.rb +6 -13
  64. data/examples/23_convergence.rb +6 -17
  65. data/examples/24_structured_delegation.rb +11 -15
  66. data/examples/25_history_search.rb +5 -12
  67. data/examples/26_document_store.rb +6 -13
  68. data/examples/27_incident_response/incident_response.rb +4 -5
  69. data/examples/28_mcp_discovery.rb +8 -11
  70. data/examples/29_ractor_tools.rb +4 -9
  71. data/examples/30_ractor_network.rb +10 -19
  72. data/examples/31_launch_assessment.rb +10 -23
  73. data/examples/32_newsletter_reader.rb +188 -0
  74. data/examples/33_stock_generator.rb +80 -0
  75. data/examples/33_stock_predictor.rb +306 -0
  76. data/examples/34_agentskills.rb +72 -0
  77. data/examples/README.md +1 -1
  78. data/examples/common.rb +76 -0
  79. data/examples/ruboruby.md +423 -0
  80. data/examples/temp.md +51 -0
  81. data/lib/robot_lab/agent_skill.rb +63 -0
  82. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  83. data/lib/robot_lab/ask_user.rb +2 -2
  84. data/lib/robot_lab/bus_poller.rb +12 -5
  85. data/lib/robot_lab/config.rb +1 -12
  86. data/lib/robot_lab/delegation_future.rb +1 -1
  87. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  88. data/lib/robot_lab/history_compressor.rb +4 -10
  89. data/lib/robot_lab/mcp/client.rb +1 -2
  90. data/lib/robot_lab/mcp/connection_poller.rb +3 -3
  91. data/lib/robot_lab/mcp/server.rb +1 -1
  92. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  93. data/lib/robot_lab/memory.rb +32 -27
  94. data/lib/robot_lab/memory_change.rb +2 -2
  95. data/lib/robot_lab/message.rb +4 -4
  96. data/lib/robot_lab/network.rb +11 -6
  97. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  98. data/lib/robot_lab/robot/bus_messaging.rb +9 -27
  99. data/lib/robot_lab/robot/history_search.rb +4 -1
  100. data/lib/robot_lab/robot/mcp_management.rb +5 -11
  101. data/lib/robot_lab/robot/template_rendering.rb +60 -40
  102. data/lib/robot_lab/robot.rb +323 -206
  103. data/lib/robot_lab/robot_result.rb +6 -5
  104. data/lib/robot_lab/run_config.rb +5 -11
  105. data/lib/robot_lab/script_tool.rb +76 -0
  106. data/lib/robot_lab/state_proxy.rb +7 -5
  107. data/lib/robot_lab/tool.rb +3 -3
  108. data/lib/robot_lab/tool_config.rb +1 -1
  109. data/lib/robot_lab/tool_manifest.rb +5 -7
  110. data/lib/robot_lab/user_message.rb +2 -2
  111. data/lib/robot_lab/version.rb +1 -1
  112. data/lib/robot_lab/waiter.rb +1 -1
  113. data/lib/robot_lab.rb +41 -52
  114. data/logfile +8 -0
  115. data/mkdocs.yml +2 -3
  116. data/robot_concurrency.md +38 -0
  117. data/simple_acp_review.md +298 -0
  118. data/site/404.html +2300 -0
  119. data/site/api/core/index.html +2706 -0
  120. data/site/api/core/memory/index.html +3793 -0
  121. data/site/api/core/network/index.html +3500 -0
  122. data/site/api/core/robot/index.html +4566 -0
  123. data/site/api/core/state/index.html +3390 -0
  124. data/site/api/core/tool/index.html +3843 -0
  125. data/site/api/index.html +2635 -0
  126. data/site/api/mcp/client/index.html +3435 -0
  127. data/site/api/mcp/index.html +2783 -0
  128. data/site/api/mcp/server/index.html +3252 -0
  129. data/site/api/mcp/transports/index.html +3352 -0
  130. data/site/api/messages/index.html +2641 -0
  131. data/site/api/messages/text-message/index.html +3087 -0
  132. data/site/api/messages/tool-call-message/index.html +3159 -0
  133. data/site/api/messages/tool-result-message/index.html +3252 -0
  134. data/site/api/messages/user-message/index.html +3212 -0
  135. data/site/api/streaming/context/index.html +3282 -0
  136. data/site/api/streaming/events/index.html +3347 -0
  137. data/site/api/streaming/index.html +2738 -0
  138. data/site/architecture/core-concepts/index.html +3757 -0
  139. data/site/architecture/index.html +2797 -0
  140. data/site/architecture/message-flow/index.html +3238 -0
  141. data/site/architecture/network-orchestration/index.html +3433 -0
  142. data/site/architecture/robot-execution/index.html +3140 -0
  143. data/site/architecture/state-management/index.html +3498 -0
  144. data/site/assets/css/custom.css +56 -0
  145. data/site/assets/images/favicon.png +0 -0
  146. data/site/assets/images/robot_lab.jpg +0 -0
  147. data/site/assets/javascripts/bundle.79ae519e.min.js +16 -0
  148. data/site/assets/javascripts/bundle.79ae519e.min.js.map +7 -0
  149. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
  150. data/site/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
  151. data/site/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
  152. data/site/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
  153. data/site/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
  154. data/site/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
  155. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
  156. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
  157. data/site/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
  158. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
  159. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
  160. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
  161. data/site/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
  162. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
  163. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
  164. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
  165. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
  166. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
  167. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
  168. data/site/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
  169. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
  170. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
  171. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
  172. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
  173. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
  174. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
  175. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
  176. data/site/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
  177. data/site/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
  178. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
  179. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
  180. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
  181. data/site/assets/javascripts/lunr/tinyseg.js +206 -0
  182. data/site/assets/javascripts/lunr/wordcut.js +6708 -0
  183. data/site/assets/javascripts/workers/search.2c215733.min.js +42 -0
  184. data/site/assets/javascripts/workers/search.2c215733.min.js.map +7 -0
  185. data/site/assets/stylesheets/main.484c7ddc.min.css +1 -0
  186. data/site/assets/stylesheets/main.484c7ddc.min.css.map +1 -0
  187. data/site/assets/stylesheets/palette.ab4e12ef.min.css +1 -0
  188. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +1 -0
  189. data/site/concepts/index.html +3455 -0
  190. data/site/examples/basic-chat/index.html +2880 -0
  191. data/site/examples/index.html +2907 -0
  192. data/site/examples/mcp-server/index.html +3018 -0
  193. data/site/examples/multi-robot-network/index.html +3131 -0
  194. data/site/examples/rails-application/index.html +3329 -0
  195. data/site/examples/tool-usage/index.html +3085 -0
  196. data/site/getting-started/configuration/index.html +3745 -0
  197. data/site/getting-started/index.html +2572 -0
  198. data/site/getting-started/installation/index.html +2981 -0
  199. data/site/getting-started/quick-start/index.html +2942 -0
  200. data/site/guides/building-robots/index.html +4290 -0
  201. data/site/guides/creating-networks/index.html +3858 -0
  202. data/site/guides/index.html +2586 -0
  203. data/site/guides/mcp-integration/index.html +3581 -0
  204. data/site/guides/memory/index.html +3586 -0
  205. data/site/guides/rails-integration/index.html +4019 -0
  206. data/site/guides/streaming/index.html +3157 -0
  207. data/site/guides/using-tools/index.html +3802 -0
  208. data/site/index.html +2671 -0
  209. data/site/search/search_index.json +1 -0
  210. data/site/sitemap.xml +183 -0
  211. data/site/sitemap.xml.gz +0 -0
  212. data/site/tags.json +1 -0
  213. data/temp.md +6 -0
  214. data/tool_manifest_plan.md +155 -0
  215. metadata +154 -92
  216. data/docs/examples/rails-application.md +0 -419
  217. data/docs/guides/ractor-parallelism.md +0 -364
  218. data/docs/guides/rails-integration.md +0 -681
  219. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  220. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  221. data/lib/generators/robot_lab/install_generator.rb +0 -90
  222. data/lib/generators/robot_lab/job_generator.rb +0 -40
  223. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  224. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  225. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  226. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  227. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  228. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  229. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  230. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  231. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  232. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  233. data/lib/robot_lab/document_store.rb +0 -155
  234. data/lib/robot_lab/ractor_boundary.rb +0 -42
  235. data/lib/robot_lab/ractor_job.rb +0 -37
  236. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  237. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  238. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  239. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  240. data/lib/robot_lab/rails_integration/job.rb +0 -158
  241. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  242. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -65,11 +65,11 @@ module RobotLab
65
65
  private
66
66
 
67
67
  def input_io
68
- robot&.respond_to?(:input) && robot.input ? robot.input : $stdin
68
+ robot.respond_to?(:input) && robot.input ? robot.input : $stdin
69
69
  end
70
70
 
71
71
  def output_io
72
- robot&.respond_to?(:output) && robot.output ? robot.output : $stdout
72
+ robot.respond_to?(:output) && robot.output ? robot.output : $stdout
73
73
  end
74
74
  end
75
75
  end
@@ -115,7 +115,16 @@ module RobotLab
115
115
  # Process one delivery, then drain any queued deliveries for the robot.
116
116
  def process_and_drain(robot, delivery)
117
117
  robot.send(:process_delivery, delivery)
118
+ drain_queued_deliveries(robot)
119
+ rescue BusError => e
120
+ release_robot(robot)
121
+ RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
122
+ rescue => e
123
+ release_robot(robot)
124
+ RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
125
+ end
118
126
 
127
+ def drain_queued_deliveries(robot)
119
128
  loop do
120
129
  next_delivery = @mutex.synchronize do
121
130
  name = robot.name
@@ -138,12 +147,10 @@ module RobotLab
138
147
  RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
139
148
  end
140
149
  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
150
+ end
151
+
152
+ def release_robot(robot)
145
153
  @mutex.synchronize { @robot_busy[robot.name] = false }
146
- RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
147
154
  end
148
155
  end
149
156
  end
@@ -43,7 +43,6 @@ module RobotLab
43
43
  @logger ||= default_logger
44
44
  end
45
45
 
46
-
47
46
  # Apply RubyLLM configuration after loading.
48
47
  #
49
48
  # This method should be called after initialization to configure
@@ -56,7 +55,6 @@ module RobotLab
56
55
  apply_prompt_manager!
57
56
  end
58
57
 
59
-
60
58
  # Apply all RubyLLM settings from the ruby_llm configuration section.
61
59
  #
62
60
  # @return [void]
@@ -100,7 +98,6 @@ module RobotLab
100
98
  set_if_present(c, :vertexai_location, :vertexai_location, 'GOOGLE_CLOUD_LOCATION')
101
99
  end
102
100
 
103
-
104
101
  def apply_provider_endpoints(c)
105
102
  c.openai_api_base = ruby_llm.openai_api_base if ruby_llm.openai_api_base
106
103
  c.gemini_api_base = ruby_llm.gemini_api_base if ruby_llm.gemini_api_base
@@ -109,14 +106,12 @@ module RobotLab
109
106
  c.xai_api_base = ruby_llm.xai_api_base if ruby_llm.xai_api_base
110
107
  end
111
108
 
112
-
113
109
  def apply_openai_options(c)
114
110
  c.openai_organization_id = ruby_llm.openai_organization_id if ruby_llm.openai_organization_id
115
111
  c.openai_project_id = ruby_llm.openai_project_id if ruby_llm.openai_project_id
116
112
  c.openai_use_system_role = ruby_llm.openai_use_system_role unless ruby_llm.openai_use_system_role.nil?
117
113
  end
118
114
 
119
-
120
115
  def apply_default_models(c)
121
116
  c.default_model = ruby_llm.default_model if ruby_llm.default_model
122
117
  c.default_embedding_model = ruby_llm.default_embedding_model if ruby_llm.default_embedding_model
@@ -124,7 +119,6 @@ module RobotLab
124
119
  c.default_moderation_model = ruby_llm.default_moderation_model if ruby_llm.default_moderation_model
125
120
  end
126
121
 
127
-
128
122
  def apply_connection_settings(c)
129
123
  c.request_timeout = ruby_llm.request_timeout if ruby_llm.request_timeout
130
124
  c.max_retries = ruby_llm.max_retries if ruby_llm.max_retries
@@ -134,14 +128,12 @@ module RobotLab
134
128
  c.http_proxy = ruby_llm.http_proxy if ruby_llm.http_proxy
135
129
  end
136
130
 
137
-
138
131
  def apply_logging_options(c)
139
132
  c.log_file = ruby_llm.log_file if ruby_llm.log_file
140
133
  c.log_level = ruby_llm.log_level if ruby_llm.log_level
141
134
  c.log_stream_debug = ruby_llm.log_stream_debug unless ruby_llm.log_stream_debug.nil?
142
135
  end
143
136
 
144
-
145
137
  def apply_prompt_manager!
146
138
  path = resolved_template_path
147
139
  return unless path
@@ -151,15 +143,13 @@ module RobotLab
151
143
  end
152
144
  end
153
145
 
154
-
155
146
  # Set a RubyLLM config attribute from config value or standard env var.
156
147
  # Only sets when a non-nil value is found, to avoid overwriting defaults.
157
148
  def set_if_present(c, setter, config_key, env_var)
158
- value = ruby_llm.public_send(config_key) || ENV[env_var]
149
+ value = ruby_llm.public_send(config_key) || ENV.fetch(env_var, nil)
159
150
  c.public_send(:"#{setter}=", value) if value
160
151
  end
161
152
 
162
-
163
153
  def resolved_template_path
164
154
  return template_path if template_path
165
155
 
@@ -170,7 +160,6 @@ module RobotLab
170
160
  end
171
161
  end
172
162
 
173
-
174
163
  def default_logger
175
164
  if defined?(Rails) && Rails.respond_to?(:logger)
176
165
  Rails.logger
@@ -70,7 +70,7 @@ module RobotLab
70
70
  @result
71
71
  end
72
72
  end
73
- alias_method :wait, :value
73
+ alias wait value
74
74
 
75
75
  # @api private — called by Robot#delegate from the worker thread
76
76
  def resolve!(result)
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Detects and interrupts tool-call doom loops inside the LLM agent loop.
5
+ #
6
+ # A doom loop occurs when the LLM repeatedly calls the same tool(s) in a
7
+ # cycle — either identical consecutive calls (A, A, A) or a repeating
8
+ # multi-step pattern (A, B, C, A, B, C, A, B, C).
9
+ #
10
+ # When a doom loop is detected the warning is embedded in the tool result
11
+ # so the LLM sees it in its next context window and can self-correct.
12
+ #
13
+ # @example Basic usage
14
+ # detector = DoomLoopDetector.new(threshold: 3)
15
+ # detector.track("search")
16
+ # detector.track("search")
17
+ # detector.track("search")
18
+ # detector.doom_loop? # => true
19
+ # detector.warning_message
20
+ # # => "Tool 'search' has been called 3 times consecutively..."
21
+ class DoomLoopDetector
22
+ DEFAULT_THRESHOLD = 3
23
+ MAX_PERIOD = 10
24
+
25
+ attr_reader :sequence
26
+
27
+ def initialize(threshold: DEFAULT_THRESHOLD)
28
+ @threshold = threshold
29
+ @sequence = []
30
+ end
31
+
32
+ def track(tool_name)
33
+ @sequence << tool_name.to_s
34
+ end
35
+
36
+ def doom_loop?
37
+ seq = @sequence
38
+ return false if seq.length < @threshold
39
+
40
+ # Consecutive identical calls: A, A, A
41
+ tail = seq.last(@threshold)
42
+ return true if tail.uniq.length == 1
43
+
44
+ # Cyclic multi-step patterns: A,B,C, A,B,C, A,B,C
45
+ max_period = [MAX_PERIOD, seq.length / @threshold].min
46
+ (2..max_period).each do |period|
47
+ window = @threshold * period
48
+ next if seq.length < window
49
+
50
+ chunk = seq.last(window)
51
+ pattern = chunk.first(period)
52
+ return true if chunk == pattern * @threshold
53
+ end
54
+
55
+ false
56
+ end
57
+
58
+ def reset
59
+ @sequence = []
60
+ end
61
+
62
+ def warning_message
63
+ seq = @sequence
64
+ return "" if seq.empty?
65
+
66
+ period = detect_period(seq)
67
+ if period == 1
68
+ tool = seq.last
69
+ "Tool '#{tool}' has been called #{@threshold}+ times consecutively with " \
70
+ "no progress. You appear to be stuck in a loop. Try a fundamentally " \
71
+ "different approach, use a different tool, or ask for clarification."
72
+ else
73
+ pattern = seq.last(period).join(" → ")
74
+ "Tool call pattern [#{pattern}] has repeated #{@threshold}+ times. " \
75
+ "You appear to be stuck in a loop. Try a fundamentally different " \
76
+ "approach, use a different tool, or ask for clarification."
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def detect_period(seq)
83
+ return 1 if seq.last(@threshold).uniq.length == 1
84
+
85
+ max_period = [MAX_PERIOD, seq.length / @threshold].min
86
+ (2..max_period).each do |period|
87
+ window = @threshold * period
88
+ next if seq.length < window
89
+
90
+ chunk = seq.last(window)
91
+ pattern = chunk.first(period)
92
+ return period if chunk == pattern * @threshold
93
+ end
94
+
95
+ 1
96
+ end
97
+ end
98
+ end
@@ -124,13 +124,11 @@ module RobotLab
124
124
 
125
125
  if score >= @keep_threshold
126
126
  :keep
127
- elsif score < @drop_threshold
127
+ elsif score < @drop_threshold || !@summarizer
128
128
  :drop
129
- elsif @summarizer
129
+ else
130
130
  summary = @summarizer.call(text).to_s.strip
131
131
  summary.empty? ? :drop : summary
132
- else
133
- :drop
134
132
  end
135
133
  end
136
134
 
@@ -141,10 +139,7 @@ module RobotLab
141
139
  @messages.each_with_index do |msg, idx|
142
140
  action = actions[idx]
143
141
 
144
- if action.nil?
145
- # Pinned or recent: always include
146
- result << msg
147
- elsif action == :keep
142
+ if action.nil? || action == :keep
148
143
  result << msg
149
144
  elsif action == :drop
150
145
  # Omit entirely
@@ -166,7 +161,7 @@ module RobotLab
166
161
  role = msg.role
167
162
 
168
163
  return true if role == :system
169
- return true if role == :tool || role == :tool_result
164
+ return true if %i[tool tool_result].include?(role)
170
165
 
171
166
  # Assistant tool-call dispatcher: content is nil or blank
172
167
  if role == :assistant
@@ -185,7 +180,6 @@ module RobotLab
185
180
  case content
186
181
  when String then content
187
182
  when Array then content.filter_map { |p| p[:text] || p["text"] }.join(" ")
188
- else nil
189
183
  end
190
184
  end
191
185
 
@@ -141,8 +141,7 @@ module RobotLab
141
141
  #
142
142
  def get_prompt(name, arguments = {})
143
143
  ensure_connected!
144
- response = request(method: "prompts/get", params: { name: name, arguments: arguments })
145
- response
144
+ request(method: "prompts/get", params: { name: name, arguments: arguments })
146
145
  end
147
146
 
148
147
  # Checks if the client is connected to the server.
@@ -145,15 +145,15 @@ module RobotLab
145
145
  loop do
146
146
  ios = @mutex.synchronize { @clients.keys.reject(&:closed?) }
147
147
 
148
- unless ios.empty?
148
+ if ios.empty?
149
+ sleep POLL_INTERVAL
150
+ else
149
151
  begin
150
152
  ready, = IO.select(ios, nil, nil, POLL_INTERVAL)
151
153
  dispatch(ready) if ready
152
154
  rescue Errno::EBADF
153
155
  # A pipe was closed between the reject and IO.select — harmless, loop again
154
156
  end
155
- else
156
- sleep POLL_INTERVAL
157
157
  end
158
158
 
159
159
  break unless @mutex.synchronize { @running }
@@ -99,7 +99,7 @@ module RobotLab
99
99
 
100
100
  seconds = value.to_f
101
101
  # If the caller passed milliseconds (>= 1000), convert to seconds
102
- seconds = seconds / 1000.0 if seconds >= 1000
102
+ seconds /= 1000.0 if seconds >= 1000
103
103
  [seconds, 1].max
104
104
  end
105
105
  end
@@ -76,8 +76,6 @@ module RobotLab
76
76
  from
77
77
  end
78
78
 
79
- private
80
-
81
79
  # @param servers [Array<Hash, MCP::Server>]
82
80
  def self.any_descriptions?(servers)
83
81
  servers.any? { |s| !description_for(s).empty? }
@@ -89,7 +89,8 @@ module RobotLab
89
89
  #
90
90
  # @example Network-owned memory
91
91
  # Memory.new(network_name: "support_pipeline")
92
- def initialize(data: {}, results: [], messages: [], session_id: nil, backend: :auto, enable_cache: true, network_name: nil)
92
+ def initialize(data: {}, results: [], messages: [], session_id: nil, backend: :auto, enable_cache: true,
93
+ network_name: nil)
93
94
  @backend = select_backend(backend)
94
95
  @mutex = Mutex.new
95
96
  @enable_cache = enable_cache
@@ -104,7 +105,7 @@ module RobotLab
104
105
  set_internal(:cache, @enable_cache ? RubyLLM::SemanticCache : nil)
105
106
 
106
107
  # Data proxy for method-style access
107
- @data_proxy = nil
108
+ @data = nil
108
109
 
109
110
  # Reactive infrastructure
110
111
  @subscriptions = Hash.new { |h, k| h[k] = [] }
@@ -149,7 +150,7 @@ module RobotLab
149
150
  # Reserved keys have special handling (no notifications)
150
151
  case key
151
152
  when :data
152
- @data_proxy = nil # Reset proxy
153
+ @data = nil # Reset proxy
153
154
  set_internal(:data, value.is_a?(Hash) ? value.transform_keys(&:to_sym) : value)
154
155
  when :results
155
156
  set_internal(:results, Array(value))
@@ -164,8 +165,6 @@ module RobotLab
164
165
  # Non-reserved keys use reactive set
165
166
  set(key, value)
166
167
  end
167
-
168
- value
169
168
  end
170
169
 
171
170
  # Access runtime data through StateProxy
@@ -173,7 +172,7 @@ module RobotLab
173
172
  # @return [StateProxy] proxy for method-style data access
174
173
  #
175
174
  def data
176
- @data_proxy ||= StateProxy.new(get_internal(:data) || {})
175
+ @data ||= StateProxy.new(get_internal(:data) || {})
177
176
  end
178
177
 
179
178
  # Get copy of results (immutable access)
@@ -207,7 +206,6 @@ module RobotLab
207
206
  #
208
207
  def session_id=(id)
209
208
  set_internal(:session_id, id)
210
- self
211
209
  end
212
210
 
213
211
  # Get the semantic cache module
@@ -450,16 +448,14 @@ module RobotLab
450
448
  # hits.each { |h| puts "#{h[:key]} (#{h[:score].round(3)}): #{h[:text][0..80]}" }
451
449
  #
452
450
  def search_documents(query, limit: 5)
453
- return [] unless @document_store
454
-
455
- @document_store.search(query, limit: limit)
451
+ document_store.search(query, limit: limit)
456
452
  end
457
453
 
458
454
  # Keys of all documents stored in the embedded document store.
459
455
  #
460
456
  # @return [Array<Symbol>]
461
457
  def document_keys
462
- @document_store&.keys || []
458
+ document_store.keys
463
459
  end
464
460
 
465
461
  # Remove a document from the store.
@@ -467,7 +463,7 @@ module RobotLab
467
463
  # @param key [Symbol, String]
468
464
  # @return [self]
469
465
  def delete_document(key)
470
- @document_store&.delete(key)
466
+ document_store.delete(key)
471
467
  self
472
468
  end
473
469
 
@@ -590,7 +586,7 @@ module RobotLab
590
586
  @backend[:session_id] = nil
591
587
  @backend[:cache] = cached # Restore cache instance
592
588
  end
593
- @data_proxy = nil
589
+ @data = nil
594
590
  self
595
591
  end
596
592
 
@@ -641,7 +637,7 @@ module RobotLab
641
637
  results: results.map(&:export),
642
638
  messages: messages.map(&:to_h),
643
639
  session_id: session_id,
644
- custom: keys.each_with_object({}) { |k, h| h[k] = self[k] }
640
+ custom: keys.to_h { |k| [k, self[k]] }
645
641
  }.compact
646
642
  end
647
643
 
@@ -650,8 +646,8 @@ module RobotLab
650
646
  # @param args [Array] arguments passed to to_json
651
647
  # @return [String]
652
648
  #
653
- def to_json(*args)
654
- to_h.to_json(*args)
649
+ def to_json(*)
650
+ to_h.to_json(*)
655
651
  end
656
652
 
657
653
  # Reconstruct memory from hash
@@ -687,7 +683,12 @@ module RobotLab
687
683
  private
688
684
 
689
685
  def document_store
690
- @document_store ||= DocumentStore.new
686
+ unless RobotLab.extension_loaded?(:document_store)
687
+ raise RobotLab::DependencyError,
688
+ "document storage requires the robot_lab-document_store gem. " \
689
+ "Add `gem 'robot_lab-document_store'` to your Gemfile."
690
+ end
691
+ @document_store ||= RobotLab::DocumentStore.new
691
692
  end
692
693
 
693
694
  def create_semantic_cache
@@ -696,11 +697,9 @@ module RobotLab
696
697
 
697
698
  def select_backend(preference)
698
699
  case preference
699
- when :redis
700
- create_redis_backend || create_hash_backend
701
700
  when :hash
702
701
  create_hash_backend
703
- else # :auto
702
+ else # :redis, :auto
704
703
  create_redis_backend || create_hash_backend
705
704
  end
706
705
  end
@@ -722,7 +721,7 @@ module RobotLab
722
721
 
723
722
  # Check if Redis is configured in RobotLab
724
723
  redis_config = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
725
- redis_config || ENV["REDIS_URL"]
724
+ redis_config || ENV.fetch("REDIS_URL", nil)
726
725
  end
727
726
 
728
727
  def get_internal(key)
@@ -877,8 +876,15 @@ module RobotLab
877
876
  end
878
877
  ensure
879
878
  reschedule = @notification_queue_mutex.synchronize do
880
- @drainer_scheduled = false
881
- !@notification_queue.empty?
879
+ if @notification_queue.empty?
880
+ @drainer_scheduled = false
881
+ false
882
+ else
883
+ # Keep the flag set so concurrent writers don't also spawn a drainer.
884
+ # Only we will reschedule — no double-schedule race.
885
+ @drainer_scheduled = true
886
+ true
887
+ end
882
888
  end
883
889
  dispatch_async { drain_notification_queue } if reschedule
884
890
  end
@@ -890,9 +896,9 @@ module RobotLab
890
896
  def pattern_to_regex(pattern)
891
897
  # Convert glob pattern to regex
892
898
  regex_str = pattern
893
- .gsub(".", "\\.")
894
- .gsub("*", ".*")
895
- .gsub("?", ".")
899
+ .gsub(".", "\\.")
900
+ .gsub("*", ".*")
901
+ .gsub("?", ".")
896
902
 
897
903
  Regexp.new("\\A#{regex_str}\\z")
898
904
  end
@@ -917,7 +923,6 @@ module RobotLab
917
923
  def []=(key, value)
918
924
  serialized = value.is_a?(String) ? value : value.to_json
919
925
  @redis.set("#{@namespace}:#{key}", serialized)
920
- value
921
926
  end
922
927
 
923
928
  def key?(key)
@@ -98,8 +98,8 @@ module RobotLab
98
98
  # @param args [Array] arguments passed to to_json
99
99
  # @return [String]
100
100
  #
101
- def to_json(*args)
102
- to_h.to_json(*args)
101
+ def to_json(*)
102
+ to_h.to_json(*)
103
103
  end
104
104
 
105
105
  # Reconstruct from hash.
@@ -79,8 +79,8 @@ module RobotLab
79
79
  #
80
80
  # @param args [Array] arguments passed to to_json
81
81
  # @return [String] JSON representation of the message
82
- def to_json(*args)
83
- to_h.to_json(*args)
82
+ def to_json(*)
83
+ to_h.to_json(*)
84
84
  end
85
85
 
86
86
  # Creates a Message instance from a hash.
@@ -200,8 +200,8 @@ module RobotLab
200
200
  #
201
201
  # @param args [Array] arguments passed to to_json
202
202
  # @return [String] JSON representation
203
- def to_json(*args)
204
- to_h.to_json(*args)
203
+ def to_json(*)
204
+ to_h.to_json(*)
205
205
  end
206
206
 
207
207
  # Creates a ToolMessage from a hash.
@@ -86,7 +86,7 @@ module RobotLab
86
86
  # task :billing, billing_robot, context: { dept: "billing" }, depends_on: :optional
87
87
  # end
88
88
  #
89
- def initialize(name:, concurrency: :auto, memory: nil, config: nil, parallel_mode: :async, &block)
89
+ def initialize(name:, concurrency: :auto, memory: nil, config: nil, parallel_mode: :async, &)
90
90
  @name = name.to_s
91
91
  @robots = {}
92
92
  @tasks = {}
@@ -97,7 +97,7 @@ module RobotLab
97
97
  @broadcast_handlers = []
98
98
  @bus_poller = BusPoller.new.start
99
99
 
100
- instance_eval(&block) if block_given?
100
+ instance_eval(&) if block_given?
101
101
  end
102
102
 
103
103
  # Add a robot as a pipeline task with optional per-task configuration
@@ -123,7 +123,8 @@ module RobotLab
123
123
  # @example Task with dependencies
124
124
  # task :writer, writer_robot, depends_on: [:analyst]
125
125
  #
126
- def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none, poller_group: :default)
126
+ def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none,
127
+ poller_group: :default)
127
128
  task_wrapper = Task.new(
128
129
  name: name,
129
130
  robot: robot,
@@ -158,8 +159,8 @@ module RobotLab
158
159
  # end
159
160
  # task :process, processor, depends_on: :fetch_data
160
161
  #
161
- def parallel(name = nil, depends_on: :none, &block)
162
- @pipeline.parallel(name, depends_on: depends_on, &block)
162
+ def parallel(name = nil, depends_on: :none, &)
163
+ @pipeline.parallel(name, depends_on: depends_on, &)
163
164
  self
164
165
  end
165
166
 
@@ -351,6 +352,11 @@ module RobotLab
351
352
  private
352
353
 
353
354
  def run_with_ractor_scheduler(run_context)
355
+ unless RobotLab.extension_loaded?(:ractor)
356
+ raise RobotLab::DependencyError,
357
+ "parallel_mode: :ractor requires the robot_lab-ractor gem. " \
358
+ "Add `gem 'robot_lab-ractor'` to your Gemfile."
359
+ end
354
360
  message = run_context[:message].to_s
355
361
  dep_graph = @pipeline.step_dependencies # { task_sym => [dep_sym, ...] }
356
362
 
@@ -373,6 +379,5 @@ module RobotLab
373
379
  scheduler.shutdown
374
380
  results
375
381
  end
376
-
377
382
  end
378
383
  end