robot_lab 0.2.6 → 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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -64
  3. data/docs/api/core/index.md +41 -15
  4. data/docs/api/core/memory.md +247 -29
  5. data/docs/api/core/network.md +255 -33
  6. data/docs/api/core/result.md +120 -32
  7. data/docs/api/core/robot.md +551 -61
  8. data/docs/api/core/state.md +87 -197
  9. data/docs/api/core/tool.md +165 -20
  10. data/docs/api/errors.md +110 -17
  11. data/docs/api/hooks.md +469 -0
  12. data/docs/api/index.md +80 -7
  13. data/docs/api/mcp/client.md +129 -35
  14. data/docs/api/mcp/index.md +164 -23
  15. data/docs/api/mcp/server.md +27 -3
  16. data/docs/api/mcp/transports.md +94 -22
  17. data/docs/api/messages/index.md +26 -3
  18. data/docs/api/messages/text-message.md +33 -11
  19. data/docs/api/messages/tool-call-message.md +27 -4
  20. data/docs/api/messages/tool-result-message.md +23 -4
  21. data/docs/api/messages/user-message.md +45 -8
  22. data/docs/api/skills.md +519 -0
  23. data/docs/api/streaming/context.md +28 -5
  24. data/docs/api/streaming/index.md +57 -11
  25. data/docs/api/support.md +846 -0
  26. data/docs/architecture/core-concepts.md +79 -31
  27. data/docs/architecture/index.md +86 -11
  28. data/docs/architecture/message-flow.md +66 -29
  29. data/docs/architecture/network-orchestration.md +145 -38
  30. data/docs/architecture/robot-execution.md +172 -90
  31. data/docs/architecture/state-management.md +31 -12
  32. data/docs/concepts.md +176 -21
  33. data/docs/examples/basic-chat.md +72 -19
  34. data/docs/examples/index.md +117 -31
  35. data/docs/examples/mcp-server.md +154 -45
  36. data/docs/examples/multi-robot-network.md +91 -21
  37. data/docs/examples/tool-usage.md +104 -37
  38. data/docs/getting-started/configuration.md +284 -97
  39. data/docs/getting-started/installation.md +53 -41
  40. data/docs/getting-started/quick-start.md +51 -6
  41. data/docs/guides/building-robots.md +258 -50
  42. data/docs/guides/creating-networks.md +214 -30
  43. data/docs/guides/hooks.md +141 -54
  44. data/docs/guides/knowledge.md +35 -4
  45. data/docs/guides/mcp-integration.md +211 -44
  46. data/docs/guides/memory.md +103 -12
  47. data/docs/guides/observability.md +95 -47
  48. data/docs/guides/streaming.md +184 -125
  49. data/docs/guides/using-tools.md +237 -17
  50. data/docs/index.md +36 -4
  51. data/examples/01_simple_robot.rb +2 -2
  52. data/examples/02_tools.rb +14 -4
  53. data/examples/03_network.rb +12 -7
  54. data/examples/04_mcp.rb +11 -4
  55. data/examples/05_streaming.rb +8 -5
  56. data/examples/06_prompt_templates.rb +13 -9
  57. data/examples/07_network_memory.rb +5 -5
  58. data/examples/08_llm_config.rb +20 -15
  59. data/examples/09_chaining.rb +4 -4
  60. data/examples/11_network_introspection.rb +4 -4
  61. data/examples/12_message_bus.rb +2 -2
  62. data/examples/13_spawn.rb +2 -2
  63. data/examples/14_rusty_circuit/README.md +1 -0
  64. data/examples/14_rusty_circuit/comic.rb +7 -3
  65. data/examples/14_rusty_circuit/display.rb +14 -0
  66. data/examples/14_rusty_circuit/heckler.rb +8 -6
  67. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  68. data/examples/14_rusty_circuit/scout.rb +17 -10
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  71. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  72. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  73. data/examples/16_writers_room/writer.rb +22 -22
  74. data/examples/16_writers_room/writers_room.rb +2 -0
  75. data/examples/17_skills.rb +14 -13
  76. data/examples/18_rails/README.md +20 -1
  77. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  78. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  79. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  80. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  81. data/examples/19_token_tracking.rb +25 -9
  82. data/examples/20_circuit_breaker.rb +10 -7
  83. data/examples/21_learning_loop.rb +42 -16
  84. data/examples/22_context_compression.rb +23 -23
  85. data/examples/23_convergence.rb +24 -17
  86. data/examples/24_structured_delegation.rb +13 -8
  87. data/examples/25_history_search.rb +12 -8
  88. data/examples/27_incident_response/incident_response.rb +31 -13
  89. data/examples/28_mcp_discovery.rb +17 -13
  90. data/examples/29_ractor_tools.rb +4 -2
  91. data/examples/30_ractor_network.rb +22 -17
  92. data/examples/31_launch_assessment.rb +20 -9
  93. data/examples/32_newsletter_reader.rb +7 -2
  94. data/examples/33_stock_predictor.rb +34 -13
  95. data/examples/34_agentskills.rb +7 -3
  96. data/examples/35_hooks.rb +18 -8
  97. data/examples/README.md +199 -45
  98. data/examples/common.rb +79 -11
  99. data/examples/xyzzy.rb +8 -1
  100. data/lib/robot_lab/config.rb +10 -5
  101. data/lib/robot_lab/names.rb +402 -0
  102. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  103. data/lib/robot_lab/robot/bus_messaging.rb +16 -8
  104. data/lib/robot_lab/robot/template_rendering.rb +16 -3
  105. data/lib/robot_lab/robot.rb +23 -2
  106. data/lib/robot_lab/version.rb +1 -1
  107. data/lib/robot_lab.rb +21 -15
  108. data/mkdocs.yml +6 -1
  109. metadata +7 -2
@@ -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
 
@@ -86,7 +86,7 @@ Memory has reserved keys with special behavior:
86
86
  | `:results` | Array | Accumulated robot results |
87
87
  | `:messages` | Array | Conversation history |
88
88
  | `:session_id` | String | Session identifier for history persistence |
89
- | `:cache` | SemanticCache | Semantic cache (read-only after init) |
89
+ | `:cache` | `RubyLLM::SemanticCache` module, or `nil` | Semantic cache (read-only after init). Set at construction time; `nil` when built with `enable_cache: false` |
90
90
 
91
91
  ### The Data Hash
92
92
 
@@ -143,6 +143,16 @@ You can also clear just the custom keys without resetting reserved keys:
143
143
  robot.memory.clear # Clears non-reserved keys only
144
144
  ```
145
145
 
146
+ > [!WARNING]
147
+ > `reset_memory` resets **only the key-value store**. It does not touch the
148
+ > robot's conversation history — the chat still holds every prior message, and
149
+ > the LLM will still see them. Clearing the transcript is a separate call:
150
+ >
151
+ > ```ruby
152
+ > robot.reset_memory # key-value store only
153
+ > robot.clear_messages(keep_system: true) # conversation history only
154
+ > ```
155
+
146
156
  ## Network Shared Memory
147
157
 
148
158
  When robots run in a network, they share the network's memory instead of using their own inherent memory. This allows robots to communicate through shared state:
@@ -190,11 +200,40 @@ results = memory.get(:sentiment, :entities, :keywords, wait: 60)
190
200
  # => { sentiment: {...}, entities: [...], keywords: [...] }
191
201
  ```
192
202
 
193
- Each blocking wait is backed by an `IO.pipe` pair (`Waiter` class). Calling `signal` writes one byte per waiting caller, so all threads blocked on `IO.select` wake immediately. This design works cleanly with Ruby's Async fiber scheduler — no mutex contention or spurious wakeups.
203
+ > [!WARNING]
204
+ > A blocking `get` that expires **raises `RobotLab::AwaitTimeout`** — it does
205
+ > not return `nil`. (A *non*-blocking `get`, the default, returns `nil` for a
206
+ > missing key.) Wrap it if a missing value is survivable:
207
+ >
208
+ > ```ruby
209
+ > begin
210
+ > memory.get(:sentiment, wait: 30)
211
+ > rescue RobotLab::AwaitTimeout => e
212
+ > # => "Timeout waiting for :sentiment after 30 seconds"
213
+ > nil
214
+ > end
215
+ > ```
216
+
217
+ > [!CAUTION]
218
+ > With multiple keys the timeout is applied **per missing key**, not to the call
219
+ > as a whole. `memory.get(:a, :b, :c, wait: 30)` awaits the missing keys
220
+ > sequentially, each with its own fresh 30-second budget.
221
+ >
222
+ > It does not, however, spend the whole 90 seconds before reporting: the first
223
+ > key whose wait expires **raises `AwaitTimeout` immediately**, aborting the call
224
+ > — so the keys that were already resolved are lost along with the ones not yet
225
+ > attempted. The 90 seconds is the worst case only for a call that *succeeds*
226
+ > (each key arriving just under its own deadline).
227
+
228
+ Each blocking wait is backed by an `IO.pipe` pair (`Waiter` class). The waiting
229
+ side calls `@read_io.wait_readable(timeout)`; `signal` writes one byte per
230
+ waiting caller so every blocked waiter wakes exactly once. `wait_readable`
231
+ yields to Ruby's Async fiber scheduler when one is installed — no mutex
232
+ contention or spurious wakeups.
194
233
 
195
234
  ### Subscriptions
196
235
 
197
- Subscribe to key changes with asynchronous callbacks:
236
+ Subscribe to key changes:
198
237
 
199
238
  ```ruby
200
239
  # Subscribe to a single key
@@ -214,6 +253,24 @@ memory.subscribe_pattern("analysis:*") do |change|
214
253
  end
215
254
  ```
216
255
 
256
+ > [!IMPORTANT]
257
+ > Subscription callbacks are dispatched through `Async { }`. Inside a running
258
+ > Async reactor that defers them; **outside one — which is the normal case for
259
+ > plain Ruby, Rails request threads, and tests — the block runs synchronously
260
+ > on the writer's thread**, before `memory[:key] = value` returns:
261
+ >
262
+ > ```ruby
263
+ > order = []
264
+ > memory.subscribe(:k) { |c| order << "callback" }
265
+ > order << "before-set"
266
+ > memory[:k] = 1
267
+ > order << "after-set"
268
+ > order # => ["before-set", "callback", "after-set"]
269
+ > ```
270
+ >
271
+ > Keep subscription callbacks fast, and never assume the writer has moved on by
272
+ > the time your callback runs.
273
+
217
274
  ### Unsubscribe
218
275
 
219
276
  ```ruby
@@ -243,6 +300,8 @@ Memory can be exported and reconstructed:
243
300
  # Export to hash
244
301
  hash = robot.memory.to_h
245
302
  # => { data: {...}, results: [...], messages: [...], session_id: "...", custom: {...} }
303
+ # to_h is compacted: nil entries are dropped, so an unset :session_id (and the
304
+ # :cache key, which is never exported) simply will not appear.
246
305
 
247
306
  # Export to JSON
248
307
  json = robot.memory.to_json
@@ -279,34 +338,60 @@ memory[:stage] = "response"
279
338
 
280
339
  ### Caching Expensive Operations
281
340
 
341
+ A tool reaches memory through its owning robot. Subclass `RobotLab::Tool` (which
342
+ has a `robot` accessor) and attach an **instance constructed with `robot:`** —
343
+ that is the only supported way for tool code to read and write robot memory:
344
+
282
345
  ```ruby
283
- class FetchUser < RubyLLM::Tool
346
+ class FetchUser < RobotLab::Tool
284
347
  description "Fetch user details by ID"
285
348
  param :user_id, type: :string, desc: "User ID"
286
349
 
287
350
  def execute(user_id:)
288
- cache_key = "cache:user:#{user_id}"
351
+ cache_key = :"cache:user:#{user_id}"
289
352
 
290
- # Check robot's memory for cached value
291
- # (In practice, you'd access memory through the robot's context)
292
- cached = Thread.current[:robot_memory]&.[](cache_key.to_sym)
353
+ cached = robot&.memory&.[](cache_key)
293
354
  return cached if cached
294
355
 
295
- # Fetch and cache
296
356
  user = User.find(user_id).to_h
297
- Thread.current[:robot_memory]&.[]=(cache_key.to_sym, user)
357
+ robot&.memory&.[]=(cache_key, user)
298
358
  user
299
359
  end
300
360
  end
361
+
362
+ robot = RobotLab.build(name: "support", system_prompt: "...")
363
+ robot.local_tools << FetchUser.new(robot: robot)
364
+
365
+ robot.run("Look up user 42", tools: :inherit)
301
366
  ```
302
367
 
368
+ > [!WARNING]
369
+ > There is **no thread-local memory handle** in RobotLab — nothing anywhere in
370
+ > the codebase ever assigns `Thread.current[:robot_memory]`. A tool that reads
371
+ > it will always see `nil` and silently cache nothing. Go through `robot.memory`
372
+ > as above, and remember that `FetchUser.new` without `robot:` leaves `robot`
373
+ > `nil`.
374
+
303
375
  ### Semantic Caching
304
376
 
305
- Memory includes a semantic cache for LLM response caching:
377
+ Memory exposes a semantic cache for LLM response caching. It is on by default
378
+ and becomes `nil` when you opt out with `enable_cache: false`:
379
+
380
+ ```ruby
381
+ RobotLab.create_memory.cache # => RubyLLM::SemanticCache
382
+ RobotLab.create_memory(enable_cache: false).cache # => nil
383
+
384
+ RobotLab.build(name: "x", system_prompt: "…").memory.cache
385
+ # => RubyLLM::SemanticCache
386
+ RobotLab.build(name: "x", system_prompt: "…", enable_cache: false).memory.cache
387
+ # => nil
388
+ ```
389
+
390
+ Guard for `nil` in any code that might run against a cache-disabled memory.
306
391
 
307
392
  ```ruby
308
393
  # Access the semantic cache
309
- cache = robot.memory.cache # => RubyLLM::SemanticCache
394
+ cache = robot.memory.cache
310
395
 
311
396
  # Use it to cache semantically similar queries
312
397
  response = cache.fetch("What is Ruby?") do
@@ -314,6 +399,12 @@ response = cache.fetch("What is Ruby?") do
314
399
  end
315
400
  ```
316
401
 
402
+ > [!NOTE]
403
+ > `memory.cache` is the `RubyLLM::SemanticCache` **module itself**, not a
404
+ > per-memory instance. Its cache store, vector store, and configuration are
405
+ > process-global — two memories with `enable_cache: true` share one cache. It
406
+ > also embeds every query, so `fetch` costs an embedding call.
407
+
317
408
  ## Best Practices
318
409
 
319
410
  ### 1. Use Descriptive Keys