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
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # <%= class_name %> Routing Robot
4
- #
5
- # <%= robot_description %>
6
- #
7
- # This robot classifies requests and activates optional tasks in a Network.
8
- # Use it as the first task in a network with optional downstream tasks:
9
- #
10
- # classifier = <%= class_name %>Robot.build
11
- # billing = BillingRobot.build
12
- # technical = TechnicalRobot.build
13
- #
14
- # network = RobotLab.create_network(name: "support") do
15
- # task :classifier, classifier, depends_on: :none
16
- # task :billing, billing, depends_on: :optional
17
- # task :technical, technical, depends_on: :optional
18
- # end
19
- #
20
- # result = network.run(message: "I was charged twice")
21
- #
22
- class <%= class_name %>Robot < RobotLab::Robot
23
- SYSTEM_PROMPT = <<~PROMPT
24
- You are a routing robot that classifies user requests.
25
-
26
- Analyze the user's request and respond with ONLY the category name.
27
- Valid categories: billing, technical, general
28
- PROMPT
29
-
30
- def self.build(**options)
31
- new(
32
- name: "<%= file_name %>",
33
- description: "<%= robot_description %>",
34
- system_prompt: SYSTEM_PROMPT,
35
- **options
36
- )
37
- end
38
-
39
- # Override call to inspect classification output and activate optional tasks.
40
- def call(result)
41
- context = extract_run_context(result)
42
- message = context.delete(:message)
43
-
44
- robot_result = run(message, **context)
45
-
46
- new_result = result
47
- .with_context(@name.to_sym, robot_result)
48
- .continue(robot_result)
49
-
50
- category = robot_result.last_text_content.to_s.strip.downcase
51
-
52
- # Route based on classification — customize these patterns
53
- case category
54
- when /billing/ then new_result.activate(:billing)
55
- when /technical/ then new_result.activate(:technical)
56
- else new_result.activate(:general)
57
- end
58
- end
59
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # RobotLab Thread Model
4
- #
5
- # Stores conversation threads for history persistence.
6
- #
7
- class RobotLabThread < ApplicationRecord
8
- has_many :results,
9
- class_name: "RobotLabResult",
10
- foreign_key: :session_id,
11
- primary_key: :session_id,
12
- dependent: :destroy
13
-
14
- validates :session_id, presence: true, uniqueness: true
15
-
16
- # Find or create a thread by ID
17
- #
18
- # @param id [String] Thread ID
19
- # @return [RobotLabThread]
20
- #
21
- def self.find_or_create_by_session_id(id)
22
- find_or_create_by(session_id: id)
23
- end
24
-
25
- # Get the last result for this thread
26
- #
27
- # @return [RobotLabResult, nil]
28
- #
29
- def last_result
30
- results.order(sequence_number: :desc).first
31
- end
32
-
33
- # Get message count for this thread
34
- #
35
- # @return [Integer]
36
- #
37
- def message_count
38
- results.count
39
- end
40
- end
@@ -1,155 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fastembed"
4
-
5
- module RobotLab
6
- # Embedding-based document store for semantic search over arbitrary text.
7
- #
8
- # Documents are embedded using {https://github.com/khasinski/fastembed-rb fastembed}
9
- # (BAAI/bge-small-en-v1.5 by default) and stored in memory. Queries are
10
- # embedded the same way, then compared by cosine similarity to find the
11
- # closest documents.
12
- #
13
- # The embedding model is initialised lazily on first use — the ONNX model
14
- # file is downloaded on that first call (cached locally afterwards).
15
- #
16
- # @example
17
- # store = RobotLab::DocumentStore.new
18
- # store.store(:q4_report, "Q4 revenue came in at $4.2M, up 18% YoY…")
19
- # store.store(:q3_report, "Q3 showed 15% growth, driven by APAC…")
20
- #
21
- # results = store.search("revenue growth", limit: 2)
22
- # results.each { |r| puts "#{r[:key]} (#{r[:score].round(3)}): #{r[:text][0..60]}" }
23
- #
24
- # @example Via Memory
25
- # memory.store_document(:readme, File.read("README.md"))
26
- # memory.search_documents("how to configure redis", limit: 3)
27
- #
28
- class DocumentStore
29
- # Default embedding model used when none is specified.
30
- DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
31
-
32
- # @param model_name [String] fastembed model name (default: BAAI/bge-small-en-v1.5)
33
- def initialize(model_name: DEFAULT_MODEL)
34
- @model_name = model_name
35
- @documents = {} # key (Symbol) => { text: String, vector: Array<Float> }
36
- @mutex = Mutex.new
37
- @model = nil # lazy: initialised on first embed call
38
- end
39
-
40
- # Embed +text+ and store it under +key+.
41
- #
42
- # If a document already exists under +key+ it is replaced.
43
- #
44
- # @param key [Symbol, String] identifier for this document
45
- # @param text [String] the document text to embed and store
46
- # @return [self]
47
- def store(key, text)
48
- key = key.to_sym
49
- vector = passage_vector(text)
50
- @mutex.synchronize { @documents[key] = { text: text, vector: vector } }
51
- self
52
- end
53
-
54
- # Search for documents semantically similar to +query+.
55
- #
56
- # @param query [String] natural-language search query
57
- # @param limit [Integer] maximum number of results (default 5)
58
- # @return [Array<Hash>] results sorted by score descending.
59
- # Each hash contains +:key+, +:text+, and +:score+ (Float 0.0..1.0).
60
- def search(query, limit: 5)
61
- return [] if empty?
62
-
63
- query_vec = query_vector(query)
64
- results = []
65
-
66
- @mutex.synchronize do
67
- @documents.each do |key, doc|
68
- score = cosine_similarity(query_vec, doc[:vector])
69
- results << { key: key, text: doc[:text], score: score }
70
- end
71
- end
72
-
73
- results.sort_by { |r| -r[:score] }.first(limit)
74
- end
75
-
76
- # Number of stored documents.
77
- #
78
- # @return [Integer]
79
- def size
80
- @mutex.synchronize { @documents.size }
81
- end
82
-
83
- # Keys of all stored documents.
84
- #
85
- # @return [Array<Symbol>]
86
- def keys
87
- @mutex.synchronize { @documents.keys }
88
- end
89
-
90
- # Whether the store contains no documents.
91
- #
92
- # @return [Boolean]
93
- def empty?
94
- @mutex.synchronize { @documents.empty? }
95
- end
96
-
97
- # Remove the document stored under +key+.
98
- #
99
- # @param key [Symbol, String]
100
- # @return [self]
101
- def delete(key)
102
- @mutex.synchronize { @documents.delete(key.to_sym) }
103
- self
104
- end
105
-
106
- # Remove all stored documents.
107
- #
108
- # @return [self]
109
- def clear
110
- @mutex.synchronize { @documents.clear }
111
- self
112
- end
113
-
114
- private
115
-
116
- # Return (or lazily create) the fastembed model instance.
117
- def model
118
- @model ||= Fastembed::TextEmbedding.new(model_name: @model_name, show_progress: false)
119
- end
120
-
121
- # Embed a single passage string.
122
- def passage_vector(text)
123
- model.passage_embed([text]).to_a.first
124
- end
125
-
126
- # Embed a single query string.
127
- def query_vector(text)
128
- model.query_embed([text]).to_a.first
129
- end
130
-
131
- # Cosine similarity between two dense float vectors.
132
- #
133
- # Returns 0.0 for nil, empty, or mismatched-length vectors.
134
- def cosine_similarity(vec_a, vec_b)
135
- return 0.0 unless vec_a && vec_b
136
- return 0.0 if vec_a.empty? || vec_b.empty?
137
- return 0.0 if vec_a.length != vec_b.length
138
-
139
- dot = 0.0
140
- norm_a = 0.0
141
- norm_b = 0.0
142
-
143
- vec_a.each_with_index do |a, i|
144
- b = vec_b[i]
145
- dot += a * b
146
- norm_a += a * a
147
- norm_b += b * b
148
- end
149
-
150
- return 0.0 if norm_a.zero? || norm_b.zero?
151
-
152
- dot / (Math.sqrt(norm_a) * Math.sqrt(norm_b))
153
- end
154
- end
155
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- # Utility for making values safe to pass across Ractor boundaries.
5
- #
6
- # Recursively freezes Hash and Array structures. Raises RactorBoundaryError
7
- # if a value cannot be made Ractor-shareable (e.g. a live IO or Proc).
8
- #
9
- # @example
10
- # safe = RactorBoundary.freeze_deep({ model: "sonnet", args: { x: 1 } })
11
- # Ractor.shareable?(safe) #=> true
12
- #
13
- module RactorBoundary
14
- # Recursively freeze an object for safe Ractor boundary crossing.
15
- #
16
- # @param obj [Object] the value to freeze
17
- # @return [Object] a frozen, Ractor-shareable copy
18
- # @raise [RactorBoundaryError] if the value cannot be made shareable
19
- def self.freeze_deep(obj)
20
- return obj if Ractor.shareable?(obj)
21
-
22
- result = case obj
23
- when Hash
24
- obj.transform_keys { |k| freeze_deep(k) }
25
- .transform_values { |v| freeze_deep(v) }
26
- when Array
27
- obj.map { |v| freeze_deep(v) }
28
- else
29
- begin
30
- obj.dup
31
- rescue TypeError
32
- raise RactorBoundaryError,
33
- "Cannot make #{obj.class} Ractor-shareable: dup not supported"
34
- end
35
- end
36
-
37
- Ractor.make_shareable(result)
38
- rescue Ractor::IsolationError, Ractor::Error => e
39
- raise RactorBoundaryError, "Cannot make value Ractor-shareable: #{e.message}"
40
- end
41
- end
42
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- # Carrier for work crossing a Ractor boundary.
5
- #
6
- # All fields must be Ractor-shareable (frozen Data, frozen String,
7
- # frozen Hash, or a RactorQueue). Build with RactorBoundary.freeze_deep
8
- # on the payload before constructing.
9
- #
10
- # @example
11
- # job = RactorJob.new(
12
- # id: SecureRandom.uuid.freeze,
13
- # type: :tool,
14
- # payload: RactorBoundary.freeze_deep({ tool_class: "MyTool", args: { x: 1 } }),
15
- # reply_queue: RactorQueue.new(capacity: 1)
16
- # )
17
- RactorJob = Data.define(:id, :type, :payload, :reply_queue)
18
-
19
- # Frozen error representation for exceptions raised inside a Ractor worker.
20
- # Serialized at the Ractor boundary and re-raised on the thread side.
21
- #
22
- # @example
23
- # err = RactorJobError.new(message: e.message.freeze, backtrace: e.backtrace.freeze)
24
- RactorJobError = Data.define(:message, :backtrace)
25
-
26
- # Carries everything needed to reconstruct a Robot inside a Ractor.
27
- # All fields must be frozen strings, symbols, or hashes.
28
- #
29
- # @example
30
- # spec = RobotSpec.new(
31
- # name: "analyst",
32
- # template: :analyst,
33
- # system_prompt: nil,
34
- # config_hash: { model: "claude-sonnet-4" }.freeze
35
- # )
36
- RobotSpec = Data.define(:name, :template, :system_prompt, :config_hash)
37
- end
@@ -1,85 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ractor/wrapper"
4
-
5
- module RobotLab
6
- # Wraps a Memory instance via Ractor::Wrapper so Ractor workers can safely
7
- # read and write shared state.
8
- #
9
- # Only get, set, and keys are proxied across the Ractor boundary.
10
- # Subscriptions and callbacks are NOT proxied — closures are not
11
- # Ractor-safe. Use the thread-side Memory directly for reactive subscriptions.
12
- #
13
- # Values passed to set() must be Ractor-shareable; RactorBoundary.freeze_deep
14
- # is applied automatically.
15
- #
16
- # The proxy uses use_current_ractor: true so the Memory object stays in the
17
- # calling Ractor and is not moved. This allows direct access alongside the
18
- # proxy and works with Memory's mutex-based internals.
19
- #
20
- # @example
21
- # memory = Memory.new
22
- # proxy = RactorMemoryProxy.new(memory)
23
- #
24
- # # From any Ractor via the stub:
25
- # proxy.set(:result, "done")
26
- # proxy.get(:result) #=> "done"
27
- #
28
- # proxy.shutdown # call when done
29
- #
30
- class RactorMemoryProxy
31
- # @param memory [Memory] the memory instance to wrap
32
- def initialize(memory)
33
- @memory = memory
34
- @wrapper = Ractor::Wrapper.new(memory, use_current_ractor: true)
35
- @stub = @wrapper.stub
36
- end
37
-
38
- # Returns the Ractor-shareable stub for use inside Ractors.
39
- #
40
- # The stub proxies get/set/keys to the wrapped Memory. Pass this to
41
- # Ractor.new rather than the proxy itself (the proxy is not shareable).
42
- #
43
- # @return [Ractor::Wrapper stub]
44
- def stub
45
- @stub
46
- end
47
-
48
- # Read a value from the proxied Memory.
49
- #
50
- # @param key [Symbol]
51
- # @return [Object, nil]
52
- def get(key)
53
- @stub.get(key)
54
- end
55
-
56
- # Write a frozen value to the proxied Memory.
57
- # The value is deep-frozen before crossing the Ractor boundary.
58
- #
59
- # @param key [Symbol]
60
- # @param value [Object] must be Ractor-shareable after freeze_deep
61
- # @return [void]
62
- # @raise [RactorBoundaryError] if value cannot be made shareable
63
- def set(key, value)
64
- frozen_value = RactorBoundary.freeze_deep(value)
65
- @stub.set(key, frozen_value)
66
- end
67
-
68
- # List all keys currently set in the proxied Memory.
69
- #
70
- # @return [Array<Symbol>]
71
- def keys
72
- @stub.keys
73
- end
74
-
75
- # Shut down the ractor-wrapper.
76
- #
77
- # @return [void]
78
- def shutdown
79
- @wrapper.async_stop
80
- @wrapper.join
81
- rescue => e
82
- RobotLab.config.logger.warn("RactorMemoryProxy shutdown error: #{e.message}")
83
- end
84
- end
85
- end
@@ -1,154 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "etc"
4
- require "ractor_queue"
5
-
6
- module RobotLab
7
- # Schedules frozen robot task descriptions across Ractor workers.
8
- #
9
- # Robots stay in threads for LLM calls (ruby_llm is not Ractor-safe).
10
- # The scheduler distributes frozen RobotSpec payloads; each worker
11
- # constructs a fresh Robot, runs the task, and returns a frozen result.
12
- #
13
- # Task ordering respects depends_on: tasks are only dispatched once all
14
- # named dependencies have resolved (same topological semantics as
15
- # SimpleFlow::Pipeline).
16
- #
17
- # @example
18
- # scheduler = RactorNetworkScheduler.new(memory: shared_memory)
19
- # scheduler.run_pipeline([
20
- # { spec: analyst_spec, depends_on: :none },
21
- # { spec: writer_spec, depends_on: ["analyst"] }
22
- # ], message: "Process this")
23
- # scheduler.shutdown
24
- #
25
- class RactorNetworkScheduler
26
- # Capacity for the work queue.
27
- QUEUE_CAPACITY = 256
28
-
29
- # @param memory [Memory] shared network memory for all robot tasks
30
- # @param pool_size [Integer, :auto] number of Ractor workers
31
- def initialize(memory:, pool_size: :auto)
32
- @memory = memory
33
- @work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
34
- @size = pool_size == :auto ? Etc.nprocessors : pool_size.to_i
35
- @workers = @size.times.map { spawn_worker(@work_q) }
36
- @closed = false
37
- end
38
-
39
- # Run a single spec and return the result string.
40
- #
41
- # @param spec [RobotSpec]
42
- # @param message [String]
43
- # @return [String] the robot's last_text_content
44
- def run_spec(spec, message:)
45
- execute_spec(spec, message)
46
- end
47
-
48
- # Run a pipeline of specs in dependency order.
49
- #
50
- # @param specs_with_deps [Array<Hash>] each entry has :spec and :depends_on
51
- # :depends_on is :none, :optional, or an Array<String> of spec names
52
- # @param message [String] initial message passed to entry-point robots
53
- # @return [Hash<String, String>] name => result for each completed robot
54
- def run_pipeline(specs_with_deps, message:)
55
- completed = {} # name => result string
56
- remaining = specs_with_deps.dup
57
-
58
- until remaining.empty?
59
- ready, remaining = remaining.partition do |entry|
60
- deps = entry[:depends_on]
61
- deps == :none || deps == :optional ||
62
- Array(deps).all? { |d| completed.key?(d) }
63
- end
64
-
65
- raise RobotLab::Error, "Circular dependency or unresolvable deps in RactorNetworkScheduler" if ready.empty?
66
-
67
- # Submit all ready tasks concurrently via threads.
68
- # report_on_exception is disabled because exceptions are propagated
69
- # to the caller via t.value — the default reporting is redundant noise.
70
- threads = ready.map do |entry|
71
- spec = entry[:spec]
72
- msg = completed.values.last || message
73
- Thread.new { [spec.name, execute_spec(spec, msg)] }.tap { |t| t.report_on_exception = false }
74
- end
75
-
76
- threads.each do |t|
77
- name, result = t.value
78
- completed[name] = result
79
- end
80
- end
81
-
82
- completed
83
- end
84
-
85
- # Gracefully shut down worker Ractors.
86
- # @return [void]
87
- def shutdown
88
- return if @closed
89
-
90
- @closed = true
91
- @size.times { @work_q.push(nil) }
92
- @workers.each { |w| w.join rescue nil }
93
- end
94
-
95
- private
96
-
97
- # Dispatch a spec to a Ractor worker and block for the result.
98
- def execute_spec(spec, message)
99
- frozen_spec = Ractor.make_shareable(spec)
100
- frozen_message = message.to_s.freeze
101
- reply_q = RactorQueue.new(capacity: 1)
102
-
103
- job = RactorJob.new(
104
- id: SecureRandom.uuid.freeze,
105
- type: :robot,
106
- payload: RactorBoundary.freeze_deep({
107
- spec: frozen_spec,
108
- message: frozen_message
109
- }),
110
- reply_queue: reply_q
111
- )
112
-
113
- @work_q.push(job)
114
- result = reply_q.pop
115
-
116
- if result.is_a?(RactorJobError)
117
- raise RobotLab::Error, "Robot '#{spec.name}' failed in Ractor: #{result.message}"
118
- end
119
-
120
- result
121
- end
122
-
123
- def spawn_worker(work_q)
124
- Ractor.new(work_q) do |q|
125
- loop do
126
- job = q.pop
127
- break if job.nil?
128
-
129
- begin
130
- spec = job.payload[:spec]
131
- message = job.payload[:message]
132
-
133
- robot = RobotLab::Robot.new(
134
- name: spec.name,
135
- template: spec.template ? spec.template.to_sym : nil,
136
- system_prompt: spec.system_prompt,
137
- config: spec.config_hash.empty? ? nil : RobotLab::RunConfig.new(**spec.config_hash.transform_keys(&:to_sym))
138
- )
139
-
140
- robot_result = robot.run(message)
141
- frozen_reply = robot_result.last_text_content.to_s.freeze
142
- job.reply_queue.push(frozen_reply)
143
- rescue => e
144
- err = RobotLab::RactorJobError.new(
145
- message: e.message.freeze,
146
- backtrace: (e.backtrace || []).map(&:freeze).freeze
147
- )
148
- job.reply_queue.push(err)
149
- end
150
- end
151
- end
152
- end
153
- end
154
- end
@@ -1,117 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "etc"
4
- require "ractor_queue"
5
-
6
- module RobotLab
7
- # A pool of Ractor workers that execute CPU-bound, Ractor-safe tools.
8
- #
9
- # Work is distributed via a shared RactorQueue. Each worker runs a
10
- # blocking loop, pops RactorJob instances, dispatches to the named
11
- # tool class, and pushes the frozen result (or a RactorJobError) to
12
- # the job's per-job reply_queue.
13
- #
14
- # Shutdown uses a poison-pill pattern: one nil sentinel per worker is
15
- # pushed to the work queue; each worker exits when it pops nil.
16
- #
17
- # Only tools that declare +ractor_safe true+ should be submitted.
18
- # Tool classes are instantiated fresh inside the Ractor for each call.
19
- #
20
- # @example
21
- # pool = RactorWorkerPool.new(size: 4)
22
- # result = pool.submit("MyTool", { "arg" => "value" })
23
- # pool.shutdown
24
- #
25
- class RactorWorkerPool
26
- # Capacity of the shared work queue.
27
- QUEUE_CAPACITY = 1024
28
-
29
- # @return [Integer] number of worker Ractors
30
- attr_reader :size
31
-
32
- # Creates a new pool and starts worker Ractors immediately.
33
- #
34
- # @param size [Integer, :auto] number of workers (:auto = Etc.nprocessors)
35
- def initialize(size: :auto)
36
- @size = size == :auto ? Etc.nprocessors : size.to_i
37
- @closed = false
38
- @work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
39
- @workers = @size.times.map { spawn_worker(@work_q) }
40
- end
41
-
42
- # Submit a tool job and block until the result is available.
43
- #
44
- # @param tool_class_name [String] fully-qualified Ruby constant name of the tool class
45
- # @param args [Hash] tool arguments (deep-frozen before crossing Ractor boundary)
46
- # @return [Object] the tool's return value
47
- # @raise [RactorBoundaryError] if args cannot be made Ractor-shareable
48
- # @raise [ToolError] if the tool raises inside the Ractor
49
- def submit(tool_class_name, args)
50
- raise ToolError, "Pool is shut down" if @closed
51
-
52
- reply_q = RactorQueue.new(capacity: 1)
53
- payload = RactorBoundary.freeze_deep({
54
- tool_class: tool_class_name.to_s,
55
- args: args
56
- })
57
-
58
- job = RactorJob.new(
59
- id: SecureRandom.uuid.freeze,
60
- type: :tool,
61
- payload: payload,
62
- reply_queue: reply_q
63
- )
64
-
65
- @work_q.push(job)
66
- result = reply_q.pop
67
-
68
- if result.is_a?(RactorJobError)
69
- raise ToolError, "Tool '#{tool_class_name}' failed in Ractor: #{result.message}"
70
- end
71
-
72
- result
73
- end
74
-
75
- # Gracefully shut down the pool.
76
- #
77
- # Pushes one nil poison pill per worker so each exits its loop.
78
- # Waits for all workers to terminate.
79
- #
80
- # @return [void]
81
- def shutdown
82
- return if @closed
83
-
84
- @closed = true
85
- # Push one nil poison pill per worker
86
- @size.times { @work_q.push(nil) }
87
- @workers.each { |w| w.join rescue nil }
88
- end
89
-
90
- private
91
-
92
- def spawn_worker(work_q)
93
- Ractor.new(work_q) do |q|
94
- loop do
95
- job = q.pop
96
-
97
- # nil is the poison pill — exit cleanly
98
- break if job.nil?
99
-
100
- begin
101
- tool_class = Object.const_get(job.payload[:tool_class])
102
- tool = tool_class.new
103
- result = tool.execute(**job.payload[:args].transform_keys(&:to_sym))
104
- frozen_result = Ractor.make_shareable(result.frozen? ? result : result.dup.freeze)
105
- job.reply_queue.push(frozen_result)
106
- rescue => e
107
- err = RobotLab::RactorJobError.new(
108
- message: e.message.freeze,
109
- backtrace: (e.backtrace || []).map(&:freeze).freeze
110
- )
111
- job.reply_queue.push(err)
112
- end
113
- end
114
- end
115
- end
116
- end
117
- end