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
@@ -8,12 +8,19 @@ Transports handle the low-level communication between `MCP::Client` and external
8
8
 
9
9
  RobotLab provides four built-in transport types:
10
10
 
11
- | Transport | Class | Use Case |
12
- |-----------|-------|----------|
13
- | Stdio | `Transports::Stdio` | Local subprocess servers |
14
- | WebSocket | `Transports::WebSocket` | Real-time bidirectional |
15
- | SSE | `Transports::SSE` | Server-sent events |
16
- | Streamable HTTP | `Transports::StreamableHTTP` | HTTP with session support |
11
+ | Transport | Class | Use Case | Status |
12
+ |-----------|-------|----------|--------|
13
+ | Stdio | `Transports::Stdio` | Local subprocess servers | Fully working |
14
+ | WebSocket | `Transports::WebSocket` | Real-time bidirectional | **Broken** — see below |
15
+ | SSE | `Transports::SSE` | Server-sent events | Working, but `connect` reports success unconditionally |
16
+ | Streamable HTTP | `Transports::StreamableHTTP` | HTTP with session support | Working, but `connect` reports success unconditionally |
17
+
18
+ > **Read this before using a non-stdio transport.** Only `Stdio` performs its
19
+ > connection and MCP handshake synchronously. The other three wrap that work in
20
+ > an `Async do ... end` block whose result is never awaited, so any error raised
21
+ > inside — including a refused TCP connection — is discarded. `connect` returns
22
+ > `self` regardless. The specific consequences are documented per transport
23
+ > below.
17
24
 
18
25
  ## Base Interface
19
26
 
@@ -35,6 +42,12 @@ end
35
42
 
36
43
  The `timeout` is extracted from the config hash during initialization (and removed from `config`). If not provided, it defaults to `DEFAULT_TIMEOUT` (15 seconds). The timeout is propagated from `MCP::Server` through `MCP::Client` to the transport.
37
44
 
45
+ **Only `Stdio` enforces the timeout.** `SSE`, `WebSocket`, and `StreamableHTTP`
46
+ store `@timeout` and expose it through the `timeout` reader, but never reference
47
+ it — their requests are not time-bounded by this value. If you need a bound on a
48
+ non-stdio transport, wrap the call yourself, or route the client through an
49
+ `MCP::ConnectionPoller` (stdio only).
50
+
38
51
  ## Stdio Transport
39
52
 
40
53
  **Class:** `RobotLab::MCP::Transports::Stdio`
@@ -90,7 +103,23 @@ transport.close
90
103
 
91
104
  **Class:** `RobotLab::MCP::Transports::WebSocket`
92
105
 
93
- Uses `async-websocket` for non-blocking bidirectional communication. Requires the `async-websocket` gem.
106
+ Intended to use `async-websocket` for non-blocking bidirectional communication.
107
+
108
+ > **This transport does not currently work.** `connect` calls
109
+ > `Async::HTTP::Endpoint.parse`, but only requires `async` and
110
+ > `async/websocket/client` — the `Async::HTTP` namespace is never loaded, so the
111
+ > call raises `NameError`. That happens inside an un-awaited `Async` block, so
112
+ > the error is swallowed: `connect` returns `self`, no `MCPError` is raised, and
113
+ > `connected?` stays `false`. Every subsequent `send_request` then raises
114
+ > `MCPError, "Not connected"`.
115
+ >
116
+ > Verified: `WebSocket.new(url: "ws://127.0.0.1:9/ws").connect.connected?` is
117
+ > `false` with no exception surfacing.
118
+ >
119
+ > The rescue on `connect` only catches `LoadError`, which is raised if
120
+ > `async-websocket` is missing. Because of the `Async::HTTP::Endpoint` call, this
121
+ > transport also needs `async-http` even once `async-websocket` is installed —
122
+ > both are declared as runtime dependencies of the gem.
94
123
 
95
124
  ### Configuration
96
125
 
@@ -107,10 +136,16 @@ Uses `async-websocket` for non-blocking bidirectional communication. Requires th
107
136
 
108
137
  ### Behavior
109
138
 
110
- - Uses `Async::WebSocket::Client.connect` within an `Async` block
111
- - Sends JSON-RPC messages as JSON strings
112
- - Reads responses synchronously within the async context
113
- - Raises `MCPError` if the `async-websocket` gem is not installed
139
+ - Intends to use `Async::WebSocket::Client.connect` within an `Async` block, then
140
+ send the MCP `initialize` handshake
141
+ - In practice the block raises `NameError` on `Async::HTTP::Endpoint` before the
142
+ connection is created, and the un-awaited block discards it
143
+ - `send_request` sends JSON-RPC messages as JSON strings and reads the response
144
+ inside an awaited `Async` block — but it raises `MCPError, "Not connected"`
145
+ because `@connected` was never set
146
+ - `connect` raises `MCPError` only for `LoadError` (missing `async-websocket`);
147
+ it does not raise for a connection failure
148
+ - `close` is a no-op while `@connected` is `false`
114
149
 
115
150
  ### Example
116
151
 
@@ -120,8 +155,10 @@ transport = RobotLab::MCP::Transports::WebSocket.new(
120
155
  )
121
156
 
122
157
  transport.connect
123
- response = transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
124
- transport.close
158
+ transport.connected? # => false, even against a live server
159
+
160
+ # Raises MCPError: "Not connected"
161
+ transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
125
162
  ```
126
163
 
127
164
  ## SSE Transport
@@ -145,10 +182,20 @@ Uses `async-http` for HTTP-based communication. Sends requests via HTTP POST and
145
182
 
146
183
  ### Behavior
147
184
 
148
- - Creates an `Async::HTTP::Client` on connect
185
+ - Creates an `Async::HTTP::Client` on connect, then sends the MCP `initialize`
186
+ handshake
149
187
  - Sends JSON-RPC messages via HTTP POST with `Content-Type: application/json`
150
188
  - Reads and parses JSON response body
151
- - Raises `MCPError` if the `async-http` gem is not installed
189
+ - Raises `MCPError` if the `async-http` gem is not installed (`LoadError` only)
190
+ - The `timeout` from the server config is stored but never applied
191
+
192
+ > **`connect` always reports success.** `@connected = true` is assigned *before*
193
+ > `send_initialize` runs, and the whole sequence is inside an un-awaited `Async`
194
+ > block. Against an unreachable host, `connect` returns `self`, `connected?`
195
+ > returns `true`, and the handshake failure is never surfaced. The first real
196
+ > `send_request` is where the failure appears.
197
+ >
198
+ > Verified: `SSE.new(url: "http://127.0.0.1:9/sse").connect.connected?` is `true`.
152
199
 
153
200
  ### Example
154
201
 
@@ -158,6 +205,8 @@ transport = RobotLab::MCP::Transports::SSE.new(
158
205
  )
159
206
 
160
207
  transport.connect
208
+ # connected? is true here whether or not the server exists
209
+
161
210
  response = transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
162
211
  transport.close
163
212
  ```
@@ -192,7 +241,17 @@ HTTP-based transport with session management and optional authentication. Suppor
192
241
  - Sends `X-Session-ID` header with each request when a session ID is available
193
242
  - Calls `auth_provider` for each request to populate the `Authorization` header
194
243
  - Exposes `session_id` reader for accessing the current session ID
195
- - Raises `MCPError` if the `async-http` gem is not installed
244
+ - Raises `MCPError` if the `async-http` gem is not installed (`LoadError` only)
245
+ - The `timeout` from the server config is stored but never applied
246
+
247
+ > **`connect` always reports success**, for the same reason as SSE:
248
+ > `@connected = true` precedes `send_initialize`, and the enclosing `Async` block
249
+ > is never awaited. `connected?` returns `true` against an unreachable host, and
250
+ > `session_id` stays at whatever you configured (`nil` if you configured nothing)
251
+ > because the handshake result was discarded.
252
+ >
253
+ > Verified: `StreamableHTTP.new(url: "http://127.0.0.1:9/mcp").connect` yields
254
+ > `connected? == true`, `session_id == nil`.
196
255
 
197
256
  ### Example
198
257
 
@@ -203,7 +262,7 @@ transport = RobotLab::MCP::Transports::StreamableHTTP.new(
203
262
  )
204
263
 
205
264
  transport.connect
206
- puts transport.session_id # => assigned by server or pre-configured
265
+ puts transport.session_id # => pre-configured value, or nil until a request lands
207
266
 
208
267
  response = transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
209
268
  transport.close
@@ -211,14 +270,26 @@ transport.close
211
270
 
212
271
  ## Connection Lifecycle
213
272
 
214
- All transports follow the same lifecycle:
273
+ All transports expose the same four-step lifecycle:
215
274
 
216
275
  1. **Create** -- instantiate with configuration hash
217
276
  2. **Connect** -- establish connection and perform MCP protocol initialization
218
277
  3. **Request/Response** -- send JSON-RPC requests, receive responses
219
278
  4. **Close** -- tear down connection and release resources
220
279
 
221
- Each transport sends the MCP `initialize` message during connect:
280
+ Step 2 behaves differently per transport:
281
+
282
+ | Transport | `connect` is synchronous | Errors surface from `connect` | `connected?` reflects reality |
283
+ |-----------|--------------------------|-------------------------------|-------------------------------|
284
+ | `Stdio` | Yes | Yes (`MCPError`) | Yes — also checks the process is alive |
285
+ | `SSE` | No (un-awaited `Async`) | No | No — always `true` after `connect` |
286
+ | `StreamableHTTP` | No (un-awaited `Async`) | No | No — always `true` after `connect` |
287
+ | `WebSocket` | No (un-awaited `Async`) | No | No — always `false` after `connect` |
288
+
289
+ Only `Stdio` gives you a trustworthy answer at connect time. For the other three,
290
+ treat the first `send_request` as the real connection test.
291
+
292
+ Each transport builds the same MCP `initialize` message during connect:
222
293
 
223
294
  ```json
224
295
  {
@@ -252,11 +323,12 @@ end
252
323
  ```
253
324
 
254
325
  Specific error cases:
255
- - **Not connected** -- calling `send_request` before `connect` raises `MCPError`
256
- - **Missing gem** -- WebSocket, SSE, and HTTP transports raise `MCPError` with a `LoadError` message if required gems are not installed
326
+ - **Not connected** -- calling `send_request` before `connect` raises `MCPError` (all transports)
327
+ - **Missing gem** -- WebSocket, SSE, and HTTP transports raise `MCPError` with a `LoadError` message if the required gem (`async-websocket` / `async-http`) is not installed. This is the *only* error `connect` re-raises on those three
328
+ - **Connection refused / unreachable host** -- **not** reported by SSE, WebSocket, or StreamableHTTP `connect`; the error is discarded with the un-awaited `Async` block
257
329
  - **No response** -- Stdio transport raises `MCPError` if the subprocess produces no output (EOF on stdout)
258
330
  - **Command not found** -- Stdio transport raises `MCPError` with the original `Errno::ENOENT` message
259
- - **Timeout** -- Stdio transport raises `MCPError` if the server does not respond within the configured timeout
331
+ - **Timeout** -- Stdio transport raises `MCPError` if the server does not respond within the configured timeout. No other transport enforces a timeout
260
332
  - **Broken pipe** -- Stdio transport raises `MCPError` and marks itself disconnected on `Errno::EPIPE` or `IOError`
261
333
  - **Immediate exit** -- Stdio transport raises `MCPError` if the server process exits immediately after spawn
262
334
 
@@ -27,22 +27,45 @@ Message (base)
27
27
  ├── ToolCallMessage - role + Array<ToolMessage>
28
28
  └── ToolResultMessage - tool + result content
29
29
 
30
- UserMessage - Standalone (not a Message subclass)
31
- ToolMessage - Standalone (not a Message subclass)
30
+ UserMessage - Standalone PORO (superclass Object)
31
+ ToolMessage - Standalone PORO (superclass Object)
32
32
  ```
33
33
 
34
+ `UserMessage` and `ToolMessage` are plain objects — their superclass is `Object`,
35
+ not `Message`. They do not respond to `type`, `role`, or any of the predicates
36
+ below.
37
+
34
38
  ## Common Interface
35
39
 
36
40
  All Message subclasses implement:
37
41
 
38
42
  ```ruby
39
- message.role # => String ("user", "assistant", "tool_result")
43
+ message.role # => String ("system", "user", "assistant", "tool_result")
40
44
  message.content # => String or structured data
41
45
  message.type # => String ("text", "tool_call", "tool_result")
42
46
  message.to_h # => Hash representation
43
47
  message.to_json # => JSON string
44
48
  ```
45
49
 
50
+ Valid values are fixed by constants on `Message`, and the constructor raises
51
+ `ArgumentError` for anything else:
52
+
53
+ ```ruby
54
+ RobotLab::Message::VALID_TYPES # => ["text", "tool_call", "tool_result"]
55
+ RobotLab::Message::VALID_ROLES # => ["system", "user", "assistant", "tool_result"]
56
+ RobotLab::Message::VALID_STOP_REASONS # => ["tool", "stop"]
57
+ ```
58
+
59
+ ### Nil keys and `to_h`
60
+
61
+ `Message#to_h` — used as-is by `TextMessage` — ends in `.compact`, so any key
62
+ whose value is `nil` is **absent** from the hash, not present with a `nil` value.
63
+ A `TextMessage` built without a `stop_reason` produces a three-key hash. The same
64
+ applies to `UserMessage#to_h`.
65
+
66
+ `ToolCallMessage`, `ToolResultMessage`, and `ToolMessage` override `to_h` without
67
+ compacting, so their keys are always present.
68
+
46
69
  Type and role predicates:
47
70
 
48
71
  ```ruby
@@ -64,17 +64,17 @@ The stop reason, if any.
64
64
  message.to_h # => Hash
65
65
  ```
66
66
 
67
- Hash representation.
67
+ Hash representation. Inherited from `Message#to_h`, which ends in `.compact` — a
68
+ `nil` `stop_reason` is **omitted** rather than serialized as `nil`.
68
69
 
69
70
  **Returns:**
70
71
 
71
72
  ```ruby
72
- {
73
- type: "text",
74
- role: "assistant",
75
- content: "Hello! How can I help you today?",
76
- stop_reason: "stop"
77
- }
73
+ TextMessage.new(role: "assistant", content: "Hello! How can I help you today?").to_h
74
+ # => { type: "text", role: "assistant", content: "Hello! How can I help you today?" }
75
+
76
+ TextMessage.new(role: "assistant", content: "Done.", stop_reason: "stop").to_h
77
+ # => { type: "text", role: "assistant", content: "Done.", stop_reason: "stop" }
78
78
  ```
79
79
 
80
80
  ### to_json
@@ -125,13 +125,35 @@ message.stopped? # => true
125
125
 
126
126
  ### In Robot Results
127
127
 
128
+ `Robot#run` returns a `RobotResult`, **not** a `TextMessage`. `RobotResult` has no
129
+ `text?` and no `content` — calling either raises `NoMethodError`. Read the reply
130
+ with `last_text_content` (aliased as `reply`):
131
+
128
132
  ```ruby
129
133
  result = robot.run("Tell me a joke")
130
134
 
131
- # The result is a TextMessage when the assistant replies with text
132
- if result.text?
133
- puts result.content
134
- end
135
+ puts result.last_text_content # => the assistant's text
136
+ puts result.reply # => same thing
137
+ result.stopped? # => true whenever there are no tool calls
138
+ result.has_tool_calls? # => whether the final message carried tool calls
139
+ ```
140
+
141
+ !!! note "`RobotResult#stopped?` is not driven by `stop_reason`"
142
+ A `RobotResult` produced by `Robot#run` always has `stop_reason == nil`
143
+ (`RubyLLM::Message` does not define the method, so `build_result` falls back
144
+ to `nil`), and the `TextMessage` it wraps is built without a `stop_reason`
145
+ too. `stopped?` therefore reduces to `!has_tool_calls?` — it never becomes
146
+ true because a `stop_reason` of `"stop"` was reported. The
147
+ `stop_reason: "stop"` form shown above only applies to `TextMessage`
148
+ instances you construct yourself.
149
+
150
+ `result.output` is an `Array` holding a single `TextMessage` rebuilt from the
151
+ final response text — it is not the full turn:
152
+
153
+ ```ruby
154
+ msg = result.output.first
155
+ msg.text? # => true
156
+ msg.content # => the assistant's text
135
157
  ```
136
158
 
137
159
  ### Filtering Text Content
@@ -24,22 +24,44 @@ ToolCallMessage.new(role:, tools:, stop_reason: nil)
24
24
  | Name | Type | Description |
25
25
  |------|------|-------------|
26
26
  | `role` | `String` | Message role (typically "assistant") |
27
- | `tools` | `Array<ToolMessage>` | Array of tool call objects |
27
+ | `tools` | `Array<ToolMessage, Hash>` | Tool calls; plain Hashes are normalized via `ToolMessage.from_hash` |
28
28
  | `stop_reason` | `String`, `nil` | Stop reason (defaults to "tool") |
29
29
 
30
+ Each entry in `tools` may be a `ToolMessage` **or** a Hash. Anything else raises
31
+ `ArgumentError: Invalid tool: must be ToolMessage or Hash`. The `tools` reader
32
+ always returns `ToolMessage` objects.
33
+
34
+ ```ruby
35
+ ToolCallMessage.new(
36
+ role: "assistant",
37
+ tools: [{ id: "call_1", name: "get_weather", arguments: { city: "NYC" } }]
38
+ ).tools.first.input
39
+ # => { city: "NYC" }
40
+ ```
41
+
42
+ `ToolMessage.from_hash` symbolizes keys and accepts either `input:` or
43
+ `arguments:` for the parameter hash (`input` wins), defaulting to `{}` when
44
+ neither is present. This makes raw provider tool-call payloads usable directly.
45
+
30
46
  ## ToolMessage
31
47
 
32
- Each tool call is represented by a standalone `ToolMessage` object:
48
+ Each tool call is represented by a standalone `ToolMessage` object. It is a plain
49
+ Ruby object — its superclass is `Object`, **not** `Message` — so it has no
50
+ `type`, `role`, or predicate methods:
33
51
 
34
52
  ```ruby
35
53
  ToolMessage.new(id:, name:, input:)
54
+ ToolMessage.from_hash(hash)
36
55
  ```
37
56
 
38
57
  | Name | Type | Description |
39
58
  |------|------|-------------|
40
59
  | `id` | `String` | Unique call identifier |
41
60
  | `name` | `String` | Tool name |
42
- | `input` | `Hash` | Tool parameters |
61
+ | `input` | `Hash`, `nil` | Tool parameters; `nil` becomes `{}` |
62
+
63
+ `ToolMessage#to_h` returns `{ type: "tool", id:, name:, input: }`. It is not
64
+ compacted, so every key is always present.
43
65
 
44
66
  ## Attributes
45
67
 
@@ -91,7 +113,8 @@ Defaults to `"tool"` indicating the conversation stopped for tool execution.
91
113
  message.to_h # => Hash
92
114
  ```
93
115
 
94
- Hash representation.
116
+ Hash representation. `ToolCallMessage` overrides `Message#to_h` and does **not**
117
+ compact, so all four keys are always present (`content` is not included at all).
95
118
 
96
119
  **Returns:**
97
120
 
@@ -23,10 +23,26 @@ ToolResultMessage.new(tool:, content:, stop_reason: nil)
23
23
 
24
24
  | Name | Type | Description |
25
25
  |------|------|-------------|
26
- | `tool` | `ToolMessage` | The tool call that was executed |
26
+ | `tool` | `ToolMessage`, `Hash` | The tool call that was executed; a Hash is normalized via `ToolMessage.from_hash` |
27
27
  | `content` | `Hash` | Result with `:data` key (success) or `:error` key (failure) |
28
28
  | `stop_reason` | `String`, `nil` | Stop reason (defaults to "tool") |
29
29
 
30
+ `tool:` accepts a plain Hash as well as a `ToolMessage`; anything else raises
31
+ `ArgumentError: Invalid tool: must be ToolMessage or Hash`. As with
32
+ `ToolCallMessage`, `from_hash` symbolizes keys and accepts `input:` or
33
+ `arguments:` for the parameter hash.
34
+
35
+ ```ruby
36
+ ToolResultMessage.new(
37
+ tool: { id: "call_1", name: "get_weather", arguments: { city: "NYC" } },
38
+ content: { data: { temperature: 72 } }
39
+ ).tool.input
40
+ # => { city: "NYC" }
41
+ ```
42
+
43
+ `role` is hard-coded to `"tool_result"` — the value you pass for `tool:` has no
44
+ bearing on it.
45
+
30
46
  ## Attributes
31
47
 
32
48
  ### tool
@@ -77,7 +93,9 @@ Defaults to `"tool"`.
77
93
  message.success? # => Boolean
78
94
  ```
79
95
 
80
- Returns `true` if the content contains a `:data` key.
96
+ Returns `true` if `content` is a Hash containing the **symbol** key `:data`.
97
+ `content` is stored verbatim — it is not symbolized — so a result built with
98
+ `content: { "data" => ... }` reports `success? == false` and `data == nil`.
81
99
 
82
100
  ### error?
83
101
 
@@ -85,7 +103,7 @@ Returns `true` if the content contains a `:data` key.
85
103
  message.error? # => Boolean
86
104
  ```
87
105
 
88
- Returns `true` if the content contains an `:error` key.
106
+ Returns `true` if `content` is a Hash containing the **symbol** key `:error`.
89
107
 
90
108
  ### data
91
109
 
@@ -109,7 +127,8 @@ Returns the error message if there was an error, `nil` otherwise.
109
127
  message.to_h # => Hash
110
128
  ```
111
129
 
112
- Hash representation.
130
+ Hash representation. `ToolResultMessage` overrides `Message#to_h` and does
131
+ **not** compact, so all five keys are always present.
113
132
 
114
133
  **Returns:**
115
134
 
@@ -89,19 +89,38 @@ Message creation timestamp.
89
89
  message.to_h # => Hash
90
90
  ```
91
91
 
92
- Hash representation.
92
+ Hash representation. The hash ends in `.compact`, so keys whose value is `nil`
93
+ are **omitted** — a message built without `session_id:` or `system_prompt:` has
94
+ no such keys at all. `metadata` always survives because it defaults to `{}`.
95
+
96
+ `created_at` is serialized with `Time#iso8601`, which carries the **local** UTC
97
+ offset. It is not normalized to `Z`/UTC.
93
98
 
94
99
  **Returns:**
95
100
 
96
101
  ```ruby
97
- {
98
- content: "What's my order status?",
102
+ UserMessage.new(
103
+ "What's my order status?",
99
104
  session_id: "session_123",
100
105
  system_prompt: "Be concise",
101
- metadata: { source: "web" },
102
- id: "uuid-here",
103
- created_at: "2024-01-15T10:30:00Z"
104
- }
106
+ metadata: { source: "web" }
107
+ ).to_h
108
+ # => {
109
+ # content: "What's my order status?",
110
+ # session_id: "session_123",
111
+ # system_prompt: "Be concise",
112
+ # metadata: { source: "web" },
113
+ # id: "uuid-here",
114
+ # created_at: "2026-07-31T13:04:42-05:00"
115
+ # }
116
+
117
+ UserMessage.new("hi").to_h
118
+ # => {
119
+ # content: "hi",
120
+ # metadata: {},
121
+ # id: "uuid-here",
122
+ # created_at: "2026-07-31T13:04:42-05:00"
123
+ # }
105
124
  ```
106
125
 
107
126
  ### to_json
@@ -134,7 +153,18 @@ Returns the content string.
134
153
  UserMessage.from(input) # => UserMessage
135
154
  ```
136
155
 
137
- Creates a `UserMessage` from a String, Hash, or existing `UserMessage`.
156
+ Normalizes any input into a `UserMessage`:
157
+
158
+ | Input | Result |
159
+ |-------|--------|
160
+ | `UserMessage` | Returned unchanged (same object) |
161
+ | `String` | `new(input)` |
162
+ | `Hash` | Keys symbolized, then `new(content, session_id:, system_prompt:, metadata:, id:)` |
163
+ | `TextMessage` | `new(input.content)` — role and stop reason are dropped |
164
+ | anything else | `new(input.to_s)` |
165
+
166
+ There is no "unsupported input" branch: `from` never raises for an unrecognized
167
+ type, it falls through to `to_s`.
138
168
 
139
169
  ## Examples
140
170
 
@@ -187,6 +217,13 @@ msg = UserMessage.from(content: "Hello!", session_id: "123")
187
217
 
188
218
  # From an existing UserMessage (returns as-is)
189
219
  msg = UserMessage.from(existing_message)
220
+
221
+ # From a TextMessage (only the content is carried over)
222
+ msg = UserMessage.from(TextMessage.new(role: "user", content: "Hello!"))
223
+ msg.content # => "Hello!"
224
+
225
+ # Anything else falls back to to_s
226
+ UserMessage.from(42).content # => "42"
190
227
  ```
191
228
 
192
229
  ## See Also