robot_lab 0.3.0 → 0.5.0

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +29 -19
  4. data/lib/robot_lab/errors.rb +4 -0
  5. data/lib/robot_lab/hook_context.rb +9 -2
  6. data/lib/robot_lab/mcp/registry.rb +99 -0
  7. data/lib/robot_lab/mcp/server.rb +47 -83
  8. data/lib/robot_lab/mcp/server_discovery.rb +22 -9
  9. data/lib/robot_lab/mcp.rb +259 -0
  10. data/lib/robot_lab/robot/mcp_management.rb +159 -105
  11. data/lib/robot_lab/robot/tool_call_gating.rb +144 -0
  12. data/lib/robot_lab/robot.rb +72 -99
  13. data/lib/robot_lab/run_config.rb +3 -2
  14. data/lib/robot_lab/tool.rb +8 -23
  15. data/lib/robot_lab/version.rb +1 -1
  16. data/lib/robot_lab.rb +1 -4
  17. metadata +16 -292
  18. data/.architecture/AGENTS.md +0 -32
  19. data/.architecture/config.yml +0 -8
  20. data/.architecture/members.yml +0 -60
  21. data/.architecture/reviews/feature-free-will.md +0 -490
  22. data/.architecture/reviews/overall-codebase.md +0 -427
  23. data/.claude/settings.local.json +0 -57
  24. data/.codex/config.toml +0 -2
  25. data/.envrc +0 -6
  26. data/.github/workflows/deploy-github-pages.yml +0 -52
  27. data/.irbrc +0 -6
  28. data/.loki +0 -65
  29. data/.quality/reek_baseline.txt +0 -43
  30. data/.rubocop.yml +0 -10
  31. data/Archspec.rb +0 -44
  32. data/CLAUDE.md +0 -141
  33. data/COMMITS.md +0 -196
  34. data/Rakefile +0 -193
  35. data/_typos.toml +0 -21
  36. data/agent2agent_review.md +0 -192
  37. data/agentf_improvements.md +0 -253
  38. data/agents.md +0 -14
  39. data/docs/api/core/index.md +0 -147
  40. data/docs/api/core/memory.md +0 -578
  41. data/docs/api/core/network.md +0 -553
  42. data/docs/api/core/result.md +0 -211
  43. data/docs/api/core/robot.md +0 -1667
  44. data/docs/api/core/state.md +0 -145
  45. data/docs/api/core/tool.md +0 -520
  46. data/docs/api/errors.md +0 -323
  47. data/docs/api/hooks.md +0 -469
  48. data/docs/api/index.md +0 -137
  49. data/docs/api/mcp/client.md +0 -387
  50. data/docs/api/mcp/index.md +0 -267
  51. data/docs/api/mcp/server.md +0 -252
  52. data/docs/api/mcp/transports.md +0 -339
  53. data/docs/api/messages/index.md +0 -126
  54. data/docs/api/messages/text-message.md +0 -170
  55. data/docs/api/messages/tool-call-message.md +0 -206
  56. data/docs/api/messages/tool-result-message.md +0 -242
  57. data/docs/api/messages/user-message.md +0 -232
  58. data/docs/api/skills.md +0 -423
  59. data/docs/api/streaming/context.md +0 -280
  60. data/docs/api/streaming/events.md +0 -185
  61. data/docs/api/streaming/index.md +0 -156
  62. data/docs/api/support.md +0 -846
  63. data/docs/architecture/core-concepts.md +0 -575
  64. data/docs/architecture/index.md +0 -252
  65. data/docs/architecture/message-flow.md +0 -366
  66. data/docs/architecture/network-orchestration.md +0 -478
  67. data/docs/architecture/robot-execution.md +0 -396
  68. data/docs/architecture/state-management.md +0 -410
  69. data/docs/assets/css/custom.css +0 -56
  70. data/docs/assets/images/robot_lab.jpg +0 -0
  71. data/docs/concepts.md +0 -641
  72. data/docs/examples/basic-chat.md +0 -258
  73. data/docs/examples/index.md +0 -425
  74. data/docs/examples/mcp-server.md +0 -403
  75. data/docs/examples/multi-robot-network.md +0 -476
  76. data/docs/examples/tool-usage.md +0 -468
  77. data/docs/getting-started/configuration.md +0 -645
  78. data/docs/getting-started/index.md +0 -56
  79. data/docs/getting-started/installation.md +0 -197
  80. data/docs/getting-started/quick-start.md +0 -293
  81. data/docs/guides/building-robots.md +0 -1187
  82. data/docs/guides/creating-networks.md +0 -736
  83. data/docs/guides/hooks.md +0 -1206
  84. data/docs/guides/index.md +0 -78
  85. data/docs/guides/knowledge.md +0 -219
  86. data/docs/guides/mcp-integration.md +0 -740
  87. data/docs/guides/memory.md +0 -458
  88. data/docs/guides/observability.md +0 -733
  89. data/docs/guides/streaming.md +0 -323
  90. data/docs/guides/using-tools.md +0 -780
  91. data/docs/index.md +0 -280
  92. data/docs/superpowers/plans/2026-05-06-agentskills.md +0 -1303
  93. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +0 -247
  94. data/examples/.envrc +0 -3
  95. data/examples/01_simple_robot.rb +0 -31
  96. data/examples/02_tools.rb +0 -109
  97. data/examples/03_network.rb +0 -118
  98. data/examples/04_mcp.rb +0 -217
  99. data/examples/05_streaming.rb +0 -137
  100. data/examples/06_prompt_templates.rb +0 -325
  101. data/examples/07_network_memory.rb +0 -313
  102. data/examples/08_llm_config.rb +0 -162
  103. data/examples/09_chaining.rb +0 -251
  104. data/examples/10_memory.rb +0 -315
  105. data/examples/11_network_introspection.rb +0 -239
  106. data/examples/12_message_bus.rb +0 -67
  107. data/examples/13_spawn.rb +0 -85
  108. data/examples/14_rusty_circuit/.envrc +0 -1
  109. data/examples/14_rusty_circuit/.gitignore +0 -1
  110. data/examples/14_rusty_circuit/README.md +0 -1
  111. data/examples/14_rusty_circuit/comic.rb +0 -149
  112. data/examples/14_rusty_circuit/display.rb +0 -217
  113. data/examples/14_rusty_circuit/heckler.rb +0 -65
  114. data/examples/14_rusty_circuit/open_mic.rb +0 -132
  115. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +0 -20
  116. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +0 -23
  117. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +0 -20
  118. data/examples/14_rusty_circuit/scout.rb +0 -165
  119. data/examples/14_rusty_circuit/show.log +0 -234
  120. data/examples/15_memory_network_and_bus/.envrc +0 -1
  121. data/examples/15_memory_network_and_bus/README.md +0 -66
  122. data/examples/15_memory_network_and_bus/editor_in_chief.rb +0 -24
  123. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +0 -213
  124. data/examples/15_memory_network_and_bus/linux_writer.rb +0 -80
  125. data/examples/15_memory_network_and_bus/os_editor.rb +0 -48
  126. data/examples/15_memory_network_and_bus/os_writer.rb +0 -49
  127. data/examples/15_memory_network_and_bus/output/combined_article.md +0 -11
  128. data/examples/15_memory_network_and_bus/output/final_article.md +0 -8
  129. data/examples/15_memory_network_and_bus/output/linux_draft.md +0 -7
  130. data/examples/15_memory_network_and_bus/output/mac_draft.md +0 -7
  131. data/examples/15_memory_network_and_bus/output/memory.json +0 -13
  132. data/examples/15_memory_network_and_bus/output/revision_1.md +0 -29
  133. data/examples/15_memory_network_and_bus/output/revision_2.md +0 -50
  134. data/examples/15_memory_network_and_bus/output/revision_3.md +0 -8
  135. data/examples/15_memory_network_and_bus/output/windows_draft.md +0 -7
  136. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +0 -13
  137. data/examples/15_memory_network_and_bus/prompts/os_chief.md +0 -13
  138. data/examples/15_memory_network_and_bus/prompts/os_editor.md +0 -13
  139. data/examples/16_writers_room/.envrc +0 -1
  140. data/examples/16_writers_room/display.rb +0 -158
  141. data/examples/16_writers_room/output/.gitignore +0 -4
  142. data/examples/16_writers_room/output/README.md +0 -69
  143. data/examples/16_writers_room/output/opus_001.md +0 -263
  144. data/examples/16_writers_room/output/opus_001_notes.log +0 -470
  145. data/examples/16_writers_room/output/opus_002.md +0 -245
  146. data/examples/16_writers_room/output/opus_002_notes.log +0 -546
  147. data/examples/16_writers_room/output/opus_002_screenplay.md +0 -7989
  148. data/examples/16_writers_room/output/opus_002_screenplay_notes.md +0 -993
  149. data/examples/16_writers_room/prompts/screenplay_writer.md +0 -66
  150. data/examples/16_writers_room/prompts/writer.md +0 -37
  151. data/examples/16_writers_room/room.rb +0 -186
  152. data/examples/16_writers_room/tools.rb +0 -173
  153. data/examples/16_writers_room/writer.rb +0 -121
  154. data/examples/16_writers_room/writers_room.rb +0 -256
  155. data/examples/17_skills.rb +0 -234
  156. data/examples/18_rails/.envrc +0 -3
  157. data/examples/18_rails/.gitignore +0 -5
  158. data/examples/18_rails/Gemfile +0 -11
  159. data/examples/18_rails/README.md +0 -67
  160. data/examples/18_rails/Rakefile +0 -4
  161. data/examples/18_rails/app/controllers/application_controller.rb +0 -4
  162. data/examples/18_rails/app/controllers/chat_controller.rb +0 -50
  163. data/examples/18_rails/app/jobs/application_job.rb +0 -4
  164. data/examples/18_rails/app/jobs/robot_run_job.rb +0 -25
  165. data/examples/18_rails/app/models/application_record.rb +0 -5
  166. data/examples/18_rails/app/models/robot_lab_result.rb +0 -36
  167. data/examples/18_rails/app/models/robot_lab_thread.rb +0 -23
  168. data/examples/18_rails/app/robots/chat_robot.rb +0 -25
  169. data/examples/18_rails/app/tools/time_tool.rb +0 -9
  170. data/examples/18_rails/app/views/chat/_user_message.html.erb +0 -1
  171. data/examples/18_rails/app/views/chat/index.html.erb +0 -67
  172. data/examples/18_rails/app/views/layouts/application.html.erb +0 -49
  173. data/examples/18_rails/bin/dev +0 -7
  174. data/examples/18_rails/bin/rails +0 -6
  175. data/examples/18_rails/bin/setup +0 -15
  176. data/examples/18_rails/config/application.rb +0 -33
  177. data/examples/18_rails/config/cable.yml +0 -2
  178. data/examples/18_rails/config/database.yml +0 -5
  179. data/examples/18_rails/config/environment.rb +0 -4
  180. data/examples/18_rails/config/initializers/robot_lab.rb +0 -11
  181. data/examples/18_rails/config/routes.rb +0 -6
  182. data/examples/18_rails/config.ru +0 -4
  183. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +0 -32
  184. data/examples/19_token_tracking.rb +0 -138
  185. data/examples/20_circuit_breaker.rb +0 -147
  186. data/examples/21_learning_loop.rb +0 -181
  187. data/examples/22_context_compression.rb +0 -172
  188. data/examples/23_convergence.rb +0 -133
  189. data/examples/24_structured_delegation.rb +0 -151
  190. data/examples/25_history_search/conversation.jsonl +0 -30
  191. data/examples/25_history_search.rb +0 -133
  192. data/examples/26_document_store/api_versioning_adr.md +0 -52
  193. data/examples/26_document_store/incident_postmortem.md +0 -46
  194. data/examples/26_document_store/postgres_runbook.md +0 -49
  195. data/examples/26_document_store/redis_caching_guide.md +0 -48
  196. data/examples/26_document_store/sidekiq_guide.md +0 -51
  197. data/examples/26_document_store.rb +0 -140
  198. data/examples/27_incident_response/README.md +0 -65
  199. data/examples/27_incident_response/incident_response.rb +0 -261
  200. data/examples/28_mcp_discovery.rb +0 -113
  201. data/examples/29_ractor_tools.rb +0 -240
  202. data/examples/30_ractor_network.rb +0 -252
  203. data/examples/31_launch_assessment.rb +0 -246
  204. data/examples/32_newsletter_reader.rb +0 -193
  205. data/examples/33_stock_generator.rb +0 -80
  206. data/examples/33_stock_predictor.rb +0 -327
  207. data/examples/34_agentskills.rb +0 -76
  208. data/examples/35_hooks.rb +0 -266
  209. data/examples/README.md +0 -550
  210. data/examples/common.rb +0 -180
  211. data/examples/prompts/assistant.md +0 -5
  212. data/examples/prompts/audit_trail.md +0 -8
  213. data/examples/prompts/billing.md +0 -10
  214. data/examples/prompts/classifier.md +0 -7
  215. data/examples/prompts/comedian.md +0 -6
  216. data/examples/prompts/comedy_critic.md +0 -10
  217. data/examples/prompts/configurable.md +0 -9
  218. data/examples/prompts/dispatcher.md +0 -12
  219. data/examples/prompts/entity_extractor.md +0 -14
  220. data/examples/prompts/escalation.md +0 -42
  221. data/examples/prompts/frontmatter_mcp_test.md +0 -9
  222. data/examples/prompts/frontmatter_named_test.md +0 -5
  223. data/examples/prompts/frontmatter_tools_test.md +0 -6
  224. data/examples/prompts/general.md +0 -7
  225. data/examples/prompts/github_assistant.md +0 -14
  226. data/examples/prompts/helper.md +0 -4
  227. data/examples/prompts/incident_responder.md +0 -18
  228. data/examples/prompts/keyword_extractor.md +0 -11
  229. data/examples/prompts/llm_config_demo.md +0 -20
  230. data/examples/prompts/order_support.md +0 -35
  231. data/examples/prompts/os_advocate.md +0 -13
  232. data/examples/prompts/os_chief.md +0 -13
  233. data/examples/prompts/os_editor.md +0 -13
  234. data/examples/prompts/parameterized_main_test.md +0 -6
  235. data/examples/prompts/pii_redactor.md +0 -7
  236. data/examples/prompts/product_support.md +0 -37
  237. data/examples/prompts/runbook_protocol.md +0 -12
  238. data/examples/prompts/sentiment_analyzer.md +0 -12
  239. data/examples/prompts/skill_a_test.md +0 -4
  240. data/examples/prompts/skill_b_test.md +0 -4
  241. data/examples/prompts/skill_config_test.md +0 -5
  242. data/examples/prompts/skill_cycle_a_test.md +0 -6
  243. data/examples/prompts/skill_cycle_b_test.md +0 -6
  244. data/examples/prompts/skill_description_test.md +0 -4
  245. data/examples/prompts/skill_leaf_test.md +0 -4
  246. data/examples/prompts/skill_nested_test.md +0 -6
  247. data/examples/prompts/skill_refs_main_test.md +0 -6
  248. data/examples/prompts/skill_self_ref_test.md +0 -6
  249. data/examples/prompts/skill_with_mcp_test.md +0 -9
  250. data/examples/prompts/skill_with_params_test.md +0 -6
  251. data/examples/prompts/skill_with_robot_name_test.md +0 -5
  252. data/examples/prompts/skill_with_tools_test.md +0 -6
  253. data/examples/prompts/sre_compliance.md +0 -10
  254. data/examples/prompts/structured_output.md +0 -7
  255. data/examples/prompts/synthesizer.md +0 -17
  256. data/examples/prompts/technical.md +0 -10
  257. data/examples/prompts/template_with_skills_test.md +0 -6
  258. data/examples/prompts/triage.md +0 -22
  259. data/examples/ruboruby.md +0 -423
  260. data/examples/run_all.rb +0 -60
  261. data/examples/xyzzy.rb +0 -97
  262. data/lib/robot_lab/mcp/client.rb +0 -223
  263. data/lib/robot_lab/mcp/connection_poller.rb +0 -195
  264. data/lib/robot_lab/mcp/transports/base.rb +0 -64
  265. data/lib/robot_lab/mcp/transports/sse.rb +0 -120
  266. data/lib/robot_lab/mcp/transports/stdio.rb +0 -170
  267. data/lib/robot_lab/mcp/transports/streamable_http.rb +0 -141
  268. data/lib/robot_lab/mcp/transports/websocket.rb +0 -111
  269. data/logfile +0 -8
  270. data/mkdocs.yml +0 -209
  271. data/robot_concurrency.md +0 -38
  272. data/simple_acp_review.md +0 -298
  273. data/temp.md +0 -6
  274. data/tool_manifest_plan.md +0 -155
@@ -1,780 +0,0 @@
1
- # Using Tools
2
-
3
- Tools give robots the ability to interact with external systems. RobotLab supports three approaches: `RubyLLM::Tool` subclasses, `RobotLab::Tool` subclasses (with robot access), and `RobotLab::Tool.create` for dynamic tools.
4
-
5
- ## Defining Tools
6
-
7
- ### RubyLLM::Tool Subclass
8
-
9
- For reusable tools that don't need robot access:
10
-
11
- ```ruby
12
- class GetWeather < RubyLLM::Tool
13
- description "Get current weather for a location"
14
-
15
- param :location, type: :string, desc: "City name or zip code"
16
- param :unit, type: :string, desc: "Temperature unit", required: false
17
-
18
- def execute(location:, unit: "celsius")
19
- WeatherService.current(location, unit: unit)
20
- end
21
- end
22
- ```
23
-
24
- ### RobotLab::Tool Subclass
25
-
26
- For tools that need access to their owning robot (self-modification, spawning, etc.):
27
-
28
- ```ruby
29
- class AdjustEnergy < RobotLab::Tool
30
- description "Adjust the robot's creativity level"
31
-
32
- param :level, type: "number", desc: "Temperature from 0.0 to 1.0"
33
-
34
- def execute(level:)
35
- robot.with_temperature(level)
36
- "Temperature adjusted to #{level}"
37
- end
38
- end
39
- ```
40
-
41
- Pass `robot: self` when constructing:
42
-
43
- ```ruby
44
- class MyRobot < RobotLab::Robot
45
- def initialize
46
- super(
47
- name: "creative_bot",
48
- system_prompt: "You are creative.",
49
- local_tools: [AdjustEnergy.new(robot: self)]
50
- )
51
- end
52
- end
53
- ```
54
-
55
- ### RobotLab::Tool.create (Dynamic Tools)
56
-
57
- For quick, inline tools use the `Tool.create` factory:
58
-
59
- ```ruby
60
- get_time = RobotLab::Tool.create(
61
- name: "get_time",
62
- description: "Get the current time"
63
- ) { |_args| Time.now.to_s }
64
- ```
65
-
66
- With parameters:
67
-
68
- ```ruby
69
- weather_tool = RobotLab::Tool.create(
70
- name: "get_weather",
71
- description: "Get current weather for a location",
72
- parameters: {
73
- type: "object",
74
- properties: {
75
- location: { type: "string", description: "City name" }
76
- },
77
- required: ["location"]
78
- }
79
- ) { |args| WeatherService.current(args[:location]) }
80
- ```
81
-
82
- ## Built-in Tools
83
-
84
- ### AskUser
85
-
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
-
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
-
92
- ```ruby
93
- robot = RobotLab.build(
94
- name: "onboarding",
95
- system_prompt: "Walk the user through project setup. Ask questions to understand their needs."
96
- )
97
- robot.local_tools << RobotLab::AskUser.new(robot: robot)
98
-
99
- robot.run("Help the user set up a new project", tools: :inherit)
100
- ```
101
-
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:
111
-
112
- ```
113
- [onboarding] What programming language will you use?
114
- 1. Ruby
115
- 2. Python
116
- 3. Go
117
- >
118
- ```
119
-
120
- Features:
121
-
122
- - **Open-ended**: just a question, free-text response
123
- - **Multiple choice**: numbered options, user types the number or text
124
- - **Default value**: shown in the prompt, used when user presses Enter
125
-
126
- Because the IO is sourced from the robot, a `StringIO` pair makes the tool
127
- testable without a terminal:
128
-
129
- ```ruby
130
- robot.input = StringIO.new("2\n")
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> "
136
- ```
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
-
147
- See the [AskUser API reference](../api/core/tool.md#built-in-askuser) for full details.
148
-
149
- ## Attaching Tools to Robots
150
-
151
- ### Via Constructor
152
-
153
- Pass tools via the `local_tools:` parameter when building a robot:
154
-
155
- ```ruby
156
- robot = RobotLab.build(
157
- name: "assistant",
158
- system_prompt: "You are a helpful assistant with tool access.",
159
- local_tools: [GetWeather, CalculatorTool]
160
- )
161
- ```
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
-
172
- ### Via Template Front Matter
173
-
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:
175
-
176
- ```markdown title="prompts/weather_bot.md"
177
- ---
178
- description: Weather assistant with forecast tools
179
- tools:
180
- - GetWeather
181
- - GetForecast
182
- ---
183
- You are a weather assistant. Use your tools to look up weather information.
184
- ```
185
-
186
- ```ruby
187
- # Tools are resolved from frontmatter — no local_tools: needed
188
- robot = RobotLab.build(template: :weather_bot)
189
- ```
190
-
191
- Tool classes must be defined and loaded before building the robot. Unresolvable names are skipped with a warning. Constructor `local_tools:` overrides frontmatter `tools:` when provided.
192
-
193
- ### Via Chaining
194
-
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`:
197
-
198
- ```ruby
199
- robot = RobotLab.build(name: "assistant", system_prompt: "...")
200
- robot.with_tools(GetWeather, CalculatorTool)
201
- ```
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
- `Capabilities` (the declaration above) lives in core, but core itself has **no sandboxing
339
- behavior and no built-in limitations** — a script always runs unconfined via a plain
340
- `Open3.capture2e`, with no timeout, unless the optional
341
- [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox) gem is required.
342
- Requiring it installs `RobotLab::Sandbox::Executor` as `RobotLab::ScriptTool.executor`;
343
- confinement still stays **opt-in and off by default** from there — see the
344
- [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section).
345
- When enabled:
346
-
347
- - 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.
348
- - 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.
349
- - 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.
350
- - 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.
351
-
352
- Full API reference: [robot_lab-sandbox](https://github.com/MadBomber/robot_lab-sandbox).
353
-
354
- ## Parameter Types
355
-
356
- Define parameters on `RubyLLM::Tool` subclasses using `param`:
357
-
358
- ### String
359
-
360
- ```ruby
361
- param :name, type: :string, desc: "User's full name"
362
- ```
363
-
364
- ### Integer
365
-
366
- ```ruby
367
- param :count, type: :integer, desc: "Number of results"
368
- ```
369
-
370
- ### Number (Float)
371
-
372
- ```ruby
373
- param :price, type: :number, desc: "Price in dollars"
374
- ```
375
-
376
- ### Boolean
377
-
378
- ```ruby
379
- param :active, type: :boolean, desc: "Whether the user is active"
380
- ```
381
-
382
- ### Array
383
-
384
- ```ruby
385
- param :tags, type: :array, desc: "List of tags"
386
- ```
387
-
388
- ### Enumerated Values
389
-
390
- `param` has **no `enum:` option**. Its full signature is
391
- `param(name, type: "string", desc: nil, description: nil, required: true)` —
392
- anything else raises `ArgumentError: unknown keyword: :enum`. Express the
393
- allowed values in the description instead, and validate in `execute`:
394
-
395
- ```ruby
396
- STATUSES = %w[pending active completed].freeze
397
-
398
- param :status, type: :string, desc: "Order status — one of: pending, active, completed"
399
-
400
- def execute(status:)
401
- return { error: "status must be one of #{STATUSES.join(', ')}" } unless STATUSES.include?(status)
402
- # ...
403
- end
404
- ```
405
-
406
- ### Required vs Optional
407
-
408
- Parameters are required by default. Mark optional with `required: false`:
409
-
410
- ```ruby
411
- param :query, type: :string, desc: "Search query" # required
412
- param :limit, type: :integer, desc: "Max results", required: false # optional
413
- ```
414
-
415
- ## Tool Patterns
416
-
417
- ### Database Lookup
418
-
419
- ```ruby
420
- class FindUser < RubyLLM::Tool
421
- description "Find user by email or ID"
422
-
423
- param :identifier, type: :string, desc: "Email address or user ID"
424
-
425
- def execute(identifier:)
426
- user = User.find_by(id: identifier) || User.find_by(email: identifier)
427
- user ? user.to_h : { error: "User not found" }
428
- end
429
- end
430
- ```
431
-
432
- ### API Integration
433
-
434
- ```ruby
435
- class GetStockPrice < RubyLLM::Tool
436
- description "Get current stock price for a ticker symbol"
437
-
438
- param :symbol, type: :string, desc: "Stock ticker symbol (e.g. AAPL)"
439
-
440
- def execute(symbol:)
441
- response = HTTP.get("https://api.stocks.example/quote/#{symbol}")
442
- JSON.parse(response.body)
443
- rescue HTTP::Error => e
444
- { error: "Failed to fetch stock price: #{e.message}" }
445
- end
446
- end
447
- ```
448
-
449
- ### File Operations
450
-
451
- ```ruby
452
- class ReadFile < RubyLLM::Tool
453
- description "Read contents of a file"
454
-
455
- param :path, type: :string, desc: "Absolute path to the file"
456
-
457
- def execute(path:)
458
- if File.exist?(path) && File.readable?(path)
459
- { content: File.read(path), size: File.size(path) }
460
- else
461
- { error: "File not found or not readable" }
462
- end
463
- end
464
- end
465
- ```
466
-
467
- ### Multi-Step Operations
468
-
469
- ```ruby
470
- class ProcessOrder < RubyLLM::Tool
471
- description "Validate and process a customer order"
472
-
473
- param :order_id, type: :string, desc: "The order ID to process"
474
-
475
- def execute(order_id:)
476
- order = Order.find(order_id)
477
-
478
- # Validate
479
- return { error: "Invalid order" } unless order.valid?
480
-
481
- # Process payment
482
- result = PaymentProcessor.charge(order)
483
- return { error: result[:error] } unless result[:success]
484
-
485
- # Update status
486
- order.update!(status: "paid")
487
-
488
- { success: true, order_id: order.id, amount: order.total }
489
- end
490
- end
491
- ```
492
-
493
- ## Tool Return Values
494
-
495
- ### Structured Data
496
-
497
- Return hashes with consistent structure:
498
-
499
- ```ruby
500
- def execute(user_id:)
501
- user = User.find(user_id)
502
- {
503
- id: user.id,
504
- name: user.name,
505
- email: user.email,
506
- created_at: user.created_at.iso8601
507
- }
508
- end
509
- ```
510
-
511
- ### Simple Values
512
-
513
- ```ruby
514
- def execute(**_)
515
- Time.now.to_s
516
- end
517
- ```
518
-
519
- ### Lists
520
-
521
- ```ruby
522
- def execute(query:)
523
- results = Search.query(query)
524
- results.map { |r| { id: r.id, title: r.title, score: r.score } }
525
- end
526
- ```
527
-
528
- ## Error Handling
529
-
530
- ### Automatic Error Handling
531
-
532
- `RobotLab::Tool` automatically catches `StandardError` exceptions from `execute` and returns a plain-text error string to the LLM. The LLM can then reason about the failure and try an alternative approach — without crashing the run.
533
-
534
- ```ruby
535
- class FetchResource < RobotLab::Tool
536
- description "Fetch a resource from an external API"
537
- param :id, type: :string, desc: "Resource ID"
538
-
539
- def execute(id:)
540
- ExternalAPI.fetch(id)
541
- end
542
- end
543
-
544
- tool = FetchResource.new
545
- result = tool.call({ "id" => "missing" })
546
- # If ExternalAPI.fetch raises, result is:
547
- # => "Error (fetch_resource): connection refused"
548
- ```
549
-
550
- This applies to all `RobotLab::Tool` variants — subclasses, `Tool.create` factory tools, and MCP tools.
551
-
552
- ### RobotLab::ToolError and Retryability
553
-
554
- `RobotLab::ToolError` is handled on a separate path from ordinary
555
- `StandardError`s. Raising one with `retryable: true` appends `" (retryable)"`
556
- to the text the LLM sees, hinting that another attempt may succeed:
557
-
558
- ```ruby
559
- class FetchResource < RobotLab::Tool
560
- description "Fetch a resource from an external API"
561
- param :id, type: :string, desc: "Resource ID"
562
-
563
- def execute(id:)
564
- raise RobotLab::ToolError.new("upstream 503", retryable: true)
565
- end
566
- end
567
-
568
- FetchResource.new.call({ "id" => "1" })
569
- # => "Error (fetch_resource): upstream 503 (retryable)"
570
-
571
- # Without retryable:, no suffix is appended:
572
- # raise RobotLab::ToolError, "bad input"
573
- # => "Error (fetch_resource): bad input"
574
- ```
575
-
576
- > [!WARNING]
577
- > Only the ordinary `StandardError` path writes to the log
578
- > (`RobotLab.config.logger.warn("Tool 'name' error: RuntimeError: …")`).
579
- > A `RobotLab::ToolError` is turned into text for the LLM and logged
580
- > **nowhere** — if you rely on `ToolError` for expected failures, add your own
581
- > logging inside `execute`, or those failures leave no trace outside the
582
- > transcript.
583
-
584
- ### Critical Tools (Opt-Out)
585
-
586
- For tools where you want exceptions to propagate (e.g., a tool whose failure should abort the run), set `raise_on_error` on the class:
587
-
588
- ```ruby
589
- class CriticalPayment < RobotLab::Tool
590
- self.raise_on_error = true
591
-
592
- description "Process a payment"
593
- param :amount, type: :number, desc: "Payment amount"
594
-
595
- def execute(amount:)
596
- PaymentGateway.charge(amount)
597
- end
598
- end
599
- ```
600
-
601
- `raise_on_error` is per-class and defaults to `false`. Setting it on one class does not affect others.
602
-
603
- > [!WARNING]
604
- > `raise_on_error` does **not** walk the inheritance chain. A subclass of a
605
- > class that set it silently reverts to `false`:
606
- >
607
- > ```ruby
608
- > class CriticalBase < RobotLab::Tool
609
- > self.raise_on_error = true
610
- > end
611
- >
612
- > class ChargeCard < CriticalBase; end
613
- >
614
- > CriticalBase.raise_on_error? # => true
615
- > ChargeCard.raise_on_error? # => false <- NOT inherited
616
- > ```
617
- >
618
- > Set `self.raise_on_error = true` on every class that needs it. (This differs
619
- > from `ractor_safe`, below, which *does* consult the superclass.)
620
-
621
- ### Manual Error Handling
622
-
623
- You can still handle specific errors inside `execute` for domain-specific responses:
624
-
625
- ```ruby
626
- class FetchResource < RobotLab::Tool
627
- description "Fetch a resource from an external API"
628
- param :id, type: :string, desc: "Resource ID"
629
-
630
- def execute(id:)
631
- result = ExternalAPI.fetch(id)
632
- { success: true, data: result }
633
- rescue ExternalAPI::NotFound
634
- { success: false, error: "Resource not found", id: id }
635
- rescue ExternalAPI::RateLimited => e
636
- { success: false, error: "Rate limited", retry_after: e.retry_after }
637
- end
638
- # Any other StandardError is still caught by the automatic handler
639
- end
640
- ```
641
-
642
- ## Ractor-Safe Tools
643
-
644
- A tool class can declare itself safe to run inside a Ractor. When it is, and the
645
- [`robot_lab-ractor`](https://github.com/MadBomber/robot_lab-ractor) extension
646
- gem is loaded, `call` dispatches the work to `RobotLab.ractor_pool` instead of
647
- running it inline on the calling thread — giving real CPU parallelism for
648
- compute-bound tools.
649
-
650
- ```ruby
651
- class Fibonacci < RobotLab::Tool
652
- ractor_safe true
653
-
654
- description "Compute the nth Fibonacci number"
655
- param :n, type: :integer, desc: "Which Fibonacci number to compute"
656
-
657
- def execute(n:)
658
- a, b = 0, 1
659
- n.times { a, b = b, a + b }
660
- a
661
- end
662
- end
663
-
664
- Fibonacci.ractor_safe? # => true
665
- ```
666
-
667
- Dispatch to the pool happens only when **all three** hold:
668
-
669
- 1. `ractor_safe?` is true
670
- 2. the class has a resolvable name — anonymous classes (including every tool
671
- built by `Tool.create`) fall back to the inline path
672
- 3. the `robot_lab-ractor` extension is loaded
673
-
674
- Otherwise `execute` runs inline, exactly as an ordinary tool would. Pool size
675
- comes from the `ractor_pool_size` `RunConfig` field (`:auto` when unset).
676
-
677
- > [!WARNING]
678
- > A Ractor-safe tool must be genuinely stateless: it is instantiated **fresh
679
- > inside the Ractor worker** for every call, so `robot`, captured closures, and
680
- > mutable class-level state are not available to it, and the result comes back
681
- > frozen. Do not set `ractor_safe true` on a tool that touches its robot.
682
-
683
- Unlike `raise_on_error`, `ractor_safe` **does** walk the inheritance chain — a
684
- subclass of a Ractor-safe tool is Ractor-safe unless it says otherwise.
685
-
686
- ## Tool Callbacks
687
-
688
- 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:
689
-
690
- ```ruby
691
- robot = RobotLab.build(
692
- name: "assistant",
693
- system_prompt: "...",
694
- local_tools: [GetWeather],
695
- on_tool_call: ->(tool_call) { puts "Calling: #{tool_call.name}" },
696
- on_tool_result: ->(result) { puts "Result: #{result}" }
697
- )
698
-
699
- robot.run("What's the weather in Tokyo?", tools: :inherit)
700
- ```
701
-
702
- See [Streaming](streaming.md#tool-callbacks) for details, including the RubyLLM deprecation warning these emit.
703
-
704
- ## RobotLab::Tool.create with Schema
705
-
706
- For dynamic tools via `Tool.create`, pass parameters as a JSON Schema hash:
707
-
708
- ```ruby
709
- tool = RobotLab::Tool.create(
710
- name: "search",
711
- description: "Search for items",
712
- parameters: {
713
- type: "object",
714
- properties: {
715
- query: { type: "string", description: "Search query" },
716
- limit: { type: "integer", description: "Max results" }
717
- },
718
- required: ["query"]
719
- }
720
- ) { |args| Search.query(args[:query], limit: args[:limit] || 10) }
721
- ```
722
-
723
- ## Best Practices
724
-
725
- ### 1. Clear Descriptions
726
-
727
- Write descriptions that help the LLM understand when and how to use the tool:
728
-
729
- ```ruby
730
- # Good: Specific and actionable
731
- class SearchOrders < RubyLLM::Tool
732
- description "Search customer orders by date range, status, or customer email. Returns up to 50 matching orders sorted by date."
733
- # ...
734
- end
735
-
736
- # Bad: Vague
737
- class Search < RubyLLM::Tool
738
- description "Searches stuff"
739
- # ...
740
- end
741
- ```
742
-
743
- ### 2. Validate Inputs
744
-
745
- ```ruby
746
- def execute(email:)
747
- unless email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
748
- return { error: "Invalid email format" }
749
- end
750
- # ... rest of logic
751
- end
752
- ```
753
-
754
- ### 3. Return Structured Data
755
-
756
- ```ruby
757
- # Good: Structured and consistent
758
- def execute(**_)
759
- {
760
- success: true,
761
- data: { id: 1, name: "Item" },
762
- metadata: { fetched_at: Time.now.iso8601 }
763
- }
764
- end
765
-
766
- # Bad: Unstructured
767
- def execute(**_)
768
- "Found item with id 1 named Item"
769
- end
770
- ```
771
-
772
- ### 4. Keep Tools Focused
773
-
774
- Each tool should do one thing well. Prefer multiple focused tools over one tool that does everything.
775
-
776
- ## Next Steps
777
-
778
- - [MCP Integration](mcp-integration.md) - External tool servers
779
- - [Building Robots](building-robots.md) - Robot creation patterns
780
- - [API Reference: Tool](../api/core/tool.md) - Complete API