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
@@ -85,16 +85,29 @@ weather_tool = RobotLab::Tool.create(
85
85
 
86
86
  `RobotLab::AskUser` lets a robot ask the user a question via the terminal. The LLM decides when it needs human input and calls the tool with a question, optional choices, and an optional default.
87
87
 
88
+ `AskUser` reads its IO from `robot.input` / `robot.output`, and it finds the
89
+ robot through its own `robot` accessor — which is only populated when you attach
90
+ an **instance constructed with `robot:`**. Build the robot first, then attach:
91
+
88
92
  ```ruby
89
93
  robot = RobotLab.build(
90
94
  name: "onboarding",
91
- system_prompt: "Walk the user through project setup. Ask questions to understand their needs.",
92
- local_tools: [RobotLab::AskUser]
95
+ system_prompt: "Walk the user through project setup. Ask questions to understand their needs."
93
96
  )
94
- robot.run("Help the user set up a new project")
97
+ robot.local_tools << RobotLab::AskUser.new(robot: robot)
98
+
99
+ robot.run("Help the user set up a new project", tools: :inherit)
95
100
  ```
96
101
 
97
- The tool displays the robot's name and question, then waits for terminal input:
102
+ > [!WARNING]
103
+ > Passing the bare class — `local_tools: [RobotLab::AskUser]` — leaves
104
+ > `tool.robot` as `nil`. The tool then ignores `robot.input`/`robot.output`,
105
+ > reads from the real `$stdin` (blocking your process, and hanging any test
106
+ > suite), and labels its prompt `[Robot]` instead of the robot's name.
107
+ > Note also the `tools: :inherit` above: a plain `run()` sends **zero** tools,
108
+ > so the LLM would never see `AskUser` at all.
109
+
110
+ The tool displays the robot's name and question, then waits for input:
98
111
 
99
112
  ```
100
113
  [onboarding] What programming language will you use?
@@ -110,13 +123,27 @@ Features:
110
123
  - **Multiple choice**: numbered options, user types the number or text
111
124
  - **Default value**: shown in the prompt, used when user presses Enter
112
125
 
113
- IO is sourced from `robot.input` / `robot.output` (defaulting to `$stdin` / `$stdout`), making it easy to test with `StringIO`:
126
+ Because the IO is sourced from the robot, a `StringIO` pair makes the tool
127
+ testable without a terminal:
114
128
 
115
129
  ```ruby
116
130
  robot.input = StringIO.new("2\n")
117
131
  robot.output = StringIO.new
132
+
133
+ # ... the LLM calls ask_user(question: "Which language?", choices: %w[Ruby Python Go])
134
+ robot.output.string
135
+ # => "\n[onboarding] Which language?\n 1. Ruby\n 2. Python\n 3. Go\n> "
118
136
  ```
119
137
 
138
+ > [!NOTE]
139
+ > The name the LLM sees is **`robot_lab--ask_user`**, not `ask_user`. RubyLLM
140
+ > derives tool names from the full class name including its namespace, so
141
+ > `RobotLab::AskUser` becomes `robot_lab--ask_user`. (A trailing `Tool` is
142
+ > stripped: a top-level `WeatherTool` is exposed as `weather`.) These derived
143
+ > names are what an allowlist must match **for tools attached as instances** —
144
+ > a tool attached as a class matches its class name instead. See
145
+ > [Runtime Tool Filtering](#runtime-tool-filtering).
146
+
120
147
  See the [AskUser API reference](../api/core/tool.md#built-in-askuser) for full details.
121
148
 
122
149
  ## Attaching Tools to Robots
@@ -133,6 +160,15 @@ robot = RobotLab.build(
133
160
  )
134
161
  ```
135
162
 
163
+ `local_tools:` and `tools:` are different mechanisms — don't confuse them. `local_tools:` attaches tool **instances or classes**; `tools:` (see [Runtime Tool Filtering](#runtime-tool-filtering) below) is a **name allowlist** that filters which of the already-attached tools are sent for a given turn. Passing an instance or class to `tools:` raises `ArgumentError` immediately, naming the offending class and pointing you at `local_tools:` instead:
164
+
165
+ ```ruby
166
+ RobotLab.build(name: "bot", tools: [GetWeather.new])
167
+ # => ArgumentError: `tools:` expects tool names (String/Symbol) to allow, but
168
+ # received GetWeather. To attach tool instances or classes, pass them as
169
+ # `local_tools:` (e.g. RobotLab.build(local_tools: [MyTool.new])).
170
+ ```
171
+
136
172
  ### Via Template Front Matter
137
173
 
138
174
  Declare tool class names in the template's YAML front matter. RobotLab resolves each string to a Ruby constant via `Object.const_get` and instantiates it:
@@ -156,13 +192,156 @@ Tool classes must be defined and loaded before building the robot. Unresolvable
156
192
 
157
193
  ### Via Chaining
158
194
 
159
- You can also add tools dynamically with chaining:
195
+ `with_tools` is delegated straight to the underlying `RubyLLM::Chat`, so it
196
+ writes tools onto the chat rather than onto the robot's `local_tools`:
160
197
 
161
198
  ```ruby
162
199
  robot = RobotLab.build(name: "assistant", system_prompt: "...")
163
200
  robot.with_tools(GetWeather, CalculatorTool)
164
201
  ```
165
202
 
203
+ > [!WARNING]
204
+ > Chained tools do not survive a `run`. Because `run` defaults to
205
+ > `tools: :none`, it replaces the chat's tool list with an empty one before
206
+ > asking the LLM — wiping whatever `with_tools` put there:
207
+ >
208
+ > ```ruby
209
+ > robot.with_tools(GetWeather, CalculatorTool)
210
+ > robot.chat.tools.keys # => [:get_weather, :calculator]
211
+ > robot.run("Hello")
212
+ > robot.chat.tools.keys # => [] <- cleared by the run
213
+ > ```
214
+ >
215
+ > For tools that should persist across runs, attach them with `local_tools:`
216
+ > and pass `tools: :inherit` on each `run`.
217
+
218
+ ## Runtime Tool Filtering
219
+
220
+ `tools:` (and `mcp:`) also work as a **per-run** override, passed to `run()` itself, on top of the build-time/network/global hierarchy described in [Hierarchical MCP and Tools](../getting-started/configuration.md#hierarchical-mcp-and-tools):
221
+
222
+ ```ruby
223
+ robot.run("What's the weather?", tools: :inherit) # every attached tool
224
+ robot.run("Just chat, no tools needed.", tools: :none) # zero tools this turn
225
+ # Allowlist. This robot attached its tools as CLASSES, so match the class names:
226
+ robot.run("Only use the calculator.", tools: %w[CalculatorTool])
227
+ ```
228
+
229
+ | Value | Meaning |
230
+ |-------|---------|
231
+ | `:inherit` | Propagate the parent level's allowlist (build-time / network / global) |
232
+ | `:none`, `[]` | Send **zero** tools this turn |
233
+ | `nil` | No filter at all — every attached tool, **discarding** any parent allowlist |
234
+ | `["name", ...]` | Only these tools, matched against how each was attached |
235
+
236
+ > [!CAUTION]
237
+ > `tools: nil` does **not** send zero tools, and it is **not** the same as
238
+ > `:inherit`. `:inherit` carries the parent's allowlist down; `nil` throws the
239
+ > filter away entirely. With a robot built `tools: %w[GetWeather]` holding
240
+ > `GetWeather` and `CalculatorTool`:
241
+ >
242
+ > ```
243
+ > run(tools: :inherit) -> [:get_weather] # parent allowlist honored
244
+ > run(tools: nil) -> [:get_weather, :calculator] # allowlist discarded
245
+ > ```
246
+ >
247
+ > If you mean zero tools, write `:none`.
248
+
249
+ > [!IMPORTANT]
250
+ > An allowlist entry must match **how the tool was attached**, because the
251
+ > comparison uses `tool.name` and `Class#name` differs from `RubyLLM::Tool#name`:
252
+ >
253
+ > | Attached as | Allowlist that matches |
254
+ > |---|---|
255
+ > | `local_tools: [CalculatorTool]` (class) | `[CalculatorTool]` or `%w[CalculatorTool]` |
256
+ > | `local_tools: [CalculatorTool.new]` (instance) | `%w[calculator]` |
257
+ >
258
+ > Note the constructor's `tools:` is validated and **rejects** classes and
259
+ > instances outright (`ArgumentError`); only task-level and `run()` values accept
260
+ > them.
261
+
262
+ An explicit `:none`/`[]` is useful for a relevance filter that decided no tool is useful for the current message — it now genuinely sends zero tools for that turn (previously an empty allowlist was silently treated as "all tools," which could overflow small-context local models with the full tool set). Each turn's resolved tool set fully **replaces** the chat's tools rather than accumulating, so a later `:none` turn correctly clears whatever a prior turn attached.
263
+
264
+ > [!CAUTION]
265
+ > **Watch the default.** Both `Robot.new`'s and `run()`'s `tools:`/`mcp:`
266
+ > parameters default to `:none`, not `:inherit`. If you build a robot with
267
+ > `local_tools:` and then call `robot.run(message)` with no `tools:` override at
268
+ > all, the runtime default takes the explicit-`:none` path above — sending no
269
+ > tools for that turn. **Pass `tools: :inherit` on the `run()` call** (or on the
270
+ > network `task`, which is forwarded to `run()`) anywhere you need the robot's
271
+ > attached tools available.
272
+
273
+ > [!WARNING]
274
+ > For a **standalone** robot, do not pass `tools: :inherit` at *build* time as a
275
+ > way to turn tools on. Build-time `:inherit` resolves against the level above
276
+ > it, which for a standalone robot is the global `:none` — producing an
277
+ > allowlist of `["none"]` that matches nothing, and the empty result then
278
+ > carries into the runtime pass. Leave build-time `tools:` unset:
279
+ >
280
+ > ```ruby
281
+ > # build tools: unset + run(tools: :inherit) -> [:t1] correct
282
+ > # build tools: :inherit + run(tools: :inherit) -> [] the standalone trap
283
+ > # build tools: :none + run(tools: :inherit) -> [:t1] also fine
284
+ > ```
285
+ >
286
+ > This applies to a robot run on its own. Inside a **network** whose `config:`
287
+ > supplies `tools:`/`mcp:`, the parent level is that network list rather than
288
+ > `:none`, and build-time `:inherit` is *required* — it is the robot's opt-in to
289
+ > the network value. See
290
+ > [Network-Wide Tool and MCP Defaults](creating-networks.md#network-wide-tool-and-mcp-defaults).
291
+
292
+ ### Tool Capping and Per-Turn Filtering
293
+
294
+ Most LLM providers reject a tool array longer than 128 entries and fail the whole turn. RobotLab clamps the fully-resolved tool list to a ceiling right before handing it to the chat provider — the definitive choke point regardless of how the tools were configured, filtered, or MCP-connected:
295
+
296
+ ```ruby
297
+ robot = RobotLab.build(
298
+ name: "power_user",
299
+ system_prompt: "...",
300
+ local_tools: many_tools, # say, 150 tools
301
+ config: RobotLab::RunConfig.new(max_tools: 50) # override the default ceiling
302
+ )
303
+ ```
304
+
305
+ - Default ceiling: **128** tools per turn (`RobotLab::Robot::DEFAULT_MAX_TOOLS`)
306
+ - Override with a **positive** `max_tools:` on `RunConfig` (or the `max_tools:` cascade field — see [Available Fields](../getting-started/configuration.md#available-fields))
307
+ - When a turn's resolved tools exceed the cap, RobotLab logs a warning naming how many were dropped and sends the first `max_tools` entries
308
+
309
+ > [!WARNING]
310
+ > The cap **cannot be disabled.** `max_tools: nil`, `0`, or a negative number
311
+ > all fall back to the 128 default — only a positive integer changes the
312
+ > ceiling. If you attach more than 128 tools and set `max_tools: 0` expecting
313
+ > "unlimited", you get the first 128.
314
+ >
315
+ > The truncation is not silent, though — `cap_tools` logs it at `WARN` through
316
+ > `RobotLab.config.logger` every time it fires:
317
+ >
318
+ > ```
319
+ > [power_user] tool list (150) exceeds max_tools (128); sending 128, dropping 22
320
+ > ```
321
+
322
+ ## Skill Scripts and Sandboxing
323
+
324
+ A skill bundle (a directory with a `SKILL.md` plus `scripts/`, discovered via `AgentSkill`) can expose its scripts as tools (`ScriptTool`). Because those scripts run as real OS processes, each `SKILL.md` can declare the capabilities its scripts need directly in front matter, alongside `name`/`description`:
325
+
326
+ ```markdown title="skills/deploy-checker/SKILL.md"
327
+ ---
328
+ name: deploy-checker
329
+ description: Verifies a deployment's health before promoting it.
330
+ fs_read: ["./data", "/etc/hosts"]
331
+ fs_write: ["./out"]
332
+ network: true
333
+ timeout: 30
334
+ trust: external # or "core" for trusted, always-unconfined skills
335
+ ---
336
+ ```
337
+
338
+ Sandboxing itself is **opt-in and off by default** — see the [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section). When disabled, scripts run exactly as they always have, unconfined. When enabled:
339
+
340
+ - The global `sandbox:` config is a **ceiling** (`fs_read`, `fs_write`, `network`, `timeout`); each skill's front matter is its **declared** request. The script actually runs under the **intersection** of the two — a path outside the ceiling's roots is dropped even if the skill declares it, `network` requires both sides to allow it, and `timeout` is the smaller of the two.
341
+ - On macOS, confinement is enforced with a generated `sandbox-exec` (Seatbelt) profile: deny-by-default, with narrow allowances for the interpreter to boot, the granted read/write paths, and (optionally) the network. Notably, `$HOME` is never implicitly readable — SSH keys and cloud credentials stay out of reach unless a path under `$HOME` is explicitly granted.
342
+ - Off macOS, or for any skill declaring `trust: core`, sandboxing is a passthrough — confinement is currently macOS-only and is always skipped for trusted "core" skills regardless of platform.
343
+ - A script that runs past its `timeout` is killed (its whole process group) and reported back to the LLM as a timed-out error rather than hanging the turn.
344
+
166
345
  ## Parameter Types
167
346
 
168
347
  Define parameters on `RubyLLM::Tool` subclasses using `param`:
@@ -197,10 +376,22 @@ param :active, type: :boolean, desc: "Whether the user is active"
197
376
  param :tags, type: :array, desc: "List of tags"
198
377
  ```
199
378
 
200
- ### Enum
379
+ ### Enumerated Values
380
+
381
+ `param` has **no `enum:` option**. Its full signature is
382
+ `param(name, type: "string", desc: nil, description: nil, required: true)` —
383
+ anything else raises `ArgumentError: unknown keyword: :enum`. Express the
384
+ allowed values in the description instead, and validate in `execute`:
201
385
 
202
386
  ```ruby
203
- param :status, type: :string, desc: "Order status", enum: %w[pending active completed]
387
+ STATUSES = %w[pending active completed].freeze
388
+
389
+ param :status, type: :string, desc: "Order status — one of: pending, active, completed"
390
+
391
+ def execute(status:)
392
+ return { error: "status must be one of #{STATUSES.join(', ')}" } unless STATUSES.include?(status)
393
+ # ...
394
+ end
204
395
  ```
205
396
 
206
397
  ### Required vs Optional
@@ -347,7 +538,39 @@ result = tool.call({ "id" => "missing" })
347
538
  # => "Error (fetch_resource): connection refused"
348
539
  ```
349
540
 
350
- This applies to all `RobotLab::Tool` variants — subclasses, `Tool.create` factory tools, and MCP tools. Errors are also logged via `RobotLab.config.logger` at `:warn` level.
541
+ This applies to all `RobotLab::Tool` variants — subclasses, `Tool.create` factory tools, and MCP tools.
542
+
543
+ ### RobotLab::ToolError and Retryability
544
+
545
+ `RobotLab::ToolError` is handled on a separate path from ordinary
546
+ `StandardError`s. Raising one with `retryable: true` appends `" (retryable)"`
547
+ to the text the LLM sees, hinting that another attempt may succeed:
548
+
549
+ ```ruby
550
+ class FetchResource < RobotLab::Tool
551
+ description "Fetch a resource from an external API"
552
+ param :id, type: :string, desc: "Resource ID"
553
+
554
+ def execute(id:)
555
+ raise RobotLab::ToolError.new("upstream 503", retryable: true)
556
+ end
557
+ end
558
+
559
+ FetchResource.new.call({ "id" => "1" })
560
+ # => "Error (fetch_resource): upstream 503 (retryable)"
561
+
562
+ # Without retryable:, no suffix is appended:
563
+ # raise RobotLab::ToolError, "bad input"
564
+ # => "Error (fetch_resource): bad input"
565
+ ```
566
+
567
+ > [!WARNING]
568
+ > Only the ordinary `StandardError` path writes to the log
569
+ > (`RobotLab.config.logger.warn("Tool 'name' error: RuntimeError: …")`).
570
+ > A `RobotLab::ToolError` is turned into text for the LLM and logged
571
+ > **nowhere** — if you rely on `ToolError` for expected failures, add your own
572
+ > logging inside `execute`, or those failures leave no trace outside the
573
+ > transcript.
351
574
 
352
575
  ### Critical Tools (Opt-Out)
353
576
 
@@ -368,6 +591,24 @@ end
368
591
 
369
592
  `raise_on_error` is per-class and defaults to `false`. Setting it on one class does not affect others.
370
593
 
594
+ > [!WARNING]
595
+ > `raise_on_error` does **not** walk the inheritance chain. A subclass of a
596
+ > class that set it silently reverts to `false`:
597
+ >
598
+ > ```ruby
599
+ > class CriticalBase < RobotLab::Tool
600
+ > self.raise_on_error = true
601
+ > end
602
+ >
603
+ > class ChargeCard < CriticalBase; end
604
+ >
605
+ > CriticalBase.raise_on_error? # => true
606
+ > ChargeCard.raise_on_error? # => false <- NOT inherited
607
+ > ```
608
+ >
609
+ > Set `self.raise_on_error = true` on every class that needs it. (This differs
610
+ > from `ractor_safe`, below, which *does* consult the superclass.)
611
+
371
612
  ### Manual Error Handling
372
613
 
373
614
  You can still handle specific errors inside `execute` for domain-specific responses:
@@ -389,20 +630,68 @@ class FetchResource < RobotLab::Tool
389
630
  end
390
631
  ```
391
632
 
633
+ ## Ractor-Safe Tools
634
+
635
+ A tool class can declare itself safe to run inside a Ractor. When it is, and the
636
+ [`robot_lab-ractor`](https://github.com/MadBomber/robot_lab-ractor) extension
637
+ gem is loaded, `call` dispatches the work to `RobotLab.ractor_pool` instead of
638
+ running it inline on the calling thread — giving real CPU parallelism for
639
+ compute-bound tools.
640
+
641
+ ```ruby
642
+ class Fibonacci < RobotLab::Tool
643
+ ractor_safe true
644
+
645
+ description "Compute the nth Fibonacci number"
646
+ param :n, type: :integer, desc: "Which Fibonacci number to compute"
647
+
648
+ def execute(n:)
649
+ a, b = 0, 1
650
+ n.times { a, b = b, a + b }
651
+ a
652
+ end
653
+ end
654
+
655
+ Fibonacci.ractor_safe? # => true
656
+ ```
657
+
658
+ Dispatch to the pool happens only when **all three** hold:
659
+
660
+ 1. `ractor_safe?` is true
661
+ 2. the class has a resolvable name — anonymous classes (including every tool
662
+ built by `Tool.create`) fall back to the inline path
663
+ 3. the `robot_lab-ractor` extension is loaded
664
+
665
+ Otherwise `execute` runs inline, exactly as an ordinary tool would. Pool size
666
+ comes from the `ractor_pool_size` `RunConfig` field (`:auto` when unset).
667
+
668
+ > [!WARNING]
669
+ > A Ractor-safe tool must be genuinely stateless: it is instantiated **fresh
670
+ > inside the Ractor worker** for every call, so `robot`, captured closures, and
671
+ > mutable class-level state are not available to it, and the result comes back
672
+ > frozen. Do not set `ractor_safe true` on a tool that touches its robot.
673
+
674
+ Unlike `raise_on_error`, `ractor_safe` **does** walk the inheritance chain — a
675
+ subclass of a Ractor-safe tool is Ractor-safe unless it says otherwise.
676
+
392
677
  ## Tool Callbacks
393
678
 
394
- Robots support `on_tool_call` and `on_tool_result` callbacks for monitoring tool usage:
679
+ Robots support `on_tool_call` and `on_tool_result` callbacks for monitoring tool usage. Each receives exactly **one** argument — `on_tool_result` gets the result only, not the originating call:
395
680
 
396
681
  ```ruby
397
682
  robot = RobotLab.build(
398
683
  name: "assistant",
399
684
  system_prompt: "...",
400
685
  local_tools: [GetWeather],
401
- on_tool_call: ->(call) { puts "Calling: #{call}" },
686
+ on_tool_call: ->(tool_call) { puts "Calling: #{tool_call.name}" },
402
687
  on_tool_result: ->(result) { puts "Result: #{result}" }
403
688
  )
689
+
690
+ robot.run("What's the weather in Tokyo?", tools: :inherit)
404
691
  ```
405
692
 
693
+ See [Streaming](streaming.md#tool-callbacks) for details, including the RubyLLM deprecation warning these emit.
694
+
406
695
  ## RobotLab::Tool.create with Schema
407
696
 
408
697
  For dynamic tools via `Tool.create`, pass parameters as a JSON Schema hash:
data/docs/index.md CHANGED
@@ -50,7 +50,7 @@ Each robot is backed by a persistent LLM chat, configured with keyword arguments
50
50
 
51
51
  Mix reusable prompt behaviors into any robot. Skills expand depth-first with automatic cycle detection and config cascading.
52
52
 
53
- [:octicons-arrow-right-24: Skills Guide](guides/building-robots.md#skills)
53
+ [:octicons-arrow-right-24: Skills Guide](guides/building-robots.md#composable-skills)
54
54
 
55
55
  - :material-tools:{ .lg .middle } **Extensible Tools**
56
56
 
@@ -142,16 +142,35 @@ Each robot is backed by a persistent LLM chat, configured with keyword arguments
142
142
 
143
143
  </div>
144
144
 
145
+ > [!NOTE]
146
+ > A few of the features above need a gem the core `robot_lab` gem does not install.
147
+ > **Knowledge & Retrieval** (`memory.store_document` / `memory.search_documents`)
148
+ > and **Runtime Skill Matching** (`AgentSkill` catalogs) both require
149
+ > `robot_lab-document_store`; the Redis backing for **Reactive Memory** requires
150
+ > `redis`; Ractor parallelism requires `robot_lab-ractor`; and
151
+ > `robot.compress_history` requires `classifier`. See
152
+ > [Optional Dependencies](getting-started/installation.md#optional-dependencies).
153
+
145
154
  ## Extension Gems
146
155
 
147
156
  These optional gems extend RobotLab with additional capabilities:
148
157
 
149
158
  | Gem | What it adds |
150
159
  |-----|-------------|
151
- | [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails) | Rails Engine, generators, `RobotLab::Job` base class, Turbo Stream broadcasting |
152
- | [robot_lab-ractor](https://github.com/MadBomber/robot_lab-ractor) | CPU parallelism for `ractor_safe` tools and robot networks via Ruby Ractors |
153
- | [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) | Cross-session knowledge persistence robots accumulate and recall learned facts |
160
+ | [robot_lab-a2a](https://github.com/MadBomber/robot_lab-a2a) | Agent2Agent (A2A) protocol adapter over HTTP + SSE |
161
+ | [robot_lab-audit](https://github.com/MadBomber/robot_lab-audit) | SQLite-backed execution audit log, wired through the Hook system |
162
+ | [robot_lab-discovery](https://github.com/MadBomber/robot_lab-discovery) | Zero-configuration mDNS/DNS-SD robot discovery on the local network |
154
163
  | [robot_lab-document_store](https://github.com/MadBomber/robot_lab-document_store) | Embedding-based semantic document search powered by fastembed |
164
+ | [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) | HTM-backed long-term memory — robots accumulate and recall learned facts across sessions |
165
+ | [robot_lab-ractor](https://github.com/MadBomber/robot_lab-ractor) | CPU parallelism for `ractor_safe` tools and robot networks via Ruby Ractors |
166
+ | [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails) | Rails Engine, generators, `RobotLab::Job` base class, Turbo Stream broadcasting |
167
+ | [robot_lab-to](https://github.com/MadBomber/robot_lab-to) | Autonomous overnight agent loop — iterate a robot toward an objective, committing each step |
168
+ | [robot_lab-web](https://github.com/MadBomber/robot_lab-web) | Rails-free Sinatra + HTMX browser console that streams a robot's run over SSE |
169
+
170
+ > [!NOTE]
171
+ > The Rails Engine, generators, `RobotLab::Job`, and Turbo broadcasting are
172
+ > **only** in `robot_lab-rails`. The core gem ships no Railtie and no generators.
173
+ > The former `robot_lab-acp` gem is retired and superseded by `robot_lab-a2a`.
155
174
 
156
175
  ## Quick Example
157
176
 
@@ -162,7 +181,10 @@ require "robot_lab"
162
181
  # Set API keys via env vars:
163
182
  # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
164
183
  #
165
- # Or place a config file at ~/.config/robot_lab/config.yml
184
+ # Or place a config file at ~/.config/robot_lab/robot_lab.yml
185
+ # (the filename repeats the app name; config.yml is never read). Keys go at the
186
+ # top level, or under a section named for the current environment
187
+ # (development:/test:/production:). Only a `defaults:` wrapper is ignored.
166
188
  # Access config values: RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
167
189
 
168
190
  # Create a robot with keyword arguments
@@ -188,6 +210,16 @@ puts result.last_text_content
188
210
  robot.with_instructions("Be extra concise.").with_temperature(0.3).run("Explain Ruby in one sentence.")
189
211
  ```
190
212
 
213
+ > [!IMPORTANT]
214
+ > `Robot#run` defaults to `tools: :none` and `mcp: :none`, so a plain
215
+ > `robot.run("...")` sends the model **no tools** even when you attached them
216
+ > with `local_tools:` or `mcp:` at build time. Ask for them on the run:
217
+ >
218
+ > ```ruby
219
+ > robot.run("...", tools: :inherit) # attached local tools
220
+ > robot.run("...", mcp: :inherit, tools: :inherit) # MCP servers and their tools
221
+ > ```
222
+
191
223
  ## Supported LLM Providers
192
224
 
193
225
  RobotLab supports multiple LLM providers through the [ruby_llm](https://github.com/crmne/ruby_llm) library:
@@ -6,13 +6,13 @@
6
6
  # Demonstrates creating and running a basic robot with a template.
7
7
  #
8
8
  # Usage:
9
- # ANTHROPIC_API_KEY=your_key ruby examples/01_simple_robot.rb
9
+ # ruby examples/01_simple_robot.rb
10
10
 
11
11
  require_relative "common"
12
12
 
13
13
  # Create a simple robot using a template
14
14
  robot = RobotLab.build(
15
- model: LLM[:default].model,
15
+ **llm_opts,
16
16
  name: "helper",
17
17
  template: :helper
18
18
  )
data/examples/02_tools.rb CHANGED
@@ -6,7 +6,7 @@
6
6
  # Demonstrates creating a robot with custom tools using RubyLLM::Tool.
7
7
  #
8
8
  # Usage:
9
- # ANTHROPIC_API_KEY=your_key ruby examples/02_tools.rb
9
+ # ruby examples/02_tools.rb
10
10
 
11
11
  require_relative "common"
12
12
 
@@ -78,7 +78,7 @@ end
78
78
 
79
79
  # Create robot with tools
80
80
  robot = RobotLab.build(
81
- model: LLM[:default].model,
81
+ **llm_opts,
82
82
  name: "assistant",
83
83
  template: :assistant,
84
84
  local_tools: [Calculator, FortuneCookie]
@@ -86,11 +86,21 @@ robot = RobotLab.build(
86
86
 
87
87
  banner "Robot with Tools"
88
88
 
89
- # Run the robot
90
- result = robot.run("What is 15 multiplied by 7? Also, give me a fortune about my career.")
89
+ # Run the robot.
90
+ #
91
+ # tools: :inherit is REQUIRED to actually offer the tools to the model.
92
+ # Robot#run defaults to tools: :none, which means "send zero tools this turn"
93
+ # — the robot still holds them in local_tools, but the provider never sees
94
+ # them and the LLM answers from memory instead of calling Calculator.
95
+ # :inherit means "no per-turn filter, use every attached tool".
96
+ result = robot.run(
97
+ "What is 15 multiplied by 7? Also, give me a fortune about my career.",
98
+ tools: :inherit
99
+ )
91
100
 
92
101
  # Display results
93
102
  puts "Robot: #{robot.name}"
103
+ puts "Tools offered: #{robot.local_tools.map(&:name).join(', ')}"
94
104
  puts "\nOutput:"
95
105
  result.output.each do |message|
96
106
  puts " #{message.content}" if message.respond_to?(:content)
@@ -7,10 +7,12 @@
7
7
  # using SimpleFlow's optional step activation.
8
8
  #
9
9
  # Usage:
10
- # ANTHROPIC_API_KEY=your_key ruby examples/03_network.rb
10
+ # ruby examples/03_network.rb
11
11
 
12
12
  require_relative "common"
13
13
 
14
+ SPECIALIST_TASKS = %i[billing technical general].freeze
15
+
14
16
  # Classifier robot that activates the appropriate specialist
15
17
  class ClassifierRobot < RobotLab::Robot
16
18
  def call(result)
@@ -36,32 +38,37 @@ class ClassifierRobot < RobotLab::Robot
36
38
  end
37
39
  end
38
40
 
39
- # Shared RunConfig — all robots in this network use the same model
40
- shared_config = RobotLab::RunConfig.new(model: LLM[:default].model)
41
+ # Shared RunConfig — operational defaults every robot in this network inherits.
42
+ #
43
+ # RunConfig has no `provider` field (see RunConfig::FIELDS), and an Ollama
44
+ # model is absent from RubyLLM's registry, so provider and model still travel
45
+ # together on each robot via **llm_opts. The RunConfig carries the settings
46
+ # that genuinely are shared.
47
+ shared_config = RobotLab::RunConfig.new(temperature: 0.3, max_tool_rounds: 5)
41
48
 
42
- # Create specialized robots (no model: needed — inherited from RunConfig)
43
49
  classifier = ClassifierRobot.new(
50
+ **llm_opts,
44
51
  name: "classifier",
45
52
  template: :classifier,
46
53
  config: shared_config
47
54
  )
48
55
 
49
56
  billing_robot = RobotLab.build(
50
- model: LLM[:default].model,
57
+ **llm_opts,
51
58
  name: "billing",
52
59
  template: :billing,
53
60
  config: shared_config
54
61
  )
55
62
 
56
63
  technical_robot = RobotLab.build(
57
- model: LLM[:default].model,
64
+ **llm_opts,
58
65
  name: "technical",
59
66
  template: :technical,
60
67
  config: shared_config
61
68
  )
62
69
 
63
70
  general_robot = RobotLab.build(
64
- model: LLM[:default].model,
71
+ **llm_opts,
65
72
  name: "general",
66
73
  template: :general,
67
74
  config: shared_config
@@ -76,8 +83,13 @@ network = RobotLab.create_network(name: "support_network", config: shared_config
76
83
  end
77
84
 
78
85
  banner "Multi-Robot Network"
79
- puts "Network structure:"
80
- puts network.visualize
86
+ puts "Routing plan:"
87
+ puts " Entry task: classifier"
88
+ puts " Optional specialists: #{SPECIALIST_TASKS.join(', ')}"
89
+ puts " Runtime behavior: classifier activates exactly one specialist"
90
+ puts
91
+ puts "Underlying SimpleFlow structure:"
92
+ puts " Optional tasks: #{network.to_h[:optional_tasks].join(', ')}"
81
93
  hr
82
94
 
83
95
  # Run the network with a billing question
@@ -85,6 +97,8 @@ result = network.run(message: "I was charged twice for my subscription last mont
85
97
 
86
98
  # Display results
87
99
  puts "Network: #{network.name}"
100
+ puts "Executed tasks: #{result.context.keys.grep_v(:run_params).join(', ')}"
101
+ puts "Skipped specialists: #{(SPECIALIST_TASKS - result.context.keys).join(', ')}"
88
102
  puts "\nConversation flow:"
89
103
 
90
104
  # Show classifier result
data/examples/04_mcp.rb CHANGED
@@ -9,11 +9,10 @@
9
9
  # Prerequisites:
10
10
  # 1. Install the GitHub MCP server: brew install github-mcp-server
11
11
  # 2. Set environment variables:
12
- # - ANTHROPIC_API_KEY: Your Anthropic API key
13
12
  # - GITHUB_PERSONAL_ACCESS_TOKEN: Your GitHub personal access token
14
13
  #
15
14
  # Usage:
16
- # ANTHROPIC_API_KEY=your_key GITHUB_PERSONAL_ACCESS_TOKEN=your_token ruby examples/04_mcp.rb
15
+ # GITHUB_PERSONAL_ACCESS_TOKEN=your_token ruby examples/04_mcp.rb
17
16
  #
18
17
  # The GitHub MCP server provides tools for:
19
18
  # - Searching repositories, code, issues, and users
@@ -136,7 +135,7 @@ begin
136
135
  # connect_mcp! forces eager connection; without it MCP clients initialize
137
136
  # lazily on the first run() call, leaving mcp_clients empty until then.
138
137
  robot = RobotLab.build(
139
- model: LLM[:default].model,
138
+ **llm_opts,
140
139
  name: "github_assistant",
141
140
  template: :github_assistant,
142
141
  mcp_servers: [github_server]
@@ -164,7 +163,15 @@ begin
164
163
  puts "Query: 'What are the top 3 most starred Ruby web frameworks on GitHub?'"
165
164
  hr
166
165
 
167
- result = robot.run("What are the top 3 most starred Ruby web frameworks on GitHub? Just list their names and star counts.")
166
+ # mcp: :inherit and tools: :inherit are both required. Both keywords default
167
+ # to :none on run(), which resolves to "no MCP servers this turn" and "send
168
+ # zero tools this turn" — the discovered MCP tools would never reach the
169
+ # provider and the model would answer from its training data.
170
+ result = robot.run(
171
+ "What are the top 3 most starred Ruby web frameworks on GitHub? Just list their names and star counts.",
172
+ mcp: :inherit,
173
+ tools: :inherit
174
+ )
168
175
 
169
176
  puts
170
177
  puts "Robot Response:"