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
data/docs/guides/index.md CHANGED
@@ -42,6 +42,10 @@ If you're new to RobotLab, start here:
42
42
 
43
43
  Token tracking, circuit breakers, doom loop detection, auto compaction, and learning accumulation
44
44
 
45
+ - [:octicons-zap-24: **Hook System**](hooks.md)
46
+
47
+ Lifecycle hooks for instrumentation, response caching, extensions, and cross-cutting concerns
48
+
45
49
  - [:octicons-search-24: **Knowledge & Retrieval**](knowledge.md)
46
50
 
47
51
  Chat history search and embedding-based document store for RAG workflows
@@ -59,6 +63,7 @@ If you're new to RobotLab, start here:
59
63
  | [Streaming](streaming.md) | Real-time responses | 5 min |
60
64
  | [Memory](memory.md) | Shared data store | 5 min |
61
65
  | [Observability & Safety](observability.md) | Token tracking, circuit breaker, doom loop detection, auto compaction, learning loop | 10 min |
66
+ | [Hook System](hooks.md) | Lifecycle hooks, extensions, response caching, instrumentation | 15 min |
62
67
  | [Knowledge & Retrieval](knowledge.md) | Chat history search and embedding-based document store (RAG) | 10 min |
63
68
 
64
69
  ## Extension Gems
@@ -21,6 +21,18 @@ results = robot.search_history(query, limit: 5)
21
21
 
22
22
  Scores every message in the robot's conversation history against `query` using stemmed term-frequency cosine similarity (via the `classifier` gem). Returns up to `limit` `HistoryResult` objects sorted by score descending.
23
23
 
24
+ > [!NOTE]
25
+ > **Every** message in `@chat.messages` is scored — there is no role filter.
26
+ > Results routinely include the `:system` prompt and raw `:tool` result payloads
27
+ > (often JSON) alongside `:user` and `:assistant` turns. Filter by role yourself
28
+ > if you only want the conversation:
29
+ >
30
+ > ```ruby
31
+ > hits = robot.search_history(query, limit: 20)
32
+ > .select { |r| %i[user assistant].include?(r.role) }
33
+ > .first(3)
34
+ > ```
35
+
24
36
  ```ruby
25
37
  results = robot.search_history("quarterly revenue", limit: 3)
26
38
 
@@ -35,7 +47,7 @@ end
35
47
  | Field | Type | Description |
36
48
  |-------|------|-------------|
37
49
  | `text` | String | The message text |
38
- | `role` | Symbol | `:user`, `:assistant`, or `:system` |
50
+ | `role` | Symbol | The message's role — `:user`, `:assistant`, `:system`, or `:tool` |
39
51
  | `score` | Float (0.0–1.0) | Cosine similarity with the query |
40
52
  | `index` | Integer | Position in `@chat.messages` |
41
53
 
@@ -89,7 +101,7 @@ Without it, calling `search_history` raises `RobotLab::DependencyError` with an
89
101
 
90
102
  ### The Problem
91
103
 
92
- Sometimes the knowledge you need isn't in the conversation history — it's in a README, a product spec, a changelog. `store_document` / `search_documents` embed arbitrary text with `fastembed` and retrieve the most relevant chunk at query time.
104
+ Sometimes the knowledge you need isn't in the conversation history — it's in a README, a product spec, a changelog. `store_document` / `search_documents` embed arbitrary text and retrieve the most relevant chunk at query time. Embeddings come from `fastembed` when it is installed, and from a built-in TF-IDF fallback when it is not (see [Embedding Model](#embedding-model)).
93
105
 
94
106
  ### memory.store_document / memory.search_documents
95
107
 
@@ -136,7 +148,7 @@ store.clear
136
148
 
137
149
  Default: `BAAI/bge-small-en-v1.5` (~23 MB, downloaded on first use, cached in `~/.cache/fastembed/`).
138
150
 
139
- Documents are embedded with a `"passage: "` prefix and queries with `"query: "` prefix — the standard retrieval convention for BGE models.
151
+ Documents and queries are embedded asymmetrically: stored text goes through `Fastembed::TextEmbedding#passage_embed`, query text through `#query_embed`. RobotLab does **not** prepend any literal `"passage: "` / `"query: "` string of its own it hands the raw text to the two methods and lets fastembed handle whatever instruction prefixing the model expects.
140
152
 
141
153
  Custom model:
142
154
 
@@ -144,6 +156,23 @@ Custom model:
144
156
  store = RobotLab::DocumentStore.new(model_name: "BAAI/bge-base-en-v1.5")
145
157
  ```
146
158
 
159
+ ### TF-IDF Fallback
160
+
161
+ `DocumentStore` decides once, at load time, whether `fastembed` is available
162
+ (`RobotLab::DocumentStore::FASTEMBED_AVAILABLE`). When it is **not**, the store
163
+ still works — it silently falls back to a stemmed, stop-word-filtered,
164
+ L2-normalised term-frequency embedder and compares those sparse vectors by
165
+ cosine similarity. Nothing is downloaded and everything runs offline, which
166
+ makes it convenient for development and CI.
167
+
168
+ > [!WARNING]
169
+ > The fallback is **lexical, not semantic**. It matches shared word stems only,
170
+ > so a query and a document that mean the same thing in different words score
171
+ > `0.0`. There is no error, no warning, and no change to the API — only
172
+ > noticeably worse results. Check `RobotLab::DocumentStore::FASTEMBED_AVAILABLE`
173
+ > if you need to know which path you are on, and note that `model_name:` is
174
+ > ignored entirely when it is `false`.
175
+
147
176
  ### RAG Pattern
148
177
 
149
178
  ```ruby
@@ -177,12 +206,14 @@ The embedding-based document store requires the [`robot_lab-document_store`](htt
177
206
  gem "robot_lab-document_store"
178
207
  ```
179
208
 
180
- This gem bundles `fastembed` for ONNX-based embeddings. The `BAAI/bge-small-en-v1.5` model (~23 MB) is downloaded on first use and cached in `~/.cache/fastembed/`. Without `robot_lab-document_store` loaded, calling `memory.store_document` or `memory.search_documents` raises `RobotLab::DependencyError`.
209
+ The gem uses `fastembed` for ONNX-based embeddings when it is installed: the `BAAI/bge-small-en-v1.5` model (~23 MB) is downloaded on first use and cached in `~/.cache/fastembed/`. If `fastembed` cannot be loaded, `DocumentStore` still works via the lexical [TF-IDF fallback](#tf-idf-fallback).
210
+
211
+ Without `robot_lab-document_store` itself loaded, calling `memory.store_document`, `memory.search_documents`, `memory.document_keys`, or `memory.delete_document` raises `RobotLab::DependencyError`.
181
212
 
182
213
  ---
183
214
 
184
215
  ## See Also
185
216
 
186
217
  - [Observability Guide](observability.md)
187
- - [Example 25 — Chat History Search](../../examples/25_history_search.rb)
188
- - [Example 26 — Embedding-Based Document Store](../../examples/26_document_store.rb)
218
+ - [examples/25_history_search.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/25_history_search.rb) — Chat History Search
219
+ - [examples/26_document_store.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/26_document_store.rb) — Embedding-Based Document Store
@@ -50,9 +50,10 @@ MCP servers can be declared directly in a template's YAML front matter, making t
50
50
  description: GitHub assistant with MCP tool access
51
51
  mcp:
52
52
  - name: github
53
- transport: stdio
54
- command: npx
55
- args: ["-y", "@modelcontextprotocol/server-github"]
53
+ transport:
54
+ type: stdio
55
+ command: npx
56
+ args: ["-y", "@modelcontextprotocol/server-github"]
56
57
  ---
57
58
  You are a helpful GitHub assistant with access to GitHub tools via MCP.
58
59
  ```
@@ -64,6 +65,16 @@ robot = RobotLab.build(template: :github_assistant)
64
65
 
65
66
  Constructor `mcp:` overrides frontmatter `mcp:` when provided.
66
67
 
68
+ > [!CAUTION]
69
+ > `transport:` must be a **nested mapping**, exactly as above. The flat form —
70
+ > `transport: stdio` with sibling `command:`/`args:` keys — does not raise:
71
+ > `Server` calls `transform_keys` on the transport value, a String raises
72
+ > `NoMethodError: undefined method 'transform_keys' for an instance of String`,
73
+ > and that exception is caught by MCP setup's rescue. The robot builds and runs
74
+ > with **zero tools** from that server. It is not silent, though: the failure is
75
+ > logged at `WARN` through `RobotLab.config.logger` (`$stdout` by default) and
76
+ > the server name is recorded in `robot.failed_mcp_server_names`.
77
+
67
78
  ### Hierarchical Configuration
68
79
 
69
80
  The `mcp:` parameter supports three modes:
@@ -96,14 +107,21 @@ MCP configuration resolves through a hierarchy: **runtime > robot build > networ
96
107
 
97
108
  ```
98
109
  Global (RobotLab.config.mcp)
99
- -> Network (task mcp: [...])
110
+ -> Network (network config: / task config:)
100
111
  -> Robot (mcp: :inherit | :none | [...])
101
- -> Runtime (robot.run("msg", mcp: [...]))
112
+ -> Runtime (robot.run("msg", mcp: [...]), and task mcp: [...])
102
113
  ```
103
114
 
115
+ > [!NOTE]
116
+ > A task's `mcp:`/`tools:` are **not** a separate network tier — `Task` puts
117
+ > them into the run params, so they arrive as the **runtime** value for that
118
+ > robot's `run()`. Only a `config:` (on the network or on the task) acts as the
119
+ > parent level that `:inherit` resolves against.
120
+
104
121
  ## Timeout Configuration
105
122
 
106
- All transports support a configurable request timeout. The default is 15 seconds. Set a custom timeout at the server level:
123
+ Every server config accepts a `timeout:`. The default is 15 seconds
124
+ (`RobotLab::MCP::Server::DEFAULT_TIMEOUT`). Set it at the server level:
107
125
 
108
126
  ```ruby
109
127
  robot = RobotLab.build(
@@ -119,10 +137,46 @@ robot = RobotLab.build(
119
137
  )
120
138
  ```
121
139
 
122
- Values >= 1000 are auto-converted from milliseconds to seconds. The minimum timeout is 1 second.
140
+ Values >= 1000 are auto-converted from milliseconds to seconds. The minimum timeout is 1 second:
141
+
142
+ | Given | Stored |
143
+ |-------|--------|
144
+ | `nil` | `15` (the default) |
145
+ | `30` | `30.0` |
146
+ | `5000` | `5.0` (read as milliseconds) |
147
+ | `0.5` | `1` (floored to the 1-second minimum) |
148
+
149
+ > [!WARNING]
150
+ > **Only the Stdio transport actually enforces the timeout.** SSE, WebSocket,
151
+ > and StreamableHTTP store the value and never reference it — a remote MCP
152
+ > server that stops responding will hang the call indefinitely regardless of
153
+ > what you set here. Apply your own timeout around remote-transport calls if
154
+ > you need one.
123
155
 
124
156
  ## Transport Types
125
157
 
158
+ Valid `type:` values are exactly: `stdio`, `sse`, `ws`, `websocket`,
159
+ `streamable-http`, `http` (`RobotLab::MCP::Server::VALID_TRANSPORT_TYPES`).
160
+
161
+ An invalid type raises `ArgumentError: Invalid transport type: <type>. Must be
162
+ one of: stdio, sse, ws, websocket, streamable-http, http` — but **only if you
163
+ construct the server or client yourself**. Through the robot (`mcp:` on
164
+ `RobotLab.build`) the error is caught by the same rescue that handles any other
165
+ connect failure: it is logged at `WARN` and the server name is recorded in
166
+ `robot.failed_mcp_server_names`, and the robot carries on with zero tools from
167
+ that server. See [Connection Errors](#connection-errors).
168
+
169
+ ```ruby
170
+ bad = { name: "oops", transport: { type: "streamable_http", url: "https://x" } }
171
+
172
+ RobotLab::MCP::Server.new(**bad) # => raises ArgumentError
173
+
174
+ robot = RobotLab.build(name: "r", system_prompt: "...", mcp: [bad])
175
+ robot.run("hi", mcp: :inherit, tools: :inherit)
176
+ # logs: WARN Robot 'r' error connecting to MCP server 'oops': Invalid transport type: ...
177
+ robot.failed_mcp_server_names # => ["oops"]
178
+ ```
179
+
126
180
  ### Stdio Transport
127
181
 
128
182
  Communicate via stdin/stdout with a subprocess:
@@ -154,7 +208,9 @@ Connect via WebSocket:
154
208
  ```
155
209
 
156
210
  !!! note "Dependency Required"
157
- WebSocket transport requires the `async-websocket` gem.
211
+ WebSocket transport requires the `async-websocket` gem. (It also reaches for
212
+ `Async::HTTP::Endpoint` without requiring it, so `async-http` must be loaded
213
+ as well.)
158
214
 
159
215
  ### SSE Transport
160
216
 
@@ -170,6 +226,9 @@ Server-Sent Events transport:
170
226
  }
171
227
  ```
172
228
 
229
+ !!! note "Dependency Required"
230
+ SSE transport requires the `async-http` gem.
231
+
173
232
  ### HTTP Transport
174
233
 
175
234
  Streamable HTTP transport with session support:
@@ -178,7 +237,7 @@ Streamable HTTP transport with session support:
178
237
  {
179
238
  name: "http_server",
180
239
  transport: {
181
- type: "streamable_http",
240
+ type: "streamable-http", # or "http" — NOT "streamable_http"
182
241
  url: "https://api.example.com/mcp",
183
242
  session_id: "optional_session_id",
184
243
  auth_provider: -> { "Bearer #{fetch_token}" }
@@ -186,30 +245,60 @@ Streamable HTTP transport with session support:
186
245
  }
187
246
  ```
188
247
 
248
+ !!! note "Dependency Required"
249
+ Streamable HTTP transport requires the `async-http` gem.
250
+
251
+ > [!CAUTION]
252
+ > The type is spelled with a **hyphen**. `type: "streamable_http"` (underscore)
253
+ > raises `ArgumentError: Invalid transport type: streamable_http`.
254
+
255
+ > [!WARNING]
256
+ > The remote transports connect inside an un-awaited `Async` block and set
257
+ > their connected flag **before** the MCP initialize handshake. `client.connected?`
258
+ > therefore returns `true` even when the host is unreachable — do not treat it
259
+ > as proof the server answered. Stdio is the only transport that blocks on the
260
+ > handshake and reports a connect failure synchronously.
261
+
189
262
  ## Using MCP Tools
190
263
 
191
- Once configured, MCP tools are automatically discovered and made available to the robot. The robot connects to MCP servers on its first `run` call and discovers tools dynamically:
264
+ Once configured, MCP tools are discovered on connect and made available to the
265
+ robot. **Connecting is not automatic** — see the warning below:
192
266
 
193
267
  ```ruby
194
268
  robot = RobotLab.build(
195
269
  name: "helper",
196
- system_prompt: <<~PROMPT
270
+ system_prompt: <<~PROMPT,
197
271
  You can help users with GitHub tasks.
198
272
  Use available tools to search repositories, create issues, etc.
199
- PROMPT,
273
+ PROMPT
200
274
  mcp: [
201
275
  { name: "github", transport: { type: "stdio", command: "mcp-server-github" } }
202
276
  ]
203
277
  )
204
278
 
205
- # MCP tools are automatically available
206
- result = robot.run("Find repositories about machine learning")
279
+ # mcp: :inherit connects the servers; tools: :inherit sends their tools to the LLM.
280
+ result = robot.run("Find repositories about machine learning", mcp: :inherit, tools: :inherit)
207
281
  puts result.last_text_content
208
282
  ```
209
283
 
284
+ > [!CAUTION]
285
+ > **A plain `robot.run(message)` neither connects MCP servers nor sends any
286
+ > tools.** Both the `mcp:` and `tools:` parameters of `run` default to `:none`,
287
+ > which means "zero this turn" — the build-time `mcp:` list is simply not
288
+ > consulted. You need **both** flags:
289
+ >
290
+ > ```ruby
291
+ > robot.run("...") # no MCP connection, no tools
292
+ > robot.run("...", mcp: :inherit) # connects, but sends zero tools
293
+ > robot.run("...", mcp: :inherit, tools: :inherit) # connects AND sends its tools
294
+ > ```
295
+
210
296
  ## Filtering MCP Tools
211
297
 
212
- Use the `tools:` parameter to restrict which tools (including MCP-discovered tools) are available to a robot:
298
+ Use the `tools:` parameter to restrict which tools (including MCP-discovered
299
+ tools) reach the LLM. It is a **name allowlist**, and it must be supplied at the
300
+ level that actually runs — the `run()` call (or the network `task`, which
301
+ forwards to `run()`):
213
302
 
214
303
  ```ruby
215
304
  robot = RobotLab.build(
@@ -217,14 +306,32 @@ robot = RobotLab.build(
217
306
  system_prompt: "You help read and search files.",
218
307
  mcp: [
219
308
  { name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } }
220
- ],
221
- tools: %w[read_file search_files list_directory] # Only allow specific tools
309
+ ]
222
310
  )
311
+
312
+ robot.run("Summarise the README",
313
+ mcp: :inherit,
314
+ tools: %w[read_file search_files list_directory]) # only these
223
315
  ```
224
316
 
317
+ > [!WARNING]
318
+ > A build-time `tools: %w[...]` allowlist has no effect on its own, because the
319
+ > runtime default of `:none` sends zero tools regardless. And for a
320
+ > **standalone** robot, do not write `tools: :inherit` at build time as a
321
+ > workaround: there the parent level is the global `:none`, so it produces an
322
+ > allowlist of `["none"]` that matches nothing. Leave build-time `tools:` unset
323
+ > and pass the filter at run time.
324
+ >
325
+ > Inside a **network** whose `config:` supplies `tools:`/`mcp:`, this reverses:
326
+ > the parent level is the network's list, and build-time `:inherit` is what
327
+ > makes the robot pick it up. See
328
+ > [Network-Wide Tool and MCP Defaults](creating-networks.md#network-wide-tool-and-mcp-defaults).
329
+
225
330
  ## MCP in Networks
226
331
 
227
- When running robots in a network, use per-task MCP configuration:
332
+ When running robots in a network, use per-task MCP configuration. Remember that
333
+ a task's `mcp:`/`tools:` become that robot's **runtime** values, so `tools:`
334
+ must be set too or the MCP tools will not be sent:
228
335
 
229
336
  ```ruby
230
337
  network = RobotLab.create_network(name: "dev_pipeline") do
@@ -233,6 +340,7 @@ network = RobotLab.create_network(name: "dev_pipeline") do
233
340
  mcp: [
234
341
  { name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } }
235
342
  ],
343
+ tools: :inherit,
236
344
  depends_on: [:planner]
237
345
  task :reviewer, reviewer_robot, depends_on: [:coder]
238
346
  end
@@ -310,6 +418,13 @@ client.list_resources # => Array of resource definitions
310
418
  client.disconnect
311
419
  ```
312
420
 
421
+ > [!NOTE]
422
+ > `MCP::Client#initialize(server_or_config, poller: nil)` takes its server as a
423
+ > **positional** argument — either a `Server` instance or a config Hash.
424
+ > `Client.new(name: "x", transport: {...})` raises `ArgumentError: wrong number
425
+ > of arguments (given 0, expected 1)`; wrap the hash in braces:
426
+ > `Client.new({ name: "x", transport: {...} })`.
427
+
313
428
  ## Connection Multiplexing
314
429
 
315
430
  When a robot connects to several local (stdio) MCP servers, each client normally blocks independently while waiting for a response. `MCP::ConnectionPoller` replaces this with a single `IO.select` call across all registered stdout file descriptors, dispatching each response to the pending request for that client.
@@ -377,8 +492,10 @@ robot = RobotLab.build(
377
492
  ]
378
493
  )
379
494
 
380
- # Discovery connects only :brew for this message — filesystem and github are skipped
381
- robot.run("install imagemagick")
495
+ # Discovery connects only :brew for this message — filesystem and github are skipped.
496
+ # mcp: :inherit is required; a plain run() resolves the MCP list to empty and
497
+ # discovery never runs.
498
+ robot.run("install imagemagick", mcp: :inherit, tools: :inherit)
382
499
  ```
383
500
 
384
501
  ### How It Works
@@ -387,7 +504,7 @@ robot.run("install imagemagick")
387
504
 
388
505
  The threshold is intentionally low — server descriptions are short, so raw cosine scores are naturally small even for on-topic queries.
389
506
 
390
- Discovery only applies on the **first** `run()` call (before `@mcp_initialized`). Once a set of servers is connected they remain connected for the robot's lifetime, preserving tool continuity across a conversation.
507
+ Discovery only applies on the **first** MCP-resolving `run()` call (before the robot is marked MCP-initialized). Once a set of servers is connected they remain connected for the robot's lifetime, preserving tool continuity across a conversation.
391
508
 
392
509
  ### Fallback Behaviour
393
510
 
@@ -404,23 +521,42 @@ All servers are returned unchanged when any of the following apply:
404
521
 
405
522
  ```ruby
406
523
  servers = [
407
- { name: "filesystem", description: "Read and write files", transport: { ... } },
408
- { name: "github", description: "GitHub repos and PRs", transport: { ... } }
524
+ { name: "filesystem", description: "Read and write files",
525
+ transport: { type: "stdio", command: "mcp-server-fs" } },
526
+ { name: "github", description: "GitHub repos and PRs",
527
+ transport: { type: "stdio", command: "mcp-server-github" } }
409
528
  ]
410
529
 
411
530
  relevant = RobotLab::MCP::ServerDiscovery.select(
412
- "list open pull requests",
531
+ "search github repos",
413
532
  from: servers,
414
533
  threshold: 0.05 # optional, default
415
534
  )
416
- # => only the :github entry
535
+ # => [{ name: "github", ... }]
417
536
  ```
418
537
 
538
+ > [!IMPORTANT]
539
+ > Scoring is lexical, not semantic — it compares word stems against each
540
+ > server's `description`. A query that happens to share no stems with **any**
541
+ > description scores 0.0 everywhere, trips the "nothing above threshold"
542
+ > fallback in the table above, and gets **all** servers back rather than none.
543
+ > With the two servers above:
544
+ >
545
+ > ```ruby
546
+ > select("search github repos", from: servers) # => ["github"]
547
+ > select("read and write files", from: servers) # => ["filesystem"]
548
+ > select("list open pull requests", from: servers) # => ["filesystem", "github"] <- fallback
549
+ > ```
550
+ >
551
+ > The last one selects everything because the description says "PRs", not "pull
552
+ > requests". Write descriptions using the words your prompts will actually use.
553
+
419
554
  ## Connection Resilience
420
555
 
421
556
  ### Eager Connection
422
557
 
423
- By default, MCP connections are lazy — established on the first `run()` call. Use `connect_mcp!` to connect early:
558
+ `connect_mcp!` connects the robot's configured servers immediately, without
559
+ waiting for a `run()` that passes `mcp: :inherit`:
424
560
 
425
561
  ```ruby
426
562
  robot = RobotLab.build(
@@ -440,14 +576,22 @@ if robot.failed_mcp_server_names.any?
440
576
  end
441
577
  ```
442
578
 
579
+ > [!NOTE]
580
+ > `connect_mcp!` only opens the connections. A later plain `robot.run(message)`
581
+ > still sends the LLM **zero** tools, because `run`'s `tools:` defaults to
582
+ > `:none`. Eager connection and tool visibility are separate switches.
583
+
443
584
  ### Automatic Retry
444
585
 
445
- Failed MCP servers are automatically retried on subsequent `run()` calls. If a server was down when the robot first connected, it will be retried transparently:
586
+ Failed MCP servers are retried on subsequent `run()` calls **that resolve to a
587
+ non-empty MCP list** — i.e. runs that pass `mcp: :inherit` (or an explicit
588
+ array). If a server was down when the robot first connected, it is retried
589
+ transparently:
446
590
 
447
591
  ```ruby
448
- robot.run("First message") # github connects, filesystem fails
592
+ robot.run("First message", mcp: :inherit, tools: :inherit) # github connects, filesystem fails
449
593
  # ... filesystem comes back up ...
450
- robot.run("Second message") # filesystem retried and connects
594
+ robot.run("Second message", mcp: :inherit, tools: :inherit) # filesystem retried and connects
451
595
  ```
452
596
 
453
597
  ### Injecting External MCP Clients
@@ -464,25 +608,45 @@ This skips the normal connection process and marks the robot as MCP-initialized.
464
608
 
465
609
  ### Connection Errors
466
610
 
611
+ MCP connection failures are **not raised**. They are logged as warnings and
612
+ recorded on the robot; the run continues without that server's tools, and one
613
+ failing server does not prevent the others from connecting. Inspect the result
614
+ rather than rescuing:
615
+
467
616
  ```ruby
468
- begin
469
- result = robot.run("Search for repos")
470
- rescue RobotLab::MCPError => e
471
- puts "MCP Error: #{e.message}"
617
+ result = robot.run("Search for repos", mcp: :inherit, tools: :inherit)
618
+
619
+ if robot.failed_mcp_server_names.any?
620
+ warn "MCP servers unavailable: #{robot.failed_mcp_server_names.join(', ')}"
472
621
  end
473
622
  ```
474
623
 
475
- MCP connection failures are logged as warnings but do not raise errors by default. The robot will continue without MCP tools if a server is unreachable. One failing server does not prevent other servers from connecting.
476
-
477
624
  ### Timeout Errors
478
625
 
479
- Stdio transports wrap all blocking I/O with a configurable timeout. If a server does not respond within the timeout period, an `MCPError` is raised with a descriptive message:
626
+ Stdio transports wrap all blocking I/O with a configurable timeout. On expiry
627
+ the transport raises `MCPError`. Note that the two messages differ, and neither
628
+ uses the server *name* you configured — the handshake message names the
629
+ **command**, and the per-request message names nothing at all:
480
630
 
481
631
  ```ruby
482
- # Server that takes too long will raise:
483
- # RobotLab::MCPError: MCP server 'heavy-server' did not respond within 15s
632
+ # Handshake timed out during connect:
633
+ # RobotLab::MCPError: MCP server 'heavy-mcp-server' did not respond within 15s
634
+
635
+ # A later request timed out:
636
+ # RobotLab::MCPError: MCP server did not respond within 15s
484
637
  ```
485
638
 
639
+ > [!IMPORTANT]
640
+ > `MCP::Client#connect` rescues **every** `StandardError`, logs
641
+ > `"MCP connection failed for <name>: ..."` at `:warn`, and returns `self` with
642
+ > `connected?` false. So a connect-time timeout never reaches your `begin/rescue`
643
+ > — check `client.connected?` (or `robot.failed_mcp_server_names`) instead.
644
+ > Per-request calls such as `list_tools` and `call_tool` **do** propagate
645
+ > `MCPError`.
646
+
647
+ SSE, WebSocket, and StreamableHTTP raise no timeout error at all — see the
648
+ warning under [Timeout Configuration](#timeout-configuration).
649
+
486
650
  ## Disconnecting
487
651
 
488
652
  Robots can be manually disconnected from MCP servers:
@@ -546,25 +710,28 @@ robot = RobotLab.build(
546
710
 
547
711
  ### 2. Limit Tool Access
548
712
 
549
- Restrict which MCP tools are available to a robot using the `tools:` parameter:
713
+ Restrict which MCP tools reach the LLM with a `tools:` allowlist on the run:
550
714
 
551
715
  ```ruby
552
716
  robot = RobotLab.build(
553
717
  name: "reader",
554
718
  system_prompt: "You read and search files.",
555
- mcp: [{ name: "fs", transport: { type: "stdio", command: "mcp-fs" } }],
556
- tools: %w[read_file search_files] # No write access
719
+ mcp: [{ name: "fs", transport: { type: "stdio", command: "mcp-fs" } }]
557
720
  )
721
+
722
+ robot.run("Find the config file",
723
+ mcp: :inherit,
724
+ tools: %w[read_file search_files]) # No write access
558
725
  ```
559
726
 
560
727
  ### 3. Use Appropriate Transports
561
728
 
562
729
  | Transport | Best For |
563
730
  |-----------|----------|
564
- | `stdio` | Local servers, CLI tools |
565
- | `websocket` | Persistent connections, bidirectional |
731
+ | `stdio` | Local servers, CLI tools. The only transport that enforces `timeout:` and reports connect failures synchronously. |
732
+ | `websocket` (or `ws`) | Persistent connections, bidirectional |
566
733
  | `sse` | Server push, event streams |
567
- | `streamable_http` | Remote APIs, session-based |
734
+ | `streamable-http` (or `http`) | Remote APIs, session-based |
568
735
 
569
736
  ## Next Steps
570
737