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
data/docs/api/mcp/server.md
CHANGED
|
@@ -11,6 +11,7 @@ This is **not** an MCP server implementation -- it is the configuration used by
|
|
|
11
11
|
```ruby
|
|
12
12
|
server = RobotLab::MCP::Server.new(
|
|
13
13
|
name: "filesystem",
|
|
14
|
+
description: "Read, write, and search local files and directories",
|
|
14
15
|
transport: { type: "stdio", command: "mcp-server-filesystem", args: ["--root", "/data"] }
|
|
15
16
|
)
|
|
16
17
|
|
|
@@ -25,7 +26,7 @@ server = RobotLab::MCP::Server.new(
|
|
|
25
26
|
## Constructor
|
|
26
27
|
|
|
27
28
|
```ruby
|
|
28
|
-
Server.new(name:, transport:, timeout: nil, **_extra)
|
|
29
|
+
Server.new(name:, transport:, timeout: nil, description: nil, **_extra)
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
**Parameters:**
|
|
@@ -35,6 +36,10 @@ Server.new(name:, transport:, timeout: nil, **_extra)
|
|
|
35
36
|
| `name` | `String` | **required** | Unique server identifier |
|
|
36
37
|
| `transport` | `Hash` | **required** | Transport configuration (must include `type`) |
|
|
37
38
|
| `timeout` | `Numeric`, `nil` | `15` | Request timeout in seconds. Values >= 1000 are auto-converted from milliseconds. Minimum 1 second |
|
|
39
|
+
| `description` | `String`, `nil` | `""` | Human-readable summary of what the server does; scored by `MCP::ServerDiscovery` |
|
|
40
|
+
|
|
41
|
+
Any other keyword is absorbed by `**_extra` and silently discarded, so a
|
|
42
|
+
misspelled key raises nothing.
|
|
38
43
|
|
|
39
44
|
**Raises:** `ArgumentError` if:
|
|
40
45
|
- The transport type is not one of the valid types
|
|
@@ -61,6 +66,18 @@ server.name # => String
|
|
|
61
66
|
|
|
62
67
|
The server identifier string.
|
|
63
68
|
|
|
69
|
+
### description
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
server.description # => String
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Human-readable summary of the server's capabilities. Always a String — a `nil`
|
|
76
|
+
or omitted `description:` becomes `""`. Used by `MCP::ServerDiscovery`, which
|
|
77
|
+
scores `"#{name} #{description}"` against the user's message when a robot is
|
|
78
|
+
built with `mcp_discovery: true`. Discovery falls back to the full server list
|
|
79
|
+
when no server in the list has a non-empty description.
|
|
80
|
+
|
|
64
81
|
### transport
|
|
65
82
|
|
|
66
83
|
```ruby
|
|
@@ -90,10 +107,14 @@ Returns the transport type string (e.g., `"stdio"`, `"ws"`, `"sse"`).
|
|
|
90
107
|
### to_h
|
|
91
108
|
|
|
92
109
|
```ruby
|
|
93
|
-
server.to_h
|
|
110
|
+
server.to_h
|
|
111
|
+
# => { name: "y", description: "", transport: { type: "sse", url: "http://x" }, timeout: 30.0 }
|
|
94
112
|
```
|
|
95
113
|
|
|
96
|
-
Converts the server configuration to a hash representation
|
|
114
|
+
Converts the server configuration to a hash representation. The hash is **not**
|
|
115
|
+
compacted — all four keys are always present, and `description` is `""` when it
|
|
116
|
+
was never set. Note that `timeout` is normalized to a Float unless it defaulted
|
|
117
|
+
(`DEFAULT_TIMEOUT` is the Integer `15`).
|
|
97
118
|
|
|
98
119
|
## Transport Configuration Options
|
|
99
120
|
|
|
@@ -207,6 +228,9 @@ robot = Robot.new(
|
|
|
207
228
|
system_prompt: "You help with development tasks.",
|
|
208
229
|
mcp: servers
|
|
209
230
|
)
|
|
231
|
+
|
|
232
|
+
# `run` defaults to `mcp: :none, tools: :none` — opt in per run
|
|
233
|
+
robot.run("What changed in the repo?", mcp: :inherit, tools: :inherit)
|
|
210
234
|
```
|
|
211
235
|
|
|
212
236
|
### Creating a Client from a Server
|
data/docs/api/mcp/transports.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
-
|
|
111
|
-
|
|
112
|
-
-
|
|
113
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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 # =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
data/docs/api/messages/index.md
CHANGED
|
@@ -27,22 +27,45 @@ Message (base)
|
|
|
27
27
|
├── ToolCallMessage - role + Array<ToolMessage>
|
|
28
28
|
└── ToolResultMessage - tool + result content
|
|
29
29
|
|
|
30
|
-
UserMessage - Standalone (
|
|
31
|
-
ToolMessage - Standalone (
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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>` |
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|