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
@@ -4,6 +4,7 @@ require_relative 'robot/template_rendering'
4
4
  require_relative 'robot/mcp_management'
5
5
  require_relative 'robot/bus_messaging'
6
6
  require_relative 'robot/history_search'
7
+ require_relative 'robot/agent_skill_matching'
7
8
 
8
9
  module RobotLab
9
10
  # LLM-powered robot built on RubyLLM::Agent
@@ -43,6 +44,7 @@ module RobotLab
43
44
  include Robot::MCPManagement
44
45
  include Robot::BusMessaging
45
46
  include Robot::HistorySearch
47
+ prepend Robot::AgentSkillMatching
46
48
 
47
49
  # @!attribute [r] name
48
50
  # @return [String] the unique identifier for the robot
@@ -69,7 +71,8 @@ module RobotLab
69
71
  attr_reader :name, :description, :template, :system_prompt,
70
72
  :local_tools, :mcp_clients, :mcp_tools, :memory,
71
73
  :bus, :outbox, :config, :skills, :provider,
72
- :total_input_tokens, :total_output_tokens, :learnings
74
+ :total_input_tokens, :total_output_tokens, :learnings,
75
+ :durable_store, :learn_domain
73
76
 
74
77
  # @!attribute [r] mcp_config
75
78
  # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
@@ -77,6 +80,36 @@ module RobotLab
77
80
  # @return [Symbol, Array] build-time tools configuration (raw, unresolved)
78
81
  attr_reader :mcp_config, :tools_config
79
82
 
83
+ # Returns the fully-merged configuration for this robot at runtime.
84
+ #
85
+ # Reflects the result of merging the RunConfig hierarchy (global → network →
86
+ # constructor kwargs → template front matter). Nil fields are omitted.
87
+ #
88
+ # @return [Hash] merged config keyed by field name
89
+ #
90
+ # @example
91
+ # robot.effective_config
92
+ # #=> { model: "claude-sonnet-4-6", temperature: 0.7, max_tokens: 4096 }
93
+ def effective_config
94
+ {
95
+ model: @config.model,
96
+ temperature: @config.temperature,
97
+ top_p: @config.top_p,
98
+ top_k: @config.top_k,
99
+ max_tokens: @config.max_tokens,
100
+ presence_penalty: @config.presence_penalty,
101
+ frequency_penalty: @config.frequency_penalty,
102
+ stop: @config.stop,
103
+ tools: @config.tools,
104
+ mcp: @config.mcp,
105
+ max_tool_rounds: @config.max_tool_rounds,
106
+ doom_loop_threshold: @config.doom_loop_threshold,
107
+ auto_compact: @config.auto_compact,
108
+ compact_threshold: @config.compact_threshold,
109
+ token_budget: @config.token_budget
110
+ }.compact
111
+ end
112
+
80
113
  # Creates a new Robot instance.
81
114
  #
82
115
  # @param name [String] the unique identifier for the robot
@@ -130,141 +163,52 @@ module RobotLab
130
163
  stop: nil,
131
164
  max_tool_rounds: nil,
132
165
  token_budget: nil,
166
+ doom_loop_threshold: nil,
133
167
  mcp_discovery: false,
134
- config: nil
168
+ config: nil,
169
+ learn: false,
170
+ learn_domain: nil,
171
+ store_path: nil
135
172
  )
136
- @name = name.to_s
137
- @name_from_constructor = (name.to_s != "robot")
138
- @template = template
139
- @system_prompt = system_prompt
140
- @build_context = context
141
- @description = description
142
- @local_tools = Array(local_tools)
143
- @skills = skills ? Array(skills).map(&:to_sym) : nil
144
- @expanded_skills = nil
145
- @mcp_discovery = mcp_discovery
173
+ assign_identity_ivars(name: name, template: template, system_prompt: system_prompt,
174
+ context: context, description: description, local_tools: local_tools,
175
+ skills: skills, mcp_discovery: mcp_discovery)
146
176
 
147
- # Build RunConfig from explicit kwargs, merged on top of passed-in config.
148
- # Explicit constructor kwargs always override the shared config.
149
- explicit_fields = {
177
+ build_effective_config(
150
178
  model: model, temperature: temperature, top_p: top_p, top_k: top_k,
151
179
  max_tokens: max_tokens, presence_penalty: presence_penalty,
152
180
  frequency_penalty: frequency_penalty, stop: stop,
153
181
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
154
182
  on_content: on_content, bus: bus, enable_cache: enable_cache,
155
- max_tool_rounds: max_tool_rounds, token_budget: token_budget
156
- }.compact
157
-
158
- # Only include mcp/tools if explicitly set (not the default :none sentinel)
159
- resolved_mcp = mcp_servers.any? ? mcp_servers : mcp
160
- explicit_fields[:mcp] = resolved_mcp unless ToolConfig.none_value?(resolved_mcp)
161
- explicit_fields[:tools] = tools unless ToolConfig.none_value?(tools)
162
-
163
- explicit_config = RunConfig.new(**explicit_fields)
164
- @config = config ? config.merge(explicit_config) : explicit_config
165
-
166
- # Extract values from effective config for backward compatibility
167
- @on_tool_call = @config.on_tool_call
168
- @on_tool_result = @config.on_tool_result
169
- @on_content = @config.on_content
170
-
171
- # Store raw config values for hierarchical resolution
172
- @mcp_config = @config.mcp || :none
173
- @tools_config = @config.tools || :none
174
-
175
- # MCP state
176
- @mcp_clients = {}
177
- @mcp_tools = []
178
- @mcp_initialized = false
179
-
180
- # Bus state (optional inter-robot communication)
181
- @bus = @config.bus
182
- @message_counter = 0
183
- @outbox = {}
184
- @message_handler = nil
185
- @bus_poller = nil
186
- @private_bus_poller = nil
187
- @bus_poller_group = :default
188
-
189
- # Token tracking
190
- @total_input_tokens = 0
191
- @total_output_tokens = 0
192
-
193
- # Learning accumulation
194
- @learnings = []
195
-
196
- # Inherent memory (used when standalone, not in a network)
197
- cache_enabled = @config.key?(:enable_cache) ? @config.enable_cache : true
198
- @memory = Memory.new(enable_cache: cache_enabled)
199
-
200
- # Restore persisted learnings from inherent memory if present
201
- persisted = @memory.get(:learnings)
202
- @learnings = Array(persisted) if persisted
183
+ max_tool_rounds: max_tool_rounds, token_budget: token_budget,
184
+ doom_loop_threshold: doom_loop_threshold, mcp_servers: mcp_servers,
185
+ mcp: mcp, tools: tools, config: config
186
+ )
203
187
 
204
- # Ensure config is loaded (triggers PM setup, RubyLLM config, etc.)
205
- config = RobotLab.config
188
+ extract_config_ivars
189
+ initialize_runtime_state
190
+ initialize_memory
191
+ configure_learning(learn: learn, learn_domain: learn_domain, store_path: store_path)
206
192
 
207
- # Build chat kwargs for Agent's super
208
- resolved_model = @config.model || config.ruby_llm.model
209
- chat_kwargs = { model: resolved_model }
193
+ lab_config = RobotLab.config
194
+ resolved_model = @config.model || lab_config.ruby_llm.model
195
+ chat_kwargs = { model: resolved_model }
210
196
 
211
- # Pass provider through for local providers (Ollama, GPUStack, etc.)
212
- # RubyLLM auto-sets assume_model_exists for local providers when
213
- # provider is specified.
197
+ # RubyLLM auto-sets assume_model_exists for local providers when provider is specified.
214
198
  @provider = provider
215
199
  if @provider
216
200
  chat_kwargs[:provider] = @provider
217
201
  chat_kwargs[:assume_model_exists] = true
218
202
  end
219
203
 
220
- # Create the persistent chat via Agent's initialize
221
204
  super(chat: nil, **chat_kwargs)
222
205
 
223
- # Dynamically delegate all with_* methods from the Chat class
224
- define_chat_delegators
225
-
226
- # Gather all skill IDs from constructor + main template front matter
227
- all_skill_ids = Array(@skills)
228
- if @template
229
- parsed_main = PM.parse(@template)
230
- fm_skills = extract_skills_from_metadata(parsed_main.metadata)
231
- all_skill_ids = all_skill_ids + fm_skills
232
- end
233
-
234
- if all_skill_ids.any?
235
- # Skills path: expand skills, merge config, concatenate bodies
236
- apply_skills_and_template_to_chat(all_skill_ids, context)
237
- elsif @template
238
- # Standard path: single template, no skills
239
- apply_template_to_chat(context)
240
- end
241
-
242
- @chat.with_instructions(@system_prompt) if @system_prompt
243
-
244
- # Constructor params override template front matter (use config values)
245
- @chat.with_temperature(@config.temperature) if @config.temperature
246
-
247
- # These parameters don't have dedicated with_* methods on Chat;
248
- # pass them through with_params.
249
- extra_params = {
250
- top_p: @config.top_p,
251
- top_k: @config.top_k,
252
- max_tokens: @config.max_tokens,
253
- presence_penalty: @config.presence_penalty,
254
- frequency_penalty: @config.frequency_penalty,
255
- stop: @config.stop
256
- }.compact
257
- @chat.with_params(**extra_params) if extra_params.any?
258
-
259
- # Apply callbacks
260
- @chat.on_tool_call(&@on_tool_call) if @on_tool_call
261
- @chat.on_tool_result(&@on_tool_result) if @on_tool_result
262
-
263
- # Set up bus channel if a bus was provided
264
- setup_bus_channel if @bus
206
+ apply_template
207
+ apply_system_prompt
208
+ apply_chat_params
209
+ register_chat_callbacks
265
210
  end
266
211
 
267
-
268
212
  # Returns the model identifier
269
213
  #
270
214
  # @return [String, nil] the LLM model ID string
@@ -275,20 +219,6 @@ module RobotLab
275
219
  m.respond_to?(:id) ? m.id : m.to_s
276
220
  end
277
221
 
278
- # Dynamically delegate all with_* methods from @chat, returning self for chaining.
279
- # Discovered from the actual Chat class to avoid maintenance sync issues.
280
- private def define_chat_delegators
281
- @chat.class.public_instance_methods(false)
282
- .select { |m| m.start_with?('with_') }
283
- .each do |method_name|
284
- define_singleton_method(method_name) do |*args, **kwargs, &block|
285
- @chat.public_send(method_name, *args, **kwargs, &block)
286
- self
287
- end
288
- end
289
- end
290
-
291
-
292
222
  # Send a message and get a response, with Robot's extended capabilities
293
223
  #
294
224
  # @param message [String] the user message
@@ -301,74 +231,27 @@ module RobotLab
301
231
  # @return [RobotResult]
302
232
  def run(message = nil, network: nil, network_memory: nil, network_config: nil,
303
233
  memory: nil, mcp: :none, tools: :none, **kwargs, &block)
304
- # Determine which memory to use
305
- run_memory = resolve_active_memory(network: network, network_memory: network_memory)
306
-
307
- # Merge runtime memory if provided
308
- case memory
309
- when Memory
310
- run_memory = memory
311
- when Hash
312
- run_memory.merge!(memory)
313
- end
314
-
315
- # Set current_writer so memory notifications know who wrote the value
234
+ run_memory = resolve_run_memory(memory, network: network, network_memory: network_memory)
316
235
  previous_writer = run_memory.current_writer
317
236
  run_memory.current_writer = @name
318
237
 
319
238
  begin
320
- # Resolve hierarchical MCP and tools configuration
321
- resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
322
- resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
323
-
324
- # Filter MCP servers by semantic relevance when discovery is enabled.
325
- # Only applies on the first run (before @mcp_initialized) so connections
326
- # are not torn down mid-conversation.
327
- if @mcp_discovery && !@mcp_initialized && resolved_mcp.is_a?(Array)
328
- resolved_mcp = MCP::ServerDiscovery.select(message.to_s, from: resolved_mcp)
329
- end
330
-
331
- # Initialize or update MCP clients based on resolved config
332
- ensure_mcp_clients(resolved_mcp)
333
-
334
- # Apply filtered tools to the persistent chat
335
- filtered = filtered_tools(resolved_tools)
336
- @chat.with_tools(*filtered) if filtered.any?
337
-
338
- # Re-render template with run-time context merged into build-time context.
339
- # Template parameters (e.g. customer: null) may require values that are
340
- # only available at run time — the robot gathers them before rendering.
341
239
  run_context = kwargs.except(:with)
240
+ prepare_tools(message: message, mcp: mcp, tools: tools,
241
+ network: network, network_config: network_config)
342
242
  rerender_template(run_context) if @template && run_context.any?
343
-
344
- # Prepend accumulated learnings to the user message
345
- effective_message = inject_learnings(message)
346
-
347
- # Install circuit breaker for this run if max_tool_rounds is configured
348
- install_circuit_breaker if @config.max_tool_rounds
349
-
350
- # Delegate to Agent's ask (which calls @chat.ask)
351
- ask_kwargs = kwargs.slice(:with)
352
- streaming = effective_streaming_block(block)
353
- response = ask(effective_message, **ask_kwargs, &streaming)
354
-
243
+ response = invoke_ask(message: message, kwargs: kwargs, block: block)
355
244
  result = build_result(response, run_memory)
356
-
357
- # Enforce token budget if configured
358
- budget = @config.token_budget
359
- if budget && @total_input_tokens + @total_output_tokens > budget
360
- raise InferenceError,
361
- "Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
362
- end
363
-
245
+ enforce_token_budget!
364
246
  result
365
247
  ensure
248
+ remove_doom_loop_detection
366
249
  restore_tool_call_callback if @config.max_tool_rounds
250
+ run_reflector if @durable_store
367
251
  run_memory.current_writer = previous_writer
368
252
  end
369
253
  end
370
254
 
371
-
372
255
  # Reconfigure the robot for a new context
373
256
  #
374
257
  # @param template [Symbol, nil] new template to apply
@@ -396,7 +279,6 @@ module RobotLab
396
279
  self
397
280
  end
398
281
 
399
-
400
282
  # SimpleFlow step interface
401
283
  #
402
284
  # @param result [SimpleFlow::Result] incoming result from previous step
@@ -429,7 +311,6 @@ module RobotLab
429
311
  .continue(error_result)
430
312
  end
431
313
 
432
-
433
314
  # Reset the robot's inherent memory
434
315
  #
435
316
  # @return [self]
@@ -438,7 +319,6 @@ module RobotLab
438
319
  self
439
320
  end
440
321
 
441
-
442
322
  # Eagerly connect to configured MCP servers and discover tools.
443
323
  # Normally MCP connections are lazy (established on first run).
444
324
  # Call this to connect early, e.g. to display connection status at startup.
@@ -468,7 +348,6 @@ module RobotLab
468
348
  self
469
349
  end
470
350
 
471
-
472
351
  # --- Public APIs for external MCP and history management (A4) ---
473
352
 
474
353
  # Inject pre-connected MCP clients and their tools into this robot.
@@ -508,10 +387,10 @@ module RobotLab
508
387
  def clear_messages(keep_system: true)
509
388
  if keep_system
510
389
  system_msg = @chat.messages.find { |m| m.role == :system }
511
- @chat.instance_variable_set(:@messages, [])
390
+ @chat.reset_messages!
512
391
  @chat.add_message(system_msg) if system_msg
513
392
  else
514
- @chat.instance_variable_set(:@messages, [])
393
+ @chat.reset_messages!
515
394
  end
516
395
  self
517
396
  end
@@ -521,7 +400,8 @@ module RobotLab
521
400
  # @param messages [Array<RubyLLM::Message>] the messages to restore
522
401
  # @return [self]
523
402
  def replace_messages(messages)
524
- @chat.instance_variable_set(:@messages, messages)
403
+ @chat.reset_messages!
404
+ messages.each { |m| @chat.add_message(m) }
525
405
  self
526
406
  end
527
407
 
@@ -583,14 +463,14 @@ module RobotLab
583
463
  # @param kwargs [Hash] additional keyword args forwarded to Robot#run
584
464
  # @return [RobotResult] when async: false
585
465
  # @return [DelegationFuture] when async: true
586
- def delegate(to:, task:, async: false, **kwargs)
466
+ def delegate(to:, task:, async: false, **)
587
467
  if async
588
468
  future = DelegationFuture.new(robot_name: to.name, delegated_by: @name)
589
469
  delegator_name = @name
590
470
 
591
471
  Thread.new do
592
472
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
593
- result = to.run(task, **kwargs)
473
+ result = to.run(task, **)
594
474
  result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
595
475
  result.delegated_by = delegator_name
596
476
  future.resolve!(result)
@@ -601,7 +481,7 @@ module RobotLab
601
481
  future
602
482
  else
603
483
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
604
- result = to.run(task, **kwargs)
484
+ result = to.run(task, **)
605
485
  result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
606
486
  result.delegated_by = @name
607
487
  result
@@ -628,7 +508,6 @@ module RobotLab
628
508
  @mcp_clients[server_name]
629
509
  end
630
510
 
631
-
632
511
  # Add a learning to this robot's accumulation store.
633
512
  #
634
513
  # Deduplicates by bidirectional substring matching: a new learning is
@@ -656,7 +535,6 @@ module RobotLab
656
535
  self
657
536
  end
658
537
 
659
-
660
538
  # Reset cumulative token counters to zero.
661
539
  #
662
540
  # @return [self]
@@ -666,7 +544,6 @@ module RobotLab
666
544
  self
667
545
  end
668
546
 
669
-
670
547
  # Converts the robot to a hash representation
671
548
  #
672
549
  # @return [Hash]
@@ -690,15 +567,189 @@ module RobotLab
690
567
 
691
568
  private
692
569
 
693
- # Determine which memory to use
570
+ def assign_identity_ivars(name:, template:, system_prompt:, context:, description:,
571
+ local_tools:, skills:, mcp_discovery:)
572
+ @name = name.to_s
573
+ @name_from_constructor = (name.to_s != "robot")
574
+ @template = template
575
+ @system_prompt = system_prompt
576
+ @build_context = context
577
+ @description = description
578
+ @local_tools = Array(local_tools)
579
+ @skills = skills ? Array(skills).map(&:to_sym) : nil
580
+ @expanded_skills = nil
581
+ @pending_agent_skills = []
582
+ @agent_skill_store = nil
583
+ @mcp_discovery = mcp_discovery
584
+ end
585
+
586
+ # Build RunConfig from explicit kwargs, merged on top of any passed-in config.
587
+ # Explicit constructor kwargs always win.
588
+ def build_effective_config(model:, temperature:, top_p:, top_k:, max_tokens:,
589
+ presence_penalty:, frequency_penalty:, stop:,
590
+ on_tool_call:, on_tool_result:, on_content:,
591
+ bus:, enable_cache:, max_tool_rounds:, token_budget:,
592
+ doom_loop_threshold:, mcp_servers:, mcp:, tools:, config:)
593
+ explicit_fields = {
594
+ model: model, temperature: temperature, top_p: top_p, top_k: top_k,
595
+ max_tokens: max_tokens, presence_penalty: presence_penalty,
596
+ frequency_penalty: frequency_penalty, stop: stop,
597
+ on_tool_call: on_tool_call, on_tool_result: on_tool_result,
598
+ on_content: on_content, bus: bus, enable_cache: enable_cache,
599
+ max_tool_rounds: max_tool_rounds, token_budget: token_budget,
600
+ doom_loop_threshold: doom_loop_threshold
601
+ }.compact
602
+
603
+ resolved_mcp = mcp_servers.any? ? mcp_servers : mcp
604
+ explicit_fields[:mcp] = resolved_mcp unless ToolConfig.none_value?(resolved_mcp)
605
+ explicit_fields[:tools] = tools unless ToolConfig.none_value?(tools)
606
+
607
+ explicit_config = RunConfig.new(**explicit_fields)
608
+ @config = config ? config.merge(explicit_config) : explicit_config
609
+ end
610
+
611
+ def extract_config_ivars
612
+ @on_tool_call = @config.on_tool_call
613
+ @on_tool_result = @config.on_tool_result
614
+ @on_content = @config.on_content
615
+ @mcp_config = @config.mcp || :none
616
+ @tools_config = @config.tools || :none
617
+ end
618
+
619
+ def initialize_runtime_state
620
+ @mcp_clients = {}
621
+ @mcp_tools = []
622
+ @mcp_initialized = false
623
+ @bus = @config.bus
624
+ @message_counter = 0
625
+ @outbox = {}
626
+ @message_handler = ->(_msg) {}
627
+ @bus_poller = nil
628
+ @private_bus_poller = nil
629
+ @bus_poller_group = :default
630
+ @total_input_tokens = 0
631
+ @total_output_tokens = 0
632
+ @learnings = []
633
+ end
634
+
635
+ def initialize_memory
636
+ cache_enabled = @config.key?(:enable_cache) ? @config.enable_cache : true
637
+ @memory = Memory.new(enable_cache: cache_enabled)
638
+ persisted = @memory.get(:learnings)
639
+ @learnings = Array(persisted) if persisted
640
+ end
641
+
642
+ def configure_learning(learn:, learn_domain:, store_path:)
643
+ return unless learn && RobotLab.extension_loaded?(:durable)
644
+
645
+ if learn_domain
646
+ setup_durable_learning(domain: learn_domain, store_path: store_path)
647
+ else
648
+ warn "[RobotLab] Robot '#{@name}': learn: true requires learn_domain: to be set. Durable learning disabled."
649
+ end
650
+ end
651
+
652
+ def apply_template
653
+ define_chat_delegators
654
+
655
+ all_skill_ids = Array(@skills)
656
+ if @template
657
+ parsed_main = PM.parse(@template)
658
+ fm_skills = extract_skills_from_metadata(parsed_main.metadata)
659
+ all_skill_ids += fm_skills
660
+ end
661
+
662
+ if all_skill_ids.any?
663
+ apply_skills_and_template_to_chat(all_skill_ids, @build_context)
664
+ elsif @template
665
+ apply_template_to_chat(@build_context)
666
+ end
667
+ end
668
+
669
+ def apply_system_prompt
670
+ @chat.with_instructions(@system_prompt) if @system_prompt
671
+ end
672
+
673
+ def apply_chat_params
674
+ @chat.with_temperature(@config.temperature) if @config.temperature
675
+
676
+ extra_params = {
677
+ top_p: @config.top_p, top_k: @config.top_k,
678
+ max_tokens: @config.max_tokens,
679
+ presence_penalty: @config.presence_penalty,
680
+ frequency_penalty: @config.frequency_penalty,
681
+ stop: @config.stop
682
+ }.compact
683
+ @chat.with_params(**extra_params) if extra_params.any?
684
+ end
685
+
686
+ def register_chat_callbacks
687
+ @chat.on_tool_call(&@on_tool_call) if @on_tool_call
688
+ @chat.on_tool_result(&@on_tool_result) if @on_tool_result
689
+ setup_bus_channel if @bus
690
+ end
691
+
692
+ # Dynamically delegate all with_* methods from @chat, returning self for chaining.
693
+ # Discovered from the actual Chat class to avoid maintenance sync issues.
694
+ def define_chat_delegators
695
+ @chat.class.public_instance_methods(false)
696
+ .select { |m| m.start_with?('with_') }
697
+ .each do |method_name|
698
+ define_singleton_method(method_name) do |*args, **kwargs, &block|
699
+ @chat.public_send(method_name, *args, **kwargs, &block)
700
+ self
701
+ end
702
+ end
703
+ end
704
+
694
705
  def resolve_active_memory(network: nil, network_memory: nil)
695
706
  network_memory || network&.memory || @memory
696
707
  end
697
708
 
709
+ def resolve_run_memory(memory, network:, network_memory:)
710
+ run_memory = resolve_active_memory(network: network, network_memory: network_memory)
711
+ case memory
712
+ when Memory then memory
713
+ when Hash then run_memory.tap { |m| m.merge!(memory) }
714
+ else run_memory
715
+ end
716
+ end
717
+
718
+ def prepare_tools(message:, mcp:, tools:, network:, network_config:)
719
+ resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
720
+ resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
721
+
722
+ if @mcp_discovery && !@mcp_initialized && resolved_mcp.is_a?(Array)
723
+ resolved_mcp = MCP::ServerDiscovery.select(message.to_s, from: resolved_mcp)
724
+ end
725
+
726
+ ensure_mcp_clients(resolved_mcp)
727
+
728
+ filtered = filtered_tools(resolved_tools)
729
+ @chat.with_tools(*filtered) if filtered.any?
730
+ end
731
+
732
+ def invoke_ask(message:, kwargs:, block:)
733
+ effective_message = inject_learnings(message)
734
+ maybe_compact
735
+ install_circuit_breaker if @config.max_tool_rounds
736
+ install_doom_loop_detection
737
+ ask_kwargs = kwargs.slice(:with)
738
+ streaming = effective_streaming_block(block)
739
+ ask(effective_message, **ask_kwargs, &streaming)
740
+ end
741
+
742
+ def enforce_token_budget!
743
+ budget = @config.token_budget
744
+ return unless budget && @total_input_tokens + @total_output_tokens > budget
745
+
746
+ raise InferenceError,
747
+ "Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
748
+ end
698
749
 
699
750
  # Extract run context from SimpleFlow::Result
700
751
  def extract_run_context(result)
701
- run_params = result.context[:run_params] || {}
752
+ run_params = (result.context[:run_params] || {}).dup
702
753
 
703
754
  # Extract robot-specific params
704
755
  mcp = run_params.delete(:mcp) || :none
@@ -732,7 +783,6 @@ module RobotLab
732
783
  merged
733
784
  end
734
785
 
735
-
736
786
  def build_result(response, _memory)
737
787
  output = if response.respond_to?(:content) && response.content
738
788
  [TextMessage.new(role: 'assistant', content: response.content)]
@@ -767,7 +817,6 @@ module RobotLab
767
817
  )
768
818
  end
769
819
 
770
-
771
820
  def normalize_tool_calls(tool_calls)
772
821
  return [] unless tool_calls
773
822
 
@@ -783,7 +832,6 @@ module RobotLab
783
832
  end
784
833
  end
785
834
 
786
-
787
835
  # Merge the stored on_content callback with a runtime streaming block.
788
836
  # If both exist, both fire (stored first, then runtime block).
789
837
  #
@@ -794,15 +842,16 @@ module RobotLab
794
842
  return runtime_block unless @on_content
795
843
 
796
844
  stored = @on_content
797
- proc { |chunk| stored.call(chunk); runtime_block.call(chunk) }
845
+ proc { |chunk|
846
+ stored.call(chunk)
847
+ runtime_block.call(chunk)
848
+ }
798
849
  end
799
850
 
800
-
801
851
  def all_tools
802
852
  @local_tools + @mcp_tools
803
853
  end
804
854
 
805
-
806
855
  def filtered_tools(allowed_names)
807
856
  available = all_tools
808
857
  return available if allowed_names.empty?
@@ -810,7 +859,6 @@ module RobotLab
810
859
  ToolConfig.filter_tools(available, allowed_names: allowed_names)
811
860
  end
812
861
 
813
-
814
862
  # Prepend accumulated learnings to a user message when learnings exist.
815
863
  def inject_learnings(message)
816
864
  return message if @learnings.empty? || message.nil?
@@ -821,6 +869,76 @@ module RobotLab
821
869
  "#{learning_block}#{message}"
822
870
  end
823
871
 
872
+ # Install per-run doom loop detection on @chat's execute_tool.
873
+ # Tracks tool call names; when a consecutive or cyclic repetition exceeds
874
+ # the threshold, embeds a self-correction warning in the tool result so the
875
+ # LLM can change strategy without requiring an external circuit breaker.
876
+ def install_doom_loop_detection
877
+ threshold = @config.doom_loop_threshold || DoomLoopDetector::DEFAULT_THRESHOLD
878
+ detector = DoomLoopDetector.new(threshold: threshold)
879
+
880
+ @chat.define_singleton_method(:execute_tool) do |tool_call|
881
+ result = super(tool_call)
882
+ detector.track(tool_call.name)
883
+
884
+ if detector.doom_loop?
885
+ warning = detector.warning_message
886
+ detector.reset
887
+ case result
888
+ when Hash then result.merge(_doom_loop_warning: warning)
889
+ when String then "#{result}\n\n⚠️ #{warning}"
890
+ else result
891
+ end
892
+ else
893
+ result
894
+ end
895
+ end
896
+ end
897
+
898
+ # Remove the doom loop detection singleton method from @chat.
899
+ def remove_doom_loop_detection
900
+ sc = @chat.singleton_class
901
+ sc.remove_method(:execute_tool) if sc.method_defined?(:execute_tool)
902
+ end
903
+
904
+ # Compact conversation history before an ask() call if auto_compact is set.
905
+ #
906
+ # :none (default) — no-op
907
+ # :context_window — compress when estimated tokens exceed compact_threshold
908
+ # fraction of the model's context window (default 80%)
909
+ # Proc — called with self; application owns the decision and strategy
910
+ def maybe_compact
911
+ return if @chat.messages.empty?
912
+
913
+ compact = @config.auto_compact
914
+ return if compact.nil? || compact == :none
915
+
916
+ case compact
917
+ when :context_window
918
+ compact_if_over_context_window
919
+ when Proc
920
+ compact.call(self)
921
+ end
922
+ end
923
+
924
+ def compact_if_over_context_window
925
+ threshold = (@config.compact_threshold || 0.80).to_f
926
+ estimated_tok = @chat.messages.sum { |m| m.content.to_s.length } / 4
927
+
928
+ window = begin
929
+ RubyLLM.models.find(model)&.context_window || 200_000
930
+ rescue StandardError
931
+ 200_000
932
+ end
933
+
934
+ return if estimated_tok < window * threshold
935
+
936
+ begin
937
+ compress_history
938
+ rescue DependencyError => e
939
+ RobotLab.config.logger.warn("[#{@name}] auto_compact: #{e.message}; skipping compaction")
940
+ end
941
+ end
824
942
 
825
943
  # Install a per-run circuit breaker on the chat's on_tool_call hook.
826
944
  # Raises ToolLoopError if tool calls exceed @config.max_tool_rounds.
@@ -841,7 +959,6 @@ module RobotLab
841
959
  end
842
960
  end
843
961
 
844
-
845
962
  # Restore the original on_tool_call callback after a circuit-breaker run.
846
963
  def restore_tool_call_callback
847
964
  @chat.on_tool_call(&@on_tool_call)