robot_lab 0.2.1 → 0.2.7

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 (236) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +519 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +299 -93
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
@@ -5,6 +5,8 @@ require_relative 'robot/mcp_management'
5
5
  require_relative 'robot/bus_messaging'
6
6
  require_relative 'robot/history_search'
7
7
  require_relative 'robot/agent_skill_matching'
8
+ require_relative 'robot/budget'
9
+ require_relative 'robot/hooking'
8
10
 
9
11
  module RobotLab
10
12
  # LLM-powered robot built on RubyLLM::Agent
@@ -44,8 +46,16 @@ module RobotLab
44
46
  include Robot::MCPManagement
45
47
  include Robot::BusMessaging
46
48
  include Robot::HistorySearch
49
+ include Robot::Budget
50
+ include Robot::Hooking
51
+ include Runnable
47
52
  prepend Robot::AgentSkillMatching
48
53
 
54
+ # Default ceiling on tools handed to the provider per turn. OpenAI and
55
+ # Anthropic both reject tool arrays longer than 128. Override per robot via
56
+ # RunConfig#max_tools.
57
+ DEFAULT_MAX_TOOLS = 128
58
+
49
59
  # @!attribute [r] name
50
60
  # @return [String] the unique identifier for the robot
51
61
  # @!attribute [r] description
@@ -70,9 +80,9 @@ module RobotLab
70
80
 
71
81
  attr_reader :name, :description, :template, :system_prompt,
72
82
  :local_tools, :mcp_clients, :mcp_tools, :memory,
73
- :bus, :outbox, :config, :skills, :provider,
83
+ :bus, :outbox, :config, :skills, :provider, :hooks,
74
84
  :total_input_tokens, :total_output_tokens, :learnings,
75
- :durable_store, :learn_domain
85
+ :budget_ledger
76
86
 
77
87
  # @!attribute [r] mcp_config
78
88
  # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
@@ -80,6 +90,14 @@ module RobotLab
80
90
  # @return [Symbol, Array] build-time tools configuration (raw, unresolved)
81
91
  attr_reader :mcp_config, :tools_config
82
92
 
93
+ # Runnable protocol: a single robot is a crew of one. (network? defaults to
94
+ # false from RobotLab::Runnable.)
95
+ #
96
+ # @return [Array<Robot>]
97
+ def crew
98
+ [self]
99
+ end
100
+
83
101
  # Returns the fully-merged configuration for this robot at runtime.
84
102
  #
85
103
  # Reflects the result of merging the RunConfig hierarchy (global → network →
@@ -106,7 +124,8 @@ module RobotLab
106
124
  doom_loop_threshold: @config.doom_loop_threshold,
107
125
  auto_compact: @config.auto_compact,
108
126
  compact_threshold: @config.compact_threshold,
109
- token_budget: @config.token_budget
127
+ token_budget: @config.token_budget,
128
+ cost_budget: @config.cost_budget
110
129
  }.compact
111
130
  end
112
131
 
@@ -163,13 +182,13 @@ module RobotLab
163
182
  stop: nil,
164
183
  max_tool_rounds: nil,
165
184
  token_budget: nil,
185
+ cost_budget: nil,
166
186
  doom_loop_threshold: nil,
167
187
  mcp_discovery: false,
168
- config: nil,
169
- learn: false,
170
- learn_domain: nil,
171
- store_path: nil
188
+ config: nil
172
189
  )
190
+ validate_tools_filter!(tools)
191
+
173
192
  assign_identity_ivars(name: name, template: template, system_prompt: system_prompt,
174
193
  context: context, description: description, local_tools: local_tools,
175
194
  skills: skills, mcp_discovery: mcp_discovery)
@@ -180,7 +199,7 @@ module RobotLab
180
199
  frequency_penalty: frequency_penalty, stop: stop,
181
200
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
182
201
  on_content: on_content, bus: bus, enable_cache: enable_cache,
183
- max_tool_rounds: max_tool_rounds, token_budget: token_budget,
202
+ max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
184
203
  doom_loop_threshold: doom_loop_threshold, mcp_servers: mcp_servers,
185
204
  mcp: mcp, tools: tools, config: config
186
205
  )
@@ -188,7 +207,6 @@ module RobotLab
188
207
  extract_config_ivars
189
208
  initialize_runtime_state
190
209
  initialize_memory
191
- configure_learning(learn: learn, learn_domain: learn_domain, store_path: store_path)
192
210
 
193
211
  lab_config = RobotLab.config
194
212
  resolved_model = @config.model || lab_config.ruby_llm.model
@@ -219,39 +237,6 @@ module RobotLab
219
237
  m.respond_to?(:id) ? m.id : m.to_s
220
238
  end
221
239
 
222
- # Send a message and get a response, with Robot's extended capabilities
223
- #
224
- # @param message [String] the user message
225
- # @param network [NetworkRun, nil] network context (legacy)
226
- # @param network_memory [Memory, nil] shared network memory
227
- # @param memory [Memory, Hash, nil] runtime memory to merge
228
- # @param mcp [Symbol, Array, nil] runtime MCP override
229
- # @param tools [Symbol, Array, nil] runtime tools override
230
- # @yield [chunk] optional streaming block called with each content chunk
231
- # @return [RobotResult]
232
- def run(message = nil, network: nil, network_memory: nil, network_config: nil,
233
- memory: nil, mcp: :none, tools: :none, **kwargs, &block)
234
- run_memory = resolve_run_memory(memory, network: network, network_memory: network_memory)
235
- previous_writer = run_memory.current_writer
236
- run_memory.current_writer = @name
237
-
238
- begin
239
- run_context = kwargs.except(:with)
240
- prepare_tools(message: message, mcp: mcp, tools: tools,
241
- network: network, network_config: network_config)
242
- rerender_template(run_context) if @template && run_context.any?
243
- response = invoke_ask(message: message, kwargs: kwargs, block: block)
244
- result = build_result(response, run_memory)
245
- enforce_token_budget!
246
- result
247
- ensure
248
- remove_doom_loop_detection
249
- restore_tool_call_callback if @config.max_tool_rounds
250
- run_reflector if @durable_store
251
- run_memory.current_writer = previous_writer
252
- end
253
- end
254
-
255
240
  # Reconfigure the robot for a new context
256
241
  #
257
242
  # @param template [Symbol, nil] new template to apply
@@ -523,13 +508,19 @@ module RobotLab
523
508
  text = text.to_s.strip
524
509
  return self if text.empty?
525
510
 
526
- # Remove any existing learning that is a substring of the new one
527
- @learnings.reject! { |existing| text.include?(existing) }
511
+ ctx = LearnHookContext.new(robot: self, text: text, learnings_before: @learnings.dup)
512
+ registries = [RobotLab.hooks, @hooks]
528
513
 
529
- # Skip if any existing learning already covers the new one
530
- unless @learnings.any? { |existing| existing.include?(text) }
531
- @learnings << text
532
- @memory.set(:learnings, @learnings.dup)
514
+ RobotLab::Hooks.run(:learn, ctx, registries: registries) do
515
+ @learnings.reject! { |existing| text.include?(existing) }
516
+
517
+ unless @learnings.any? { |existing| existing.include?(text) }
518
+ @learnings << text
519
+ @memory.set(:learnings, @learnings.dup)
520
+ ctx.stored = true
521
+ end
522
+
523
+ RobotLab::Hooks.call(:on_learn, ctx, registries: registries)
533
524
  end
534
525
 
535
526
  self
@@ -567,6 +558,22 @@ module RobotLab
567
558
 
568
559
  private
569
560
 
561
+ # `tools:` is a NAME allowlist (a filter over available tools), not a place
562
+ # to attach tool instances — passing instances there silently attaches
563
+ # nothing. Catch the mistake with a clear, actionable error.
564
+ def validate_tools_filter!(tools)
565
+ return unless tools.is_a?(Array)
566
+
567
+ offenders = tools.reject { |t| t.is_a?(String) || t.is_a?(Symbol) }
568
+ return if offenders.empty?
569
+
570
+ classes = offenders.map { |t| t.is_a?(Class) ? t.name : t.class.name }.uniq.join(", ")
571
+ raise ArgumentError,
572
+ "`tools:` expects tool names (String/Symbol) to allow, but received #{classes}. " \
573
+ "To attach tool instances or classes, pass them as `local_tools:` " \
574
+ "(e.g. RobotLab.build(local_tools: [MyTool.new]))."
575
+ end
576
+
570
577
  def assign_identity_ivars(name:, template:, system_prompt:, context:, description:,
571
578
  local_tools:, skills:, mcp_discovery:)
572
579
  @name = name.to_s
@@ -588,7 +595,7 @@ module RobotLab
588
595
  def build_effective_config(model:, temperature:, top_p:, top_k:, max_tokens:,
589
596
  presence_penalty:, frequency_penalty:, stop:,
590
597
  on_tool_call:, on_tool_result:, on_content:,
591
- bus:, enable_cache:, max_tool_rounds:, token_budget:,
598
+ bus:, enable_cache:, max_tool_rounds:, token_budget:, cost_budget:,
592
599
  doom_loop_threshold:, mcp_servers:, mcp:, tools:, config:)
593
600
  explicit_fields = {
594
601
  model: model, temperature: temperature, top_p: top_p, top_k: top_k,
@@ -596,7 +603,7 @@ module RobotLab
596
603
  frequency_penalty: frequency_penalty, stop: stop,
597
604
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
598
605
  on_content: on_content, bus: bus, enable_cache: enable_cache,
599
- max_tool_rounds: max_tool_rounds, token_budget: token_budget,
606
+ max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
600
607
  doom_loop_threshold: doom_loop_threshold
601
608
  }.compact
602
609
 
@@ -623,6 +630,7 @@ module RobotLab
623
630
  @bus = @config.bus
624
631
  @message_counter = 0
625
632
  @outbox = {}
633
+ @bus_mutex = Mutex.new
626
634
  @message_handler = ->(_msg) {}
627
635
  @bus_poller = nil
628
636
  @private_bus_poller = nil
@@ -630,6 +638,8 @@ module RobotLab
630
638
  @total_input_tokens = 0
631
639
  @total_output_tokens = 0
632
640
  @learnings = []
641
+ @hooks = HookRegistry.new
642
+ @budget_ledger = build_budget_ledger
633
643
  end
634
644
 
635
645
  def initialize_memory
@@ -639,16 +649,6 @@ module RobotLab
639
649
  @learnings = Array(persisted) if persisted
640
650
  end
641
651
 
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
652
  def apply_template
653
653
  define_chat_delegators
654
654
 
@@ -666,8 +666,27 @@ module RobotLab
666
666
  end
667
667
  end
668
668
 
669
+ # Append the inline system prompt to whatever apply_template already set.
670
+ #
671
+ # The template renders first and establishes the base instructions; the
672
+ # inline system_prompt is appended so both survive. Combining into a single
673
+ # system message (rather than using with_instructions(append: true), which
674
+ # adds a second one) keeps readers that look at only the first system
675
+ # message -- notably AgentSkillMatching's snapshot/restore -- correct.
669
676
  def apply_system_prompt
670
- @chat.with_instructions(@system_prompt) if @system_prompt
677
+ return unless @system_prompt
678
+
679
+ base = current_system_instructions.to_s
680
+ combined = [base, @system_prompt].reject(&:empty?).join("\n\n")
681
+ @chat.with_instructions(combined)
682
+ end
683
+
684
+ # Content of the chat's system message, or nil when none is set.
685
+ #
686
+ # @return [String, nil]
687
+ def current_system_instructions
688
+ messages = @chat.instance_variable_get(:@messages)
689
+ messages&.find { |m| m.role.to_s == "system" }&.content
671
690
  end
672
691
 
673
692
  def apply_chat_params
@@ -725,26 +744,89 @@ module RobotLab
725
744
 
726
745
  ensure_mcp_clients(resolved_mcp)
727
746
 
728
- filtered = filtered_tools(resolved_tools)
729
- @chat.with_tools(*filtered) if filtered.any?
747
+ # An EXPLICIT `:none` (or literal `[]`) means "send zero tools this turn" —
748
+ # e.g. a relevance filter judged no tool useful for the prompt. That is
749
+ # distinct from `:inherit`/unset, which means "no filter, use every attached
750
+ # tool". Both collapse to `[]` once resolved, and `filtered_tools([])`
751
+ # treats an empty allowlist as "all tools", so we must branch on the raw
752
+ # runtime value before resolution to honor the zero-tools intent.
753
+ filtered = explicit_none_tools?(tools) ? [] : cap_tools(filtered_tools(resolved_tools))
754
+
755
+ # replace: true so the chat holds EXACTLY this turn's resolved+capped set.
756
+ # RubyLLM's with_tools appends by default; on a persistent chat that lets
757
+ # tools accumulate across turns, so a capped per-turn addition could still
758
+ # push the chat's total past the provider limit. An explicit none clears
759
+ # the chat's tools to zero (with_tools(replace: true) with no tools).
760
+ @chat.with_tools(*filtered, replace: true) if filtered.any? || explicit_none_tools?(tools)
730
761
  end
731
762
 
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)
763
+ # True when the runtime tools value explicitly requests zero tools — `:none`
764
+ # or a literal empty array — as opposed to `:inherit`/`nil`, which mean "no
765
+ # filter, use all attached tools". Lets a per-turn filter that found nothing
766
+ # relevant actually send no tools instead of the whole set.
767
+ #
768
+ # @param tools [Symbol, Array, nil]
769
+ # @return [Boolean]
770
+ def explicit_none_tools?(tools)
771
+ tools == :none || (tools.is_a?(Array) && tools.empty?)
740
772
  end
741
773
 
742
- def enforce_token_budget!
743
- budget = @config.token_budget
744
- return unless budget && @total_input_tokens + @total_output_tokens > budget
774
+ # Clamp the resolved tool list to the provider's hard maximum. Most LLM
775
+ # providers reject tool arrays longer than 128 and fail the whole turn. This
776
+ # is the definitive choke point: tools are fully resolved (MCP connected)
777
+ # and about to be handed to the chat, so the cap holds no matter how the
778
+ # tools were configured, filtered, or connected. Cap value comes from
779
+ # RunConfig#max_tools, defaulting to DEFAULT_MAX_TOOLS; a nil, zero, or
780
+ # negative max_tools falls back to that default, so the cap cannot be
781
+ # disabled from configuration.
782
+ #
783
+ # @param tools [Array<Tool>] the resolved tools
784
+ # @return [Array<Tool>] at most `max_tools` tools
785
+ def cap_tools(tools)
786
+ max = effective_max_tools
787
+ return tools if max.nil? || tools.size <= max
788
+
789
+ RobotLab.config.logger.warn(
790
+ "[#{@name}] tool list (#{tools.size}) exceeds max_tools (#{max}); " \
791
+ "sending #{max}, dropping #{tools.size - max}"
792
+ )
793
+ tools.first(max)
794
+ end
745
795
 
746
- raise InferenceError,
747
- "Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
796
+ # The provider tool cap: RunConfig#max_tools when set positive, else the
797
+ # default. Returns nil only if the default itself is disabled.
798
+ #
799
+ # @return [Integer, nil]
800
+ def effective_max_tools
801
+ configured = @config.respond_to?(:max_tools) ? @config.max_tools : nil
802
+ configured&.positive? ? configured : DEFAULT_MAX_TOOLS
803
+ end
804
+
805
+ def invoke_ask(context:, kwargs:, hooks:, block:)
806
+ generation_context = LlmGenerationHookContext.new(
807
+ robot: self,
808
+ network: context.network,
809
+ task: context.task,
810
+ memory: context.memory,
811
+ config: context.config,
812
+ request: context.request,
813
+ metadata: context.metadata
814
+ )
815
+
816
+ RobotLab::Hooks.run(:llm_generation, generation_context,
817
+ registries: hook_registries(context.network), per_run_hooks: hooks) do
818
+ effective_message = inject_learnings(generation_context.request)
819
+ maybe_compact(network: context.network)
820
+ install_circuit_breaker if @config.max_tool_rounds
821
+ install_doom_loop_detection
822
+ ask_kwargs = kwargs.slice(:with)
823
+ streaming = effective_streaming_block(block)
824
+ ask(effective_message, **ask_kwargs, &streaming)
825
+ end
826
+ end
827
+
828
+ def hook_registries(network = nil)
829
+ [RobotLab.hooks, network&.hooks, @hooks]
748
830
  end
749
831
 
750
832
  # Extract run context from SimpleFlow::Result
@@ -757,6 +839,8 @@ module RobotLab
757
839
  memory = run_params.delete(:memory)
758
840
  network_memory = run_params.delete(:network_memory)
759
841
  network_config = run_params.delete(:network_config)
842
+ network = run_params.delete(:network)
843
+ task = run_params.delete(:task)
760
844
 
761
845
  # Build base context from remaining run params
762
846
  base = run_params.dup
@@ -779,16 +863,15 @@ module RobotLab
779
863
  merged[:memory] = memory if memory
780
864
  merged[:network_memory] = network_memory if network_memory
781
865
  merged[:network_config] = network_config if network_config
866
+ merged[:network] = network if network
867
+ merged[:task] = task if task
782
868
 
783
869
  merged
784
870
  end
785
871
 
786
872
  def build_result(response, _memory)
787
- output = if response.respond_to?(:content) && response.content
788
- [TextMessage.new(role: 'assistant', content: response.content)]
789
- else
790
- []
791
- end
873
+ text = result_text(response)
874
+ output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
792
875
 
793
876
  tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
794
877
 
@@ -817,6 +900,37 @@ module RobotLab
817
900
  )
818
901
  end
819
902
 
903
+ # Text for the result's output. Prefers the final response's content, then
904
+ # falls back in order to: (1) thinking text for models that route all output
905
+ # through reasoning_content (e.g. qwen3 on Ollama), (2) the most recent
906
+ # assistant text within the current turn for models that end on a tool call
907
+ # with no trailing text.
908
+ #
909
+ # The chat-history fallback is scoped to messages AFTER the last user message
910
+ # (the current turn) to prevent a previous turn's response from being returned
911
+ # when a thinking-mode model emits nothing in response.content.
912
+ def result_text(response)
913
+ content = response.content if response.respond_to?(:content)
914
+ return content if content && !content.to_s.empty?
915
+
916
+ # Ollama routes qwen3's reasoning to reasoning_content, which ruby_llm
917
+ # surfaces as response.thinking (a RubyLLM::Thinking object). When content
918
+ # is nil and thinking is present, the thinking IS the response for that turn.
919
+ if response.respond_to?(:thinking) && (thinking = response.thinking)
920
+ thinking_text = thinking.respond_to?(:text) ? thinking.text.to_s : thinking.to_s
921
+ return thinking_text unless thinking_text.empty?
922
+ end
923
+
924
+ return nil unless @chat.respond_to?(:messages)
925
+
926
+ messages = @chat.messages
927
+ last_user_idx = messages.rindex { |m| m.role == :user } || -1
928
+ current_turn = messages[(last_user_idx + 1)..]
929
+
930
+ last = current_turn.rfind { |m| m.role == :assistant && m.content && !m.content.to_s.empty? }
931
+ last&.content
932
+ end
933
+
820
934
  def normalize_tool_calls(tool_calls)
821
935
  return [] unless tool_calls
822
936
 
@@ -907,21 +1021,47 @@ module RobotLab
907
1021
  # :context_window — compress when estimated tokens exceed compact_threshold
908
1022
  # fraction of the model's context window (default 80%)
909
1023
  # Proc — called with self; application owns the decision and strategy
910
- def maybe_compact
1024
+ #
1025
+ # Fires the :compaction hook family (before/around/after_compaction).
1026
+ # An on_compaction handler can replace the default strategy entirely by
1027
+ # setting ctx.compacted_messages; the core algorithm is skipped when handled.
1028
+ def maybe_compact(network: nil)
911
1029
  return if @chat.messages.empty?
912
1030
 
913
1031
  compact = @config.auto_compact
914
1032
  return if compact.nil? || compact == :none
1033
+ return if compact == :context_window && !over_compact_threshold?
1034
+
1035
+ strategy = compact.is_a?(Proc) ? :custom : compact
1036
+ ctx = CompactionHookContext.new(
1037
+ robot: self,
1038
+ messages_before: @chat.messages.dup,
1039
+ config: @config,
1040
+ strategy: strategy
1041
+ )
1042
+
1043
+ RobotLab::Hooks.run(:compaction, ctx, registries: hook_registries(network)) do
1044
+ RobotLab::Hooks.call(:on_compaction, ctx, registries: hook_registries(network))
915
1045
 
916
- case compact
917
- when :context_window
918
- compact_if_over_context_window
919
- when Proc
920
- compact.call(self)
1046
+ if ctx.handled?
1047
+ replace_messages(ctx.compacted_messages)
1048
+ else
1049
+ case compact
1050
+ when :context_window
1051
+ begin
1052
+ compress_history
1053
+ rescue DependencyError => e
1054
+ RobotLab.config.logger.warn("[#{@name}] auto_compact: #{e.message}; skipping compaction")
1055
+ end
1056
+ when Proc
1057
+ compact.call(self)
1058
+ end
1059
+ ctx.compacted_messages = @chat.messages.dup
1060
+ end
921
1061
  end
922
1062
  end
923
1063
 
924
- def compact_if_over_context_window
1064
+ def over_compact_threshold?
925
1065
  threshold = (@config.compact_threshold || 0.80).to_f
926
1066
  estimated_tok = @chat.messages.sum { |m| m.content.to_s.length } / 4
927
1067
 
@@ -931,7 +1071,11 @@ module RobotLab
931
1071
  200_000
932
1072
  end
933
1073
 
934
- return if estimated_tok < window * threshold
1074
+ estimated_tok >= window * threshold
1075
+ end
1076
+
1077
+ def compact_if_over_context_window
1078
+ return unless over_compact_threshold?
935
1079
 
936
1080
  begin
937
1081
  compress_history
@@ -41,8 +41,8 @@ module RobotLab
41
41
  CALLBACK_FIELDS = %i[on_tool_call on_tool_result on_content].freeze
42
42
 
43
43
  # Infrastructure fields
44
- INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget ractor_pool_size max_concurrent_robots
45
- doom_loop_threshold auto_compact compact_threshold].freeze
44
+ INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget cost_budget ractor_pool_size
45
+ max_concurrent_robots doom_loop_threshold auto_compact compact_threshold max_tools].freeze
46
46
 
47
47
  # All recognized fields
48
48
  FIELDS = (LLM_FIELDS + TOOL_FIELDS + CALLBACK_FIELDS + INFRA_FIELDS).freeze
@@ -107,14 +107,33 @@ module RobotLab
107
107
 
108
108
  # Applies LLM fields to a chat object via its with_* methods.
109
109
  #
110
+ # +provider+/+assume_model_exists+ are threaded through to +with_model+
111
+ # specifically (RunConfig itself has no provider field -- provider lives
112
+ # on the Robot). Without this, re-applying a RunConfig after the chat's
113
+ # provider/model were already set via Robot#initialize (e.g. during
114
+ # template front-matter merging) drops that context on the floor and
115
+ # `with_model` falls back to the static model registry lookup, which
116
+ # raises ModelNotFoundError for any local-provider model (Ollama, etc.)
117
+ # not in RubyLLM's bundled registry.
118
+ #
110
119
  # @param chat [Object] a RubyLLM::Chat (or similar) that responds to with_model, with_temperature, etc.
111
- def apply_to(chat)
120
+ # @param provider [String, Symbol, nil] passed through to chat.with_model's provider: kwarg
121
+ # @param assume_model_exists [Boolean] passed through to chat.with_model's assume_exists: kwarg
122
+ def apply_to(chat, provider: nil, assume_model_exists: false)
112
123
  LLM_FIELDS.each do |field|
113
124
  value = @fields[field]
114
125
  next unless value
115
126
 
116
- method = :"with_#{field}"
117
- chat.public_send(method, value) if chat.respond_to?(method)
127
+ if field == :model && provider
128
+ # Only take the provider-aware path when a provider was actually
129
+ # given -- preserves the original single-arg call (and thus
130
+ # compatibility with any chat-like object exposing only
131
+ # `with_model(value)`) for the common case.
132
+ chat.with_model(value, provider:, assume_exists: assume_model_exists) if chat.respond_to?(:with_model)
133
+ else
134
+ method = :"with_#{field}"
135
+ chat.public_send(method, value) if chat.respond_to?(method)
136
+ end
118
137
  end
119
138
  end
120
139
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Shared interface for things you can run with a message: a single {Robot} or a
5
+ # multi-robot {Network}. It lets callers treat them uniformly instead of
6
+ # branching on `is_a?(RobotLab::Network)` — which otherwise spreads concrete
7
+ # type knowledge across every consumer.
8
+ #
9
+ # Implementers provide:
10
+ # - #run(message = nil, **opts) — execute with the given message (both Robot
11
+ # and Network accept a positional message and keyword options)
12
+ # - #crew — the constituent robots, as an Array
13
+ # - #network? — true for a multi-robot network
14
+ #
15
+ # The rest (chief, robot_count, single?) derive from those.
16
+ #
17
+ # @example treat either uniformly
18
+ # runnable.run(prompt, mcp: :inherit, tools: :inherit)
19
+ # names = runnable.crew.map(&:name)
20
+ # show_network(runnable) if runnable.network?
21
+ module Runnable
22
+ # The constituent robots as an Array. A single Robot is a crew of one.
23
+ #
24
+ # @return [Array<Robot>]
25
+ def crew
26
+ raise NotImplementedError, "#{self.class} must implement #crew"
27
+ end
28
+
29
+ # The lead robot — the chief of the crew.
30
+ #
31
+ # @return [Robot, nil]
32
+ def chief
33
+ crew.first
34
+ end
35
+
36
+ # @return [Integer] number of constituent robots
37
+ def robot_count
38
+ crew.size
39
+ end
40
+
41
+ # @return [Boolean] true for a multi-robot network
42
+ def network?
43
+ false
44
+ end
45
+
46
+ # @return [Boolean] true for a single robot
47
+ def single?
48
+ !network?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Sandbox
5
+ # Passthrough strategy: runs the command with no confinement. Used when
6
+ # sandboxing is unavailable (non-macOS) or unnecessary (trust: core).
7
+ class Null
8
+ def wrap(cmd) = cmd
9
+
10
+ def cleanup; end
11
+ end
12
+ end
13
+ end