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.
- checksums.yaml +4 -4
- data/README.md +124 -64
- data/docs/api/core/index.md +41 -15
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +255 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +551 -61
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +110 -17
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +80 -7
- data/docs/api/mcp/client.md +129 -35
- data/docs/api/mcp/index.md +164 -23
- data/docs/api/mcp/server.md +27 -3
- data/docs/api/mcp/transports.md +94 -22
- data/docs/api/messages/index.md +26 -3
- data/docs/api/messages/text-message.md +33 -11
- data/docs/api/messages/tool-call-message.md +27 -4
- data/docs/api/messages/tool-result-message.md +23 -4
- data/docs/api/messages/user-message.md +45 -8
- data/docs/api/skills.md +519 -0
- data/docs/api/streaming/context.md +28 -5
- data/docs/api/streaming/index.md +57 -11
- data/docs/api/support.md +846 -0
- data/docs/architecture/core-concepts.md +79 -31
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +145 -38
- data/docs/architecture/robot-execution.md +172 -90
- data/docs/architecture/state-management.md +31 -12
- data/docs/concepts.md +176 -21
- data/docs/examples/basic-chat.md +72 -19
- data/docs/examples/index.md +117 -31
- data/docs/examples/mcp-server.md +154 -45
- data/docs/examples/multi-robot-network.md +91 -21
- data/docs/examples/tool-usage.md +104 -37
- data/docs/getting-started/configuration.md +284 -97
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +258 -50
- data/docs/guides/creating-networks.md +214 -30
- data/docs/guides/hooks.md +141 -54
- data/docs/guides/knowledge.md +35 -4
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +95 -47
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +237 -17
- data/docs/index.md +36 -4
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +12 -7
- data/examples/04_mcp.rb +11 -4
- data/examples/05_streaming.rb +8 -5
- data/examples/06_prompt_templates.rb +13 -9
- data/examples/07_network_memory.rb +5 -5
- data/examples/08_llm_config.rb +20 -15
- data/examples/09_chaining.rb +4 -4
- data/examples/11_network_introspection.rb +4 -4
- data/examples/12_message_bus.rb +2 -2
- data/examples/13_spawn.rb +2 -2
- data/examples/14_rusty_circuit/README.md +1 -0
- data/examples/14_rusty_circuit/comic.rb +7 -3
- data/examples/14_rusty_circuit/display.rb +14 -0
- data/examples/14_rusty_circuit/heckler.rb +8 -6
- data/examples/14_rusty_circuit/open_mic.rb +17 -6
- data/examples/14_rusty_circuit/scout.rb +17 -10
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
- data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
- data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
- data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
- data/examples/16_writers_room/writer.rb +22 -22
- data/examples/16_writers_room/writers_room.rb +2 -0
- data/examples/17_skills.rb +14 -13
- data/examples/18_rails/README.md +20 -1
- data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
- data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
- data/examples/18_rails/app/robots/chat_robot.rb +11 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
- data/examples/19_token_tracking.rb +25 -9
- data/examples/20_circuit_breaker.rb +10 -7
- data/examples/21_learning_loop.rb +42 -16
- data/examples/22_context_compression.rb +23 -23
- data/examples/23_convergence.rb +24 -17
- data/examples/24_structured_delegation.rb +13 -8
- data/examples/25_history_search.rb +12 -8
- data/examples/27_incident_response/incident_response.rb +31 -13
- data/examples/28_mcp_discovery.rb +17 -13
- data/examples/29_ractor_tools.rb +4 -2
- data/examples/30_ractor_network.rb +22 -17
- data/examples/31_launch_assessment.rb +20 -9
- data/examples/32_newsletter_reader.rb +7 -2
- data/examples/33_stock_predictor.rb +34 -13
- data/examples/34_agentskills.rb +7 -3
- data/examples/35_hooks.rb +18 -8
- data/examples/README.md +199 -45
- data/examples/common.rb +79 -11
- data/examples/xyzzy.rb +8 -1
- data/lib/robot_lab/config.rb +10 -5
- data/lib/robot_lab/names.rb +402 -0
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/bus_messaging.rb +16 -8
- data/lib/robot_lab/robot/template_rendering.rb +16 -3
- data/lib/robot_lab/robot.rb +23 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +21 -15
- data/mkdocs.yml +6 -1
- 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:
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|
|
408
|
-
|
|
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
|
-
"
|
|
531
|
+
"search github repos",
|
|
413
532
|
from: servers,
|
|
414
533
|
threshold: 0.05 # optional, default
|
|
415
534
|
)
|
|
416
|
-
# =>
|
|
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
|
-
|
|
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
|
|
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")
|
|
592
|
+
robot.run("First message", mcp: :inherit, tools: :inherit) # github connects, filesystem fails
|
|
449
593
|
# ... filesystem comes back up ...
|
|
450
|
-
robot.run("Second message")
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
|
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
|
-
| `
|
|
734
|
+
| `streamable-http` (or `http`) | Remote APIs, session-based |
|
|
568
735
|
|
|
569
736
|
## Next Steps
|
|
570
737
|
|
data/docs/guides/memory.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|