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
@@ -13,7 +13,7 @@ These examples show how to use RobotLab for common scenarios, from simple chatbo
13
13
  | [Basic Chat](basic-chat.md) | Simple conversational robot |
14
14
  | [Multi-Robot Network](multi-robot-network.md) | Customer service with routing |
15
15
  | [Tool Usage](tool-usage.md) | External API integration |
16
- | [MCP Server](mcp-server.md) | Creating an MCP tool server |
16
+ | [MCP Server](mcp-server.md) | Connecting a robot to external MCP servers |
17
17
  | [Message Bus](#message-bus) | Bidirectional robot communication with convergence |
18
18
  | [Spawning Robots](#spawning-robots) | Dynamic specialist creation at runtime |
19
19
 
@@ -43,7 +43,8 @@ require "robot_lab"
43
43
  # Configuration is handled automatically via MywayConfig.
44
44
  # Set API keys via environment variables:
45
45
  # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
46
- # Or via config files (~/.config/robot_lab/config.yml)
46
+ # Or via config files (~/.config/robot_lab/robot_lab.yml — the filename
47
+ # repeats the app name; ~/.config/robot_lab/config.yml is never read)
47
48
 
48
49
  robot = RobotLab.build(
49
50
  name: "greeter",
@@ -57,14 +58,25 @@ puts result.last_text_content
57
58
 
58
59
  ## Robot with Tools
59
60
 
61
+ Give the LLM a fixed set of operations rather than an expression evaluator —
62
+ never `eval` a string the model produced.
63
+
60
64
  ```ruby
61
65
  class CalculatorTool < RubyLLM::Tool
62
- description "Perform a calculation"
63
-
64
- param :expression, type: :string, desc: "Math expression to evaluate"
65
-
66
- def execute(expression:)
67
- eval(expression).to_s
66
+ description "Performs basic arithmetic operations"
67
+
68
+ param :operation, type: "string", desc: "add, subtract, multiply, or divide"
69
+ param :a, type: "number", desc: "First operand"
70
+ param :b, type: "number", desc: "Second operand"
71
+
72
+ def execute(operation:, a:, b:)
73
+ case operation
74
+ when "add" then a + b
75
+ when "subtract" then a - b
76
+ when "multiply" then a * b
77
+ when "divide" then a.to_f / b
78
+ else "Unknown operation: #{operation}"
79
+ end
68
80
  end
69
81
  end
70
82
 
@@ -74,41 +86,85 @@ robot = RobotLab.build(
74
86
  local_tools: [CalculatorTool]
75
87
  )
76
88
 
77
- result = robot.run("What's 25 * 4?")
89
+ # tools: :inherit is REQUIRED — run() defaults to tools: :none
90
+ result = robot.run("What's 25 * 4?", tools: :inherit)
78
91
  puts result.last_text_content
79
92
  ```
80
93
 
94
+ > [!WARNING]
95
+ > `Robot#run` defaults to `tools: :none` and `mcp: :none`. A plain
96
+ > `robot.run("...")` sends the LLM **no tools at all**, even when `local_tools:`
97
+ > were attached at build time. Pass `tools: :inherit` on the call that should be
98
+ > able to use them.
99
+ >
100
+ > For a **standalone** robot like this one, do not pass `tools: :inherit` at
101
+ > *build* time — there the parent level is the global default (`:none`), so it
102
+ > produces an allowlist that matches nothing. Leave `tools:` unset in the
103
+ > constructor. (Inside a network the opposite holds: build-time `:inherit` is how a
104
+ > robot opts into the allowlist on the network's `config:`. See
105
+ > [Configuration](../getting-started/configuration.md#hierarchical-mcp-and-tools).)
106
+
107
+ See [`examples/02_tools.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/02_tools.rb)
108
+ for a second tool definition (`FortuneCookie`) alongside the calculator.
109
+
81
110
  ## Network with Routing
82
111
 
112
+ Routing is not configured declaratively — a robot performs it. Subclass
113
+ `RobotLab::Robot`, override `#call`, and activate one of the `depends_on: :optional`
114
+ tasks based on what the LLM returned.
115
+
83
116
  ```ruby
84
- classifier = RobotLab.build(
85
- name: "classifier",
86
- system_prompt: "Classify the request as BILLING or TECHNICAL. Respond with only the category."
87
- )
117
+ class ClassifierRobot < RobotLab::Robot
118
+ def call(result)
119
+ run_context = extract_run_context(result)
120
+ message = run_context.delete(:message)
121
+ robot_result = run(message, **run_context)
122
+
123
+ new_result = result
124
+ .with_context(@name.to_sym, robot_result)
125
+ .continue(robot_result)
126
+
127
+ case robot_result.reply.to_s.strip.downcase
128
+ when /billing/ then new_result.activate(:billing)
129
+ when /technical/ then new_result.activate(:technical)
130
+ else new_result.activate(:general)
131
+ end
132
+ end
133
+ end
88
134
 
89
- billing = RobotLab.build(
90
- name: "billing",
91
- system_prompt: "You handle billing questions."
135
+ classifier = ClassifierRobot.new(
136
+ name: "classifier",
137
+ system_prompt: "Classify the request as BILLING, TECHNICAL, or GENERAL. Respond with only the category."
92
138
  )
93
139
 
94
- tech = RobotLab.build(
95
- name: "tech",
96
- system_prompt: "You handle technical issues."
97
- )
140
+ billing = RobotLab.build(name: "billing", system_prompt: "You handle billing questions.")
141
+ tech = RobotLab.build(name: "technical", system_prompt: "You handle technical issues.")
142
+ general = RobotLab.build(name: "general", system_prompt: "You handle everything else.")
98
143
 
99
144
  network = RobotLab.create_network(name: "support") do
100
145
  task :classifier, classifier, depends_on: :none
101
- task :billing, billing, depends_on: :optional
102
- task :tech, tech, depends_on: :optional
146
+ task :billing, billing, depends_on: :optional
147
+ task :technical, tech, depends_on: :optional
148
+ task :general, general, depends_on: :optional
103
149
  end
104
150
 
105
151
  result = network.run(message: "I was charged twice for my subscription")
106
152
 
107
153
  # Access individual robot results via context
108
- classifier_result = result.context[:classifier]
109
- puts classifier_result.last_text_content
154
+ puts result.context[:classifier].last_text_content
155
+ puts result.value.last_text_content # the specialist that was activated
110
156
  ```
111
157
 
158
+ > [!WARNING]
159
+ > `result.context` is keyed by the **robot's** name (`with_context(@name.to_sym, ...)`),
160
+ > not the task name, and `activate(:name)` takes a **task** name. Keep the two
161
+ > identical — activating a task name that was never declared **raises
162
+ > `ArgumentError` and aborts the run** (`Step :classifier attempted to activate
163
+ > unknown step :billing`). The same happens for a task that was declared without
164
+ > `depends_on: :optional`.
165
+
166
+ Full version: [`examples/03_network.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/03_network.rb).
167
+
112
168
  ## Chaining Configuration
113
169
 
114
170
  Robots support `with_*` methods that return `self` for chaining:
@@ -123,17 +179,27 @@ result = robot.run("Explain Ruby blocks.")
123
179
  puts result.last_text_content
124
180
  ```
125
181
 
182
+ > [!NOTE]
183
+ > The chainable set is exactly what `RubyLLM::Chat` exposes — `with_context`,
184
+ > `with_headers`, `with_instructions`, `with_model`, `with_params`, `with_schema`,
185
+ > `with_temperature`, `with_thinking`, `with_tool`, `with_tools` — plus RobotLab's
186
+ > `with_template` and `with_bus`. There is no `with_max_tokens`, `with_top_p`, or
187
+ > `with_top_k`; use a constructor kwarg or `with_params(max_tokens: 2000)`.
188
+
126
189
  ## Using Templates
127
190
 
128
- Templates are `.md` files with optional YAML front matter, managed by prompt_manager:
191
+ Templates are `.md` files with optional YAML front matter, managed by prompt_manager.
192
+ The body is rendered with **ERB** — interpolate with `<%= var %>`.
129
193
 
130
194
  ```ruby
131
195
  # Template file: prompts/support.md
132
196
  # ---
133
197
  # model: claude-sonnet-4
134
198
  # temperature: 0.5
199
+ # parameters:
200
+ # company_name: null
135
201
  # ---
136
- # You are a support assistant for {{ company_name }}.
202
+ # You are a support assistant for <%= company_name %>.
137
203
 
138
204
  robot = RobotLab.build(
139
205
  name: "support",
@@ -145,6 +211,13 @@ result = robot.run("How do I reset my password?")
145
211
  puts result.last_text_content
146
212
  ```
147
213
 
214
+ > [!NOTE]
215
+ > `{{ var }}` is **not** interpolated — it passes through to the LLM verbatim.
216
+ > Of the LLM keys accepted in front matter, only `model:` and `temperature:` are
217
+ > actually applied to the chat. `top_p`, `top_k`, `max_tokens`, `presence_penalty`,
218
+ > `frequency_penalty`, and `stop` are parsed and then silently dropped; supply
219
+ > those as constructor kwargs or via a `RunConfig` instead.
220
+
148
221
  ## Running Examples
149
222
 
150
223
  1. Install dependencies:
@@ -171,12 +244,16 @@ bundle exec rake examples:run[1] # Run specific example by number
171
244
 
172
245
  ## Shared Example Setup (`examples/common.rb`)
173
246
 
174
- All numbered examples (`01_*.rb` through `34_*.rb`) begin with:
247
+ Most numbered examples (`01_*.rb` through `35_*.rb`) pull in a shared setup file:
175
248
 
176
249
  ```ruby
177
250
  require_relative "common"
178
251
  ```
179
252
 
253
+ The line sits below each example's header comment rather than at the very top of
254
+ the file, and three examples do without it entirely —
255
+ `32_newsletter_reader.rb`, `33_stock_generator.rb`, and `33_stock_predictor.rb`.
256
+
180
257
  `common.rb` handles the shared boilerplate so individual examples stay focused:
181
258
 
182
259
  - **`LLM` hash** — frozen lookup of provider/model pairs accessible as `LLM[:default]`, `LLM[:local]`, `LLM[:anthropic]`. Each entry is a `LlmConfig = Data.define(:provider, :model)` value, so you access the model string as `LLM[:default].model`.
@@ -224,7 +301,7 @@ class Comedian < RobotLab::Robot
224
301
  @attempts += 1
225
302
  temp = [TEMP_START + TEMP_STEP * (@attempts - 1), 1.0].min
226
303
  with_temperature(temp)
227
- joke = run(message.content.to_s).last_text_content.strip
304
+ joke = run(message.content.to_s).reply.strip
228
305
  send_reply(to: message.from.to_sym, content: joke, in_reply_to: message.key)
229
306
  end
230
307
  end
@@ -236,10 +313,14 @@ class ComedyCritic < RobotLab::Robot
236
313
  def initialize(bus:)
237
314
  super(name: "alice", template: :comedy_critic, bus: bus)
238
315
  @accepted = false
316
+ @rounds = 0
239
317
  on_message do |message|
240
- verdict = run("Evaluate this joke:\n\n#{message.content}").last_text_content.strip
318
+ @rounds += 1
319
+ verdict = run("Evaluate this joke:\n\n#{message.content}").reply.strip
241
320
  @accepted = verdict.start_with?("FUNNY")
242
- send_message(to: :bob, content: "Not funny enough. Try again.") unless @accepted
321
+ # The @rounds guard is what terminates the loop — without it the critic
322
+ # keeps sending Bob back forever.
323
+ send_message(to: :bob, content: "Not funny enough. Try again.") unless @accepted || @rounds >= MAX_ATTEMPTS
243
324
  end
244
325
  end
245
326
 
@@ -261,7 +342,12 @@ Key patterns demonstrated:
261
342
  - **Auto-ack** via 1-arg `on_message` blocks
262
343
  - **`send_reply(to:, content:, in_reply_to:)`** for correlated responses
263
344
  - **Temperature ramping** (0.2 &rarr; 1.0) for increasing creativity
264
- - **Convergence loop** that terminates when the critic approves
345
+ - **Convergence loop** that terminates when the critic approves *or* `MAX_ATTEMPTS` is reached
346
+
347
+ > [!WARNING]
348
+ > `MAX_ATTEMPTS` only bounds the loop because the critic checks it before sending
349
+ > Bob back. Declaring the constant without testing it leaves the two robots
350
+ > messaging each other indefinitely.
265
351
 
266
352
  Run: `bundle exec ruby examples/12_message_bus.rb`
267
353
 
@@ -6,13 +6,36 @@ Connecting robots to Model Context Protocol servers for external tool access.
6
6
 
7
7
  This example demonstrates how to connect robots to external MCP servers. MCP servers expose tools that robots can discover and invoke automatically. RobotLab supports stdio, HTTP, WebSocket, and SSE transports.
8
8
 
9
+ The runnable version of everything below is
10
+ [`examples/04_mcp.rb`](https://github.com/MadBomber/robot_lab/blob/main/examples/04_mcp.rb)
11
+ (direct `MCP::Client` usage in Part 1, robot integration in Part 2).
12
+
13
+ > [!WARNING]
14
+ > **Two things are required to actually use MCP tools in a run.** `Robot#run`
15
+ > defaults to `mcp: :none, tools: :none`, so build-time `mcp:` servers are never
16
+ > connected by a plain `robot.run("...")`. Pass both:
17
+ >
18
+ > ```ruby
19
+ > robot.run("...", mcp: :inherit, tools: :inherit)
20
+ > ```
21
+ >
22
+ > `mcp: :inherit` triggers the connection attempt; `tools: :inherit` is what sends
23
+ > the discovered tools to the LLM.
24
+
25
+ > [!WARNING]
26
+ > `transport:` must be a **nested hash**. A flat `transport: stdio` with sibling
27
+ > `command:` / `args:` keys raises
28
+ > `NoMethodError: undefined method 'transform_keys' for an instance of String`
29
+ > internally — and that error is swallowed, so the robot silently builds with zero
30
+ > MCP tools. Connection failures are logged and recorded in
31
+ > `robot.failed_mcp_server_names`; they are never raised.
32
+
9
33
  ## Using MCP with a Robot
10
34
 
11
35
  The primary pattern is to pass MCP server configurations via `mcp:` or `mcp_servers:` when building a robot:
12
36
 
13
37
  ```ruby
14
38
  #!/usr/bin/env ruby
15
- # examples/mcp_client.rb
16
39
 
17
40
  require "bundler/setup"
18
41
  require "robot_lab"
@@ -42,27 +65,34 @@ robot = RobotLab.build(
42
65
  model: "claude-sonnet-4"
43
66
  )
44
67
 
45
- # The robot auto-discovers MCP tools on first run
68
+ # MCP clients are created lazily. connect_mcp! forces the connection now so the
69
+ # counts below are meaningful -- without it both lines print empty results.
70
+ robot.connect_mcp!
71
+
46
72
  puts "MCP Servers: #{robot.mcp_clients.keys.join(", ")}"
47
73
  puts "MCP Tools: #{robot.mcp_tools.size} discovered"
74
+ puts "Failed servers: #{robot.failed_mcp_server_names.join(", ")}" if robot.failed_mcp_server_names.any?
48
75
 
49
- # Run the robot -- it can use any discovered MCP tools
50
- result = robot.run("What are the top 3 most starred Ruby web frameworks on GitHub?")
76
+ # Run the robot -- both kwargs are required for it to reach the MCP tools
77
+ result = robot.run(
78
+ "What are the top 3 most starred Ruby web frameworks on GitHub?",
79
+ mcp: :inherit,
80
+ tools: :inherit
81
+ )
51
82
  puts result.last_text_content
52
83
 
53
- # Show tool calls if any were made
54
- if result.tool_calls.any?
55
- puts "\nTool calls made:"
56
- result.tool_calls.each do |tc|
57
- tool_info = tc.respond_to?(:tool) ? tc.tool : tc
58
- puts " #{tool_info[:name] || tool_info}"
59
- end
60
- end
84
+ # Confirm which tools were sent this turn
85
+ puts "Tools sent: #{robot.chat.tools.size}"
61
86
 
62
87
  # Always disconnect MCP clients when done
63
88
  robot.disconnect
64
89
  ```
65
90
 
91
+ > [!NOTE]
92
+ > `result.tool_calls` is effectively always empty — it reads the final assistant
93
+ > message, which contains only text once ruby_llm's tool loop has finished. Use the
94
+ > `on_tool_call:` callback or the Hook system to observe MCP tool invocations.
95
+
66
96
  ## Direct MCP Client Usage
67
97
 
68
98
  You can also use the MCP client directly without a robot:
@@ -81,6 +111,8 @@ github_server = {
81
111
  }
82
112
  }
83
113
 
114
+ # NOTE: the config is a single POSITIONAL argument.
115
+ # MCP::Client.new(name: ..., transport: ...) raises ArgumentError.
84
116
  client = RobotLab::MCP::Client.new(github_server)
85
117
  client.connect
86
118
 
@@ -134,7 +166,11 @@ robot = RobotLab.build(
134
166
  model: "claude-sonnet-4"
135
167
  )
136
168
 
137
- result = robot.run("Search for Ruby repos with CI configs and list their workflow files")
169
+ result = robot.run(
170
+ "Search for Ruby repos with CI configs and list their workflow files",
171
+ mcp: :inherit,
172
+ tools: :inherit
173
+ )
138
174
  puts result.last_text_content
139
175
 
140
176
  robot.disconnect
@@ -142,41 +178,77 @@ robot.disconnect
142
178
 
143
179
  ## MCP in Networks
144
180
 
145
- Networks pass MCP configuration through the hierarchical resolution system. Use `mcp: :inherit` on robots to use the network-level MCP config, or specify per-task MCP servers:
181
+ `mcp` and `tools` are the only two fields a network passes down to its member
182
+ robots. Declare them **per task** — the `task` DSL is what reliably reaches the
183
+ robot's `run` call.
146
184
 
147
185
  ```ruby
148
- # Create robots
186
+ # Create robots -- leave mcp:/tools: unset in the constructor
149
187
  data_analyst = RobotLab.build(
150
188
  name: "data_analyst",
151
- system_prompt: "You analyze data.",
152
- mcp: :inherit # Will use whatever MCP config is resolved at runtime
189
+ system_prompt: "You analyze data."
153
190
  )
154
191
 
155
192
  file_manager = RobotLab.build(
156
193
  name: "file_manager",
157
- system_prompt: "You manage files.",
158
- mcp: :inherit,
159
- tools: :none # Only use inherited MCP tools, no local tools
194
+ system_prompt: "You manage files."
160
195
  )
161
196
 
162
- # Create network with per-task MCP configuration
197
+ # Per-task MCP + tools configuration
163
198
  network = RobotLab.create_network(name: "support_with_mcp") do
164
- task :analyst, data_analyst,
199
+ task :data_analyst, data_analyst,
165
200
  mcp: [github_server],
201
+ tools: :inherit, # send everything discovered
166
202
  depends_on: :none
167
203
 
168
- task :files, file_manager,
204
+ task :file_manager, file_manager,
169
205
  mcp: [filesystem_server],
170
- tools: %w[read_file list_directory], # Whitelist only these MCP tools
206
+ tools: %w[read_file list_directory], # allowlist only these tool names
171
207
  depends_on: :optional
172
208
  end
173
209
 
174
210
  result = network.run(message: "Analyze the project structure")
175
211
  ```
176
212
 
213
+ > [!WARNING]
214
+ > Build-time `:inherit` cuts both ways, so scope it deliberately.
215
+ >
216
+ > For a **standalone** robot it is a trap: `:inherit` resolves against the global
217
+ > level (`:none`), yielding an allowlist of `["none"]` that matches nothing, so the
218
+ > robot sends no tools no matter what you pass at run time.
219
+ >
220
+ > Inside a **network**, build-time `:inherit` is exactly how a robot opts into the
221
+ > network's `config:` list — the parent is resolved at run time from
222
+ > `network_config`. Verified with a network `config:` of `tools: %w[ReadFile]` on a
223
+ > robot holding `ReadFile` and `ListDir`:
224
+ >
225
+ > | Robot constructor | Task line | Tools sent |
226
+ > |---|---|---|
227
+ > | `tools: :inherit` | `tools: :inherit` | `[:read_file]` — network allowlist applied |
228
+ > | (unset) | `tools: :inherit` | `[:read_file, :list_dir]` — allowlist ignored |
229
+ >
230
+ > A network-level `config:` also propagates *only* `mcp` and `tools`. LLM fields
231
+ > (`model`, `temperature`, `max_tokens`, ...) and callbacks (`on_content`) are read
232
+ > from each robot's own config at construction time and are never inherited from
233
+ > the network.
234
+
235
+ ## Transport Types
236
+
237
+ The `type:` field must be one of `stdio`, `sse`, `ws`, `websocket`,
238
+ `streamable-http`, or `http`. Anything else raises `ArgumentError` — note that
239
+ `streamable_http` with an underscore is **not** valid.
240
+
241
+ `timeout:` sits alongside `transport:` on the server config and defaults to 15
242
+ seconds. It is normalized: `nil` becomes 15, any value of 1000 or more is treated
243
+ as milliseconds (5000 → 5.0s), and anything under 1 second is floored to 1.
244
+
245
+ > [!NOTE]
246
+ > Only the stdio transport actually enforces `timeout`. The SSE, WebSocket, and
247
+ > StreamableHTTP transports store the value and never reference it.
248
+
177
249
  ## HTTP Transport
178
250
 
179
- Connect to remote MCP servers over HTTP:
251
+ Connect to remote MCP servers over HTTP. Requires the `async-http` gem.
180
252
 
181
253
  ```ruby
182
254
  robot = RobotLab.build(
@@ -185,22 +257,36 @@ robot = RobotLab.build(
185
257
  mcp: [
186
258
  {
187
259
  name: "remote_api",
260
+ timeout: 30,
188
261
  transport: {
189
262
  type: "http",
190
263
  url: "https://mcp.example.com/mcp",
191
- headers: { "Authorization" => "Bearer #{ENV['MCP_TOKEN']}" }
264
+ # Auth is supplied by a callable, not a headers hash
265
+ auth_provider: -> { "Bearer #{ENV['MCP_TOKEN']}" }
192
266
  }
193
267
  }
194
268
  ]
195
269
  )
196
270
 
197
- result = robot.run("Use the remote tools to check system status")
271
+ result = robot.run("Use the remote tools to check system status", mcp: :inherit, tools: :inherit)
198
272
  robot.disconnect
199
273
  ```
200
274
 
275
+ > [!WARNING]
276
+ > A `headers:` key in a streamable-http transport config is **silently discarded**.
277
+ > The transport builds its own header hash on every request and only merges in
278
+ > `Authorization` when `auth_provider` is set. Use `auth_provider:` (a proc
279
+ > returning the full header value) for authentication.
280
+
281
+ > [!WARNING]
282
+ > The HTTP and SSE transports set `@connected = true` *before* the MCP
283
+ > initialization handshake completes, so `connected?` returns `true` even against
284
+ > an unreachable host. Treat it as "a connect was attempted", not "the server
285
+ > answered", and check `robot.failed_mcp_server_names` as well.
286
+
201
287
  ## WebSocket Transport
202
288
 
203
- For real-time bidirectional communication:
289
+ For real-time bidirectional communication. Requires the `async-websocket` gem.
204
290
 
205
291
  ```ruby
206
292
  robot = RobotLab.build(
@@ -217,13 +303,18 @@ robot = RobotLab.build(
217
303
  ]
218
304
  )
219
305
 
220
- result = robot.run("Subscribe to the events channel")
306
+ result = robot.run("Subscribe to the events channel", mcp: :inherit, tools: :inherit)
221
307
  robot.disconnect
222
308
  ```
223
309
 
310
+ > [!CAUTION]
311
+ > The WebSocket transport currently raises `NameError` on
312
+ > `Async::HTTP::Endpoint` inside an un-awaited `Async` block. It is not usable as
313
+ > shipped — prefer stdio or HTTP.
314
+
224
315
  ## SSE Transport
225
316
 
226
- Server-Sent Events transport for streaming responses:
317
+ Server-Sent Events transport. Requires the `async-http` gem.
227
318
 
228
319
  ```ruby
229
320
  robot = RobotLab.build(
@@ -240,13 +331,13 @@ robot = RobotLab.build(
240
331
  ]
241
332
  )
242
333
 
243
- result = robot.run("Stream the latest metrics")
334
+ result = robot.run("Stream the latest metrics", mcp: :inherit, tools: :inherit)
244
335
  robot.disconnect
245
336
  ```
246
337
 
247
- ## Runtime MCP Overrides
338
+ ## Runtime MCP Selection
248
339
 
249
- Override MCP configuration at runtime via `robot.run`:
340
+ `mcp:` and `tools:` on `robot.run` decide, per call, what the LLM sees:
250
341
 
251
342
  ```ruby
252
343
  robot = RobotLab.build(
@@ -255,15 +346,28 @@ robot = RobotLab.build(
255
346
  mcp: [github_server]
256
347
  )
257
348
 
258
- # Use default MCP config
259
- result = robot.run("Search for Ruby repos")
349
+ # Connect the configured MCP servers and send their tools
350
+ result = robot.run("Search for Ruby repos", mcp: :inherit, tools: :inherit)
260
351
 
261
- # Override MCP at runtime -- disable all MCP
262
- result = robot.run("Just answer from your knowledge", mcp: :none)
352
+ # The DEFAULT: no MCP connection, no tools sent. Writing these out is
353
+ # redundant -- omitting both kwargs does exactly the same thing.
354
+ result = robot.run("Just answer from your knowledge")
355
+
356
+ # Connect MCP but send only two named tools
357
+ result = robot.run(
358
+ "List the open issues",
359
+ mcp: :inherit,
360
+ tools: %w[list_issues get_issue]
361
+ )
263
362
 
264
363
  robot.disconnect
265
364
  ```
266
365
 
366
+ > [!NOTE]
367
+ > `tools:` is a **name allowlist** across every available tool — local and MCP
368
+ > alike. `tools: :none` means "zero tools of any kind", not "MCP only"; there is no
369
+ > switch that selects local tools versus MCP tools.
370
+
267
371
  ## Running
268
372
 
269
373
  ```bash
@@ -274,18 +378,23 @@ export GITHUB_PERSONAL_ACCESS_TOKEN="your-token"
274
378
  # Install MCP server (example: GitHub)
275
379
  brew install github-mcp-server
276
380
 
277
- # Run client
278
- ruby examples/mcp_client.rb
381
+ # Direct MCP::Client usage + robot integration
382
+ ruby examples/04_mcp.rb
383
+
384
+ # Semantic MCP server selection (no LLM calls)
385
+ ruby examples/28_mcp_discovery.rb
279
386
  ```
280
387
 
281
388
  ## Key Concepts
282
389
 
283
- 1. **MCP Configuration**: Pass server configs via `mcp:` parameter on `RobotLab.build` or `Robot.new`
284
- 2. **Auto-Discovery**: Tools are automatically discovered when the robot connects to an MCP server
285
- 3. **Transport Types**: stdio, http, websocket, sse
286
- 4. **Hierarchical Config**: `runtime > robot > network > global`, using `:none`, `:inherit`, or explicit arrays
287
- 5. **Tool Filtering**: Use `tools:` whitelist to limit which MCP tools are available
288
- 6. **Cleanup**: Always call `robot.disconnect` when done to release MCP connections
390
+ 1. **MCP Configuration**: Pass server configs via `mcp:` / `mcp_servers:` on `RobotLab.build` or `Robot.new`. `transport:` must be a nested hash
391
+ 2. **Nothing is sent by default**: `run` defaults to `mcp: :none, tools: :none`. Pass `mcp: :inherit, tools: :inherit` to connect and send
392
+ 3. **Lazy connection**: MCP clients are created on the first qualifying `run`. Call `robot.connect_mcp!` to connect eagerly — before that, `mcp_clients` and `mcp_tools` are empty
393
+ 4. **Failures are silent**: connection errors are logged and recorded in `robot.failed_mcp_server_names`, never raised
394
+ 5. **Transport Types**: `stdio`, `sse`, `ws`, `websocket`, `streamable-http`, `http` (`streamable_http` is invalid)
395
+ 6. **Tool Filtering**: `tools:` is an allowlist over local *and* MCP tools combined; entries must match how each tool was attached (class-attached → `"ReadFile"`, instance-attached → `"read_file"`)
396
+ 7. **Networks**: an explicit list on the `task` line applies directly; to inherit the network's `config:` list instead, build the robot with `tools: :inherit` *and* pass `tools: :inherit` on the task
397
+ 8. **Cleanup**: Always call `robot.disconnect` when done to release MCP connections
289
398
 
290
399
  ## See Also
291
400